A modern, open-source PostScript and PDF rendering engine written in pure Rust.
stet interprets PostScript (Level 3) and parses PDF files, rendering both to PNG images, PDF documents, or display lists through a unified pipeline. It can also render them in an interactive desktop viewer or a browser-based WASM viewer.
The PostScript interpreter and PDF reader are independent — use either or both — but they produce the same display list type, so every output device and rendering path works with both sources.
Try it at https://andycappdev.github.io/stet/ — drop a PS, EPS, or PDF file onto the page and stet renders it client-side, no install required.
This is a capability sampler, not a production viewer:
- No system fonts. A browser WASM sandbox can't reach the OS font directories, so font coverage is limited to the 35 URW fonts embedded in the binary plus whatever the source PDF embeds. Documents that expect a specific unembedded font will fall back to a URW substitute.
- Fixed zoom stops (fit, 75, 150, 300, 600 DPI) rather than arbitrary zoom — re-rasterizing at every scroll was too slow in single-threaded WASM to feel responsive.
- Single-threaded WASM. No rayon parallelism. Rendering is ~2-4× slower than native stet.
For production work, install the native binary below.
cargo install stet-cliThat builds the stet binary with the interactive viewer included. For a
headless build, or to work from a checkout, see
Building from Source.
To use stet as a library instead, see Library Usage.
stet auto-detects the input format — PostScript, EPS, or PDF — so the same commands work against any of them.
stet --device png document.ps # PostScript → PNG
stet --device pdf document.ps # PostScript → PDF
stet --device png document.pdf # PDF → PNG
stet document.ps # Interactive viewer
stet # REPL (viewer opens on first showpage)
stet inspect document.pdf # Print PDF structural summarySee the Viewer Guide for keyboard/mouse controls, zoom presets, minimap navigation, and drag-and-drop.
| Option | Description |
|---|---|
--device <TYPE> |
Output: png, pdf, viewer (default), null |
--dpi <DPI> |
Resolution (overrides device default; all built-in devices default to 300) |
--pages <RANGE> |
Page filter: 1, 1-5, 2,4,6 |
--page <SIZE> |
Page size for PostScript/EPS input: a named size (letter, legal, tabloid, ledger, executive, a0–a6, b4, b5) or WIDTHxHEIGHT in points, e.g. 620x1000. Add -landscape / -portrait to orient a named size. See Page size. |
--width <PX> / --height <PX> |
Scale PDF output to a pixel size (PDF input only; not combinable with --dpi or --page) |
--threads <N> |
Worker-thread count (default: 75 % of cores in viewer mode, 8 otherwise) |
--no-icc |
Disable ICC color management entirely |
--no-aa |
Disable anti-aliasing |
--output-profile <FILE> |
Generic ICC output profile (also used as source CMYK when --cmyk-profile is absent) |
--cmyk-profile <FILE> |
Pin the source CMYK ICC profile for CMYK→sRGB conversion |
--use-output-intent |
Honour the PDF's embedded OutputIntent as the source CMYK profile (default) |
--no-output-intent |
Ignore the PDF's embedded OutputIntent and use the system CMYK profile |
--bpc <on|off|auto> |
Black-point compensation (default: auto, currently equivalent to on) |
--password <PW> |
Password for encrypted PDF input |
--timeout <SECONDS> |
Abort a job running longer than this. No limit by default — PostScript is Turing-complete and legitimate jobs run for minutes. Set one for untrusted input |
--max-vm <MB> |
Ceiling on PostScript VM — strings, arrays, dictionaries (default 8192). Exceeding it raises VMerror instead of aborting. Separate from the renderer's image and band buffers, so it does not cap rendering resolution |
A PostScript program is rendered onto whatever page the device provides,
which defaults to US Letter. %%BoundingBox does not change that for a
plain %!PS document — DSC defines it as a description of the artwork's
extent, not a page-size request — so a program drawing outside Letter is
clipped unless it calls setpagedevice itself. Ghostscript behaves the same
way. --page is how you supply the size from outside:
stet --device png --page 620x1000 broadside.ps # explicit, in points
stet --device png --page a4 report.ps # named size
stet --device png --page a4-landscape report.ps # swap the dimensionsEPS is the exception: an EPSF header line or a .eps extension makes stet
honour %%BoundingBox automatically, and --page overrides it when both
apply. For PDF input the page size comes from the document, so --page is
rejected there — use --width / --height to scale the output instead.
Prints a human-readable summary of the document structure: metadata, page count and dimensions, outline (bookmark) tree, named destinations, per-page annotation counts by subtype, AcroForm field summary, embedded file attachments, and any parse warnings. Read-only; never writes to the file. See the PDF Reader API guide for the underlying library API.
$ stet inspect document.pdf
document.pdf
Metadata:
Title: Annual Report 2026
Author: Scott Bowman
Producer: stet 0.6.0
Created: 2026-04-27 12:00:00 UTC
Pages: 4
Page 1 size: 612.0 × 792.0 pt (8.50 × 11.00 in)
Outline (3 entries):
- Chapter 1 → page 1 (fit)
- Section 1.1 → page 2 (xyz)
- Chapter 2 → page 3 (fit)
Annotations: 3
Page 1: 2 Link
Page 3: 1 Highlight
Form: 4 terminal fields (4 widgets)
By kind: Button: 1, Text: 3
Pass --password <pw> for encrypted documents.
Unlike rendering engines that interpret and rasterize in a single pass, stet decouples the two: interpreters produce an intermediate display list, and rendering is a separate step that consumes it.
The display list is a public, iterable Rust data structure — a flat sequence of painting operations (fills, strokes, images, shadings, text, groups, clips) that you can walk directly. Emit SVG, extract structured text or metadata, diff two documents, transform the list before rendering, feed it to an analysis or ML pipeline, or build a custom renderer for a non-standard target. Few rendering engines expose this layer; stet treats it as the interchange format between parser and consumer — use it for anything you want.
This decoupling also enables viewport rendering at arbitrary zoom without re-interpretation, pipelined multi-page rendering, trivial cancellation between render bands, multiple output formats from a single interpretation pass, and display list caching for repeated renders at different resolutions. See the Architecture Guide and Display List Reference for details.
PDF Reader
- PDF 1.0–2.0 parsing with cross-reference tables and streams
- Encryption: RC4, AES-128, AES-256
- Filters: Flate, LZW, ASCII85, ASCIIHex, RunLength, DCT, JPXDecode (JPEG 2000), CCITTFax, JBIG2
- All PDF color spaces including ICCBased, Separation, DeviceN, Indexed
- Transparency groups (isolated, knockout), soft masks, blend modes
- Font rendering: Type 1, TrueType, CFF, CID with CMap/encoding support
- Annotations (form fields, stamps)
- No dependency on the PostScript interpreter — usable standalone
PDF Structural API (read-only, on top of the reader)
- Document metadata:
/Infodict + XMP/Metadatastream, with PDFDocEncoding / UTF-16BE / UTF-8-BOM string decoding and PDF-date parsing - Outline (bookmarks) tree with cycle protection and 64-level depth cap
- Typed [
Destination] and [Action] (URI, GoTo, GoToR, Named, JavaScript, SubmitForm, …) - Named-destination table merging legacy
/Destsand the modern/Names /Destsname tree - Per-page annotations as structured data — Link, Text, Highlight/Underline/Squiggly/StrikeOut, FreeText, Line, Square/Circle, Polygon/PolyLine, Ink, Stamp, Caret, FileAttachment, Popup
- AcroForm field tree with Button/Text/Choice/Signature kinds, Ff-bit decoding, dotted-path field names, widget cross-references back to annotations
- All 5 PDF page boxes (MediaBox, CropBox, BleedBox, TrimBox, ArtBox) plus rotation, user unit, presentation hints
- Embedded files (file attachments) with on-demand byte access and AfRelationship hints
- Full Optional Content (layer) model: per-layer metadata +
/Usagehints, hierarchy + alternate configurations +/RBGroups, runtimeLayerSetoverrides, OCMD/Ppolicies (AllOn/AnyOn/AllOff/AnyOff),/VEboolean expressions (/And//Or//Not), and intent-driven rendering (RenderIntent::View/Print/Export) that honours/ASautomatic-state rules - Parse warnings (
ParseWarning,ParsePhase,Severity) for cycles, dropped entries, and structural truncations
PostScript Interpreter
- Full PostScript Level 3 — 388 operators in
systemdict - Type 1, CFF/Type 2, TrueType, CID, and Type 3 font rendering
- All 7 shading types (axial, radial, Gouraud mesh, Coons/tensor patch)
- CIE color spaces (CIEBasedABC, CIEBasedA, CIEBasedDEF, CIEBasedDEFG)
- ICC color management with system CMYK profile auto-detection
- Filters: ASCII85, ASCIIHex, Flate, LZW, RunLength, DCT (JPEG), CCITTFax, JBIG2, JPX (JPEG 2000), eexec, SubFile
- PDF-imaging extensions: constant alpha + 16 blend modes, transparency groups, soft masks, optional-content layers — all driven from PostScript with stet-specific operators (reference)
- Resource system with embedded fonts (35 URW equivalents of the standard PS fonts)
- Interactive REPL with
executive
Rendering & Output
- RGBA rasterization via
stet-tiny-skia(banded, multi-threaded) - PDF output with native CMYK, spot colors, ICC profiles, transfer functions, halftone screens, overprint, and font embedding
- PNG file output
- Viewport rendering: render any region at any zoom from a stored display list
- Interactive desktop viewer (egui) with zoom, pan, minimap, drag-and-drop
- WASM viewer for browser-based rendering
- Display list as a public API for building custom output devices
Print Production
- Native CMYK color preservation (no lossy RGB round-trip)
- Separation and DeviceN (spot color) support with tint transforms
- Overprint and overprint mode (OPM) for both rasterizer simulation and PDF output
- Transfer functions, halftone screens, black generation, and undercolor removal carried per display element
- Rendering intent preservation
- Trim box support
Two issues that plague most PDF and PostScript renderers are handled correctly here:
- No seams on shared clip edges. Anti-aliased clip masks produce visible seams where adjacent clipped regions meet — the background bleeds through the softened edge. stet uses binary clip coverage instead, so AGM (Adobe Graphics Manager) EPS exports and other heavily-clipped artwork render without artifacts.
- Overprint simulation. Overprint is a print-workflow feature where the painter combines with the canvas below instead of replacing it. Correct simulation requires honest CMYK blend math, knockout group handling, and spot channel preservation — complexity that most on-screen renderers skip for historical reasons, treating overprint as a print-only concern. The result is that documents relying on it render visibly wrong everywhere: Firefox/pdf.js, Okular/poppler, Chromium/pdfium, and most others all show incorrect colours on PDF/X-4 files and the Ghent Workgroup (GWG) conformance test suite. Only Adobe Acrobat gets it right — and stet. Overprint support in stet is actively being hardened: the common cases and most GWG conformance files render correctly today, but stet is not yet bug-for-bug Acrobat parity on every edge case.
If you're doing prepress, proofing, or any color-separated output, these matter more than raw rendering speed.
[dependencies]
stet = "0.6"Upgrading? Cargo will not auto-bump across these pre-1.0 minors, each of which is a compatibility boundary. Two breaking surfaces so far: 0.5.0 widened
PsValue::Intfromi32toi64(somatcharms bind ani64, andas_i32()now returnsNoneout of range instead of alwaysSome), and 0.2.0 added#[non_exhaustive]to ~40 public match-surface enums (DisplayElement,PsError,Destination, the pdfmark records, …), somatchsites need a_ => { ... }wildcard arm. See CHANGELOG.md for the details of each.
let mut interp = stet::Interpreter::new();
let pages = interp.render(include_bytes!("document.ps"), 300.0)?;
// pages[0].rgba — RGBA pixel data (4 bytes/pixel, row-major)
// pages[0].width — pixel width at 300 DPIThe stet crate embeds all required resources (35 fonts, init scripts,
encodings, ICC color profiles) so there are no external files to ship.
The interpreter produces a display list for each page. The display list is the central data structure — every output format is derived from it.
| Method | Output | Use case |
|---|---|---|
render() |
RGBA pixels + display list | Rasterization, thumbnails, image export |
render_to_pdf() |
PDF document bytes | Print-quality vector output |
render_to_display_list() |
Display list only | Custom renderers, viewport rendering, analysis |
exec() |
Nothing | Test suites, scripting, data extraction |
// RGBA pixels at 300 DPI
let pages = interp.render(ps_data, 300.0)?;
// PDF output
let pdf_bytes = interp.render_to_pdf(ps_data, 300.0)?;
std::fs::write("output.pdf", &pdf_bytes)?;
// Display list for custom rendering
let pages = interp.render_to_display_list(ps_data, 300.0)?;
for page in &pages {
for element in page.display_list.elements() {
// Fill, Stroke, Image, Clip, Shading, Text, Group, ...
}
}An empty page list is not necessarily an error. The usual cause is a program
that painted marks and then ended without calling showpage — the page is
discarded and render() returns Ok(vec![]), which reads like a legitimate
result. warnings() distinguishes the two:
let pages = interp.render(ps_data, 300.0)?;
if pages.is_empty() {
for w in interp.warnings() {
eprintln!("warning: {}\n {}", w, w.hint());
}
}Warnings describe the most recent render call and are cleared at the start of
the next one. Programs that install nulldevice are exempt — that is the
PLRM-sanctioned way to ask for no output, so unemitted marks are expected
there. The CLI prints the same diagnostic to stderr.
Display lists support efficient viewport rendering — render any rectangular region at any zoom level without re-interpreting the PostScript:
let pages = interp.render_to_display_list(ps_data, 150.0)?;
let prepared = stet::prepare_display_list(&pages[0].display_list);
// Render just the top-left quadrant at 2x zoom
let rgba = stet::render_region_prepared(
&pages[0].display_list, &prepared,
0.0, 0.0, 500.0, 500.0, // viewport in device pixels
1000, 1000, // output pixel dimensions
150.0, None, None, false,
);stet-pdf-reader is a separate crate that parses PDF files and converts
pages to display lists. It does not depend on the PostScript interpreter —
it can be used standalone for PDF rendering:
use stet_pdf_reader::PdfDocument;
let doc = PdfDocument::from_bytes(&pdf_data)?;
for page in 0..doc.page_count() {
let display_list = doc.render_page(page, 300.0)?;
// Same DisplayList type as the PS interpreter produces
}The display lists from the PDF reader and PostScript interpreter are the
same type (DisplayList), so the same rendering pipeline handles both.
For layer-aware rendering, build a LayerSet and pass it through:
use stet_pdf_reader::{PdfDocument, RenderIntent, layers};
let doc = PdfDocument::from_bytes(&pdf_data)?;
// Hide print-only watermarks for an interactive view.
let view_set = doc.layer_set_for(RenderIntent::View);
let (rgba, w, h) = doc.render_page_to_rgba_with_layers(0, 150.0, &view_set)?;
// Or build a custom override set and toggle one layer.
let mut custom = layers::layer_set_from_document(&doc);
custom.set(/* ocg_id */ 42, false);
let (rgba, w, h) = doc.render_page_to_rgba_with_layers(0, 150.0, &custom)?;Full layer reference: docs/PDF-LAYERS.md.
Runnable example: cargo run --example render_pdf_layers -- some.pdf
(see crates/stet/examples/render_pdf_layers.rs).
The interpreter communicates with output backends through the OutputDevice
trait and the DisplayList. You can create custom output formats by
consuming the display list directly:
let pages = interp.render_to_display_list(ps_data, 300.0)?;
for page in &pages {
for element in page.display_list.elements() {
match element {
DisplayElement::Fill { path, params } => { /* vector fill */ }
DisplayElement::Stroke { path, params } => { /* vector stroke */ }
DisplayElement::Image { sample_data, params } => { /* raster image */ }
DisplayElement::Text { params } => { /* text with font/position */ }
DisplayElement::AxialShading { params } => { /* linear gradient */ }
// Clip, InitClip, RadialShading, MeshShading, PatchShading,
// PatternFill, Group, SoftMasked, ErasePage
_ => {}
}
}
}Display list elements include all the information needed to render: paths are already transformed to device coordinates, colors are resolved, images contain raw sample data, and fonts are referenced by entity ID with glyph paths available.
See the Architecture Guide for how the crates fit together, and the Display List Reference for complete element documentation.
| Feature | Default | Description |
|---|---|---|
render |
yes | RGBA pixel output via stet-render (stet-tiny-skia) |
pdf-output |
yes | PDF output via stet-pdf |
For the smallest dependency footprint (display lists only):
[dependencies]
stet = { version = "0.6", default-features = false }let mut interp = stet::Interpreter::builder()
.no_icc() // disable ICC color management
.suppress_output() // silence PS print/==/= operators
.build();stet requires Rust 1.88 or newer. This is verified on every push by a CI job that builds the workspace on exactly that toolchain, so the number above is a tested fact rather than an estimate.
The MSRV is not covered by semantic versioning: it may be raised in any minor release. Any change to it is called out in CHANGELOG.md.
┌──────────┐
│ stet-cli │ Binary: file I/O, REPL, arg parsing
└────┬─────┘
│
┌───────────────┼───────────────┐
v v v
┌──────────┐ ┌───────────┐ ┌─────────────┐
│ stet │ │stet-viewer│ │stet-pdf- │
│ (facade) │ │ (egui) │ │ reader │
└────┬─────┘ └─────┬─────┘ └──────┬──────┘
│ │ │
┌───────┼───────┐ │ │
v v v v │
┌────────┐┌──────┐┌───────────┐ │
│stet-pdf││stet- ││stet-render│ │
│(output)││engine││(tiny-skia)│ │
└───┬────┘└──┬───┘└─────┬─────┘ │
│ v │ │
│ ┌─────────┐ │ │
│ │stet-ops │ │ │
│ └────┬────┘ │ │
│ v │ │
├──►┌─────────┐◄────┘ │
│ │stet-core│ │
│ └────┬────┘ │
│ v │
│ ┌────────────┐◄───────────────────────┘
└──►│stet- │
│ graphics │
└─────┬──────┘
v
┌───────────┐
│stet-fonts │
└───────────┘
| Crate | Role |
|---|---|
stet |
Batteries-included library API (facade) |
stet-core |
Interpreter infrastructure: types, VM, tokenizer |
stet-ops |
PostScript operator implementations (388 registered in systemdict) |
stet-engine |
Execution engine (eval loop) |
stet-fonts |
Font parsing: Type 1, CFF/Type 2, TrueType |
stet-graphics |
Display list, color types, ICC color management |
stet-render |
Rasterization backend, PNG output |
stet-tiny-skia |
Modified tiny-skia fork with stet-specific optimizations (BSD-3-Clause) |
stet-tiny-skia-path |
Companion path/stroker crate for stet-tiny-skia (BSD-3-Clause) |
stet-pdf |
PDF output device (PS → PDF) |
stet-pdf-reader |
PDF input parser (PDF → display lists) |
stet-viewer |
Interactive egui/winit desktop viewer |
stet-cli |
Command-line interface and REPL |
stet-wasm |
WebAssembly bindings for browser rendering |
See the Architecture Guide for a detailed explanation of how these crates work together.
cargo build # Build all crates
cargo test # Run all tests (1130 passing)
cargo run -- file.ps # Run a PostScript file
cargo run # Interactive REPL
cargo clippy # LintThere is a second suite written in PostScript itself, exercising operator behaviour through the interpreter rather than through Rust. CI runs it as its own gate and it exits non-zero on failure:
cargo build --release
./target/release/stet unit_tests/ps_tests.ps # 68 files, 2813 assertionsOne-time setup after cloning, if you intend to push:
git config core.hooksPath .githooksThis enables .githooks/pre-push, which runs the same gates as CI's
lint job — cargo fmt --check, clippy errors, the #[non_exhaustive]
and VM level-zero-allocation audits, the version/MSRV cross-check, and
the CLI documentation cross-check, the tag-namespace audit — plus a
wasm32 cross-compile, which
catches the one class of error that passes everywhere else (usize is
32-bit there). The wasm step is skipped with a warning if the target is
not installed. A failing tree is caught before it reaches a remote rather
than a few minutes later in CI. Bypass a single
push with git push --no-verify.
Git does not enable this automatically, and it fails silently: without the command above, pushes simply succeed with nothing checked.
vX.Y.Z is reserved for releases — exactly that shape, with a matching
## [X.Y.Z] entry in CHANGELOG.md. Benchmark and experiment markers use a
perf/ or exp/ prefix instead:
git tag perf/aa-256x4 # not v0.6.1-perf
git tag exp/glyph-cacheGit allows the slash, those sort away from v*, and they can never be
mistaken for a version that shipped. scripts/check-tags.sh audits existing
tags and the pre-push hook rejects a malformed one before it reaches a
remote.
cd web && ./build.sh # Build WASM module
python3 serve.py # Serve at localhost:8000cargo test runs with no extra setup. The PDF visual-regression
harness (./pdf_visual_test.sh) needs a local corpus of test PDFs,
which the project doesn't ship (most are third-party). To reproduce
PDF-rendering bugs or check for regressions across a large corpus:
# 1. Fetch public test corpora into pdf_samples/ (clones with
# sparse-checkout so only the PDFs are pulled).
./scripts/fetch_test_pdfs.sh # all corpora
./scripts/fetch_test_pdfs.sh --list # see what's available
# 2. Generate your local baseline on a known-good commit
# (typically `main` before your changes).
./pdf_visual_test.sh --baseline
# 3. Switch to your feature branch and compare.
./pdf_visual_test.shAny PDFs you already have at the top level of pdf_samples/ keep
working; the fetcher drops new corpora into their own subdirs
(e.g. pdf_samples/pdfjs/) and the visual-test harness walks the
tree so both flat and subdir layouts are picked up. Corpus
subdirectories are gitignored — nothing third-party lands in a
commit.
stet is built and maintained by Scott Bowman and is free under Apache-2.0 OR MIT with no obligations. If your organization runs it in production, commercial support and consulting are available — priority fixes, integration help, and format-coverage work, including guarantees around the specific PostScript, EPS/AI, or PDF edge cases your pipeline depends on. Reach out at scott@bowmans.org.
- hayro — PDF renderer by
Laurenz Stampfl.
stet-pdf-readeruses the project'shayro-jpeg2000,hayro-jbig2, andhayro-ccittcrates for JPEG 2000, JBIG2, and CCITT-Fax stream decoding — the PDF filters that have no other pure-Rust implementation. Big thanks to the hayro project for factoring these out as reusable crates. - tiny-skia by
Yevhenii Reizner — a Skia subset
ported to Rust.
stet-tiny-skia/stet-tiny-skia-pathare modified forks (see each crate's README for the specific changes). - moxcms — pure-Rust ICC colour
management.
stet-graphicsuses it for CMYK↔sRGB conversion, image bulk transforms, and black-point compensation.
Apache-2.0 OR MIT