This document describes how stet's crates work together, the role of the display list, how output devices are plugged in, and how to extend the system with custom renderers.
stet has two independent pipelines that produce the same output type:
PostScript source ──► PS Interpreter ──► DisplayList ──► Output
PDF file ──► PDF Reader ──► DisplayList ──► Output
The display list is the meeting point. Both pipelines produce a
Vec<DisplayElement> per page, and every output format — PNG, PDF,
viewport render, or custom — consumes display lists.
Most rendering engines (including GhostScript and hayro) interpret and render in a single pass: the interpreter calls directly into the rasterizer, and once a page is drawn the source must be re-interpreted to draw it again. stet takes a fundamentally different approach — the interpreter produces an intermediate display list, and rendering is a separate step that consumes it.
This decoupling is the foundation of stet's architecture, and it enables capabilities that are difficult or impossible to retrofit onto a direct-rendering pipeline:
-
Viewport rendering. Render any rectangular region of a page at any zoom level without re-interpreting the source. The display list is data that can be queried, culled, and projected onto arbitrary viewports. This is what powers the desktop viewer's pan/zoom and the WASM viewer's on-demand tile rendering.
-
Multiple outputs from one interpretation. A single interpretation pass produces a display list that can be rasterized to PNG, converted to PDF, displayed in a viewer, or consumed by a custom output device — all without re-parsing the source.
-
Pipelined multi-page rendering. The interpreter can build page N+1's display list while the rasterizer is still rendering page N in the background. Display lists are self-contained values that can be handed off across threads.
-
Cancellation and streaming. Banded rendering processes the display list in chunks. Cancellation between bands is trivial — check a flag and bail. There is no deeply-nested interpreter state to unwind.
-
Layer toggling. Display-list elements are tagged with their PDF Optional Content Group (OCG) via
DisplayElement::OcgGroup, and a per-renderLayerSet(instet-graphics) overrides visibility at render time — no re-interpretation required. Seedocs/PDF-LAYERS.mdfor the runtime visibility model. -
Caching. Store the display list and re-render at different DPI, zoom, or viewport without re-parsing. The prepare/cache pipeline (bounding boxes, image conversion, ICC transforms) is computed once and reused across renders.
-
Custom output devices. The display list is a public, documented data structure. Building a new output format (SVG, TIFF, accessibility tree, diffing tool) is a matter of iterating over elements — no need to hook into interpreter internals.
The tradeoff is that display lists use memory and replay has per-element overhead. For pages with tens of thousands of elements, a direct-rendering engine avoids that cost. But the design ceiling is higher: the display list is data you can index, filter, cache, and parallelize over. A direct-rendering pipeline is a black box that runs start-to-finish.
See also the Display List Reference for complete field documentation and code examples.
A DisplayList is a flat sequence of DisplayElement variants that
describe everything on a page in device coordinates:
| Element | Description |
|---|---|
Fill { path, params } |
Fill a path (color, fill rule, transform) |
Stroke { path, params } |
Stroke a path (color, line width, dash, join, cap) |
Image { sample_data, params } |
Raster image (raw samples, dimensions, color space, transform) |
Text { params } |
Text run (font, size, position, glyphs) — used by PDF device |
Clip { path, params } |
Intersect clip region with a path |
InitClip |
Reset clip to full page |
ErasePage |
Clear the page to white |
AxialShading { params } |
Linear gradient |
RadialShading { params } |
Radial gradient |
MeshShading { params } |
Gouraud triangle mesh (Types 4/5) |
PatchShading { params } |
Coons/tensor patch mesh (Types 6/7) |
PatternFill { params } |
Tiled pattern |
Group { elements, params } |
Transparency group (blend mode, alpha, isolated/knockout, group CS) |
OcgGroup { elements, ocg_id, default_visible } |
PDF Optional Content Group (layer) |
SoftMasked { mask, content, params, mask_cache } |
Soft mask (luminosity or alpha mask) |
Paths are already transformed through the CTM to device coordinates at construction time. Colors are resolved. Images contain raw sample data in their native color space. This means a consumer of the display list does not need to understand PostScript graphics state — everything is explicit.
DPI matters at interpretation time. Because the display list is in device coordinates, the DPI chosen during interpretation determines the coordinate space of the display list. For best results, set the reference DPI to match the intended final output resolution. Rendering at a different DPI later (e.g., zooming in the viewer) scales the device-space coordinates.
┌──────────────────────────────────────────────────┐
PS source ──► Tokenizer ──► Eval Loop ──► Operators ──► Context │
│ │ │ │ │
│ (e_stack) (o_stack) (display_ │
│ list) │
└──────────────────────────────────────────┬───────┘
│
showpage ──► DisplayList
-
Tokenizer (
stet-core): Converts bytes to PostScript tokens (numbers, names, strings, operators, procedure bodies). -
Eval loop (
stet-engine): Processes the execution stack one object at a time. Executable names are looked up in the dictionary stack and dispatched; procedures are stepped through element by element. -
Operators (
stet-ops): ~376 native Rust functions that manipulate the operand stack, dictionary stack, graphics state, and display list. Path-building operators (moveto,lineto,curveto) construct paths on the graphics state. Painting operators (fill,stroke,image) appendDisplayElemententries to the display list.showpagefinalizes the page. The PDF-imaging extension operators (begintransparencygroup/beginsoftmask/beginoptionalcontentand their close partners — seedocs/PDF-EXTENSIONS.md) push capture frames ontoContext::group_stack, and paint emits route throughContext::current_display_list_mut()so they land in the innermost active scope rather than the page-level list. -
Context (
stet-core): Central state — operand stack, execution stack, dictionary stack, graphics state stack, VM stores (strings, arrays, dicts), save/restore stack, and the current display list. -
showpage: Takes the accumulated display list, passes it to the output device via
replay_and_show(), and captures a clone for viewport rendering if display list capture is enabled.
The PDF-imaging extension operators
(begintransparencygroup / endtransparencygroup,
beginsoftmask / endsoftmask / clearsoftmask,
beginoptionalcontent / endoptionalcontent) capture paint into
nested scopes via Context::group_stack: Vec<GroupFrame>. While
group_stack is non-empty, paint operators emit into the topmost
frame's display_list instead of the page-level
Context::display_list. The new current_display_list_mut() /
current_display_list() helpers route every paint emit site
uniformly — paint_ops, clip_ops, graphics_state_ops (clip
restoration after gsave/grestore/initgraphics), image_ops,
shading_ops, halftone_ops form replay, and show_ops
(including the Type 3 BuildChar capture window) all resolve to the
active list.
Each GroupFrame carries a GroupKind discriminating
transparency groups from soft-mask builders (SoftMask), implicit
masked-content scopes (Masked, opened by endsoftmask), and
optional-content (OCG) scopes. On close the frame emits a single
DisplayElement::Group / DisplayElement::SoftMasked /
DisplayElement::OcgGroup element into the next-innermost
target. Frames also snapshot gstate_stack.len() at open time so
grestore, restore, and the close operators refuse to unwind
across the boundary, and showpage / copypage raise rangecheck
while a frame is open. See docs/PDF-EXTENSIONS.md for the
operator reference.
The pdfmark operator (Adobe / GhostScript convention) feeds a
producer/consumer pipeline that's independent of the paint pipeline.
PostScript code issues [ … /TYPETAG pdfmark calls during
interpretation; the dispatcher in stet-ops::pdfmark_ops parses each
call into a [stet_core::pdfmark::PdfMarkRecord] and pushes it onto
Context::pdfmark_buffer. The PdfDevice reads that buffer at
end-of-job (in build_info_dict() and forthcoming outline / annotation
writers) and merges every record into the output PDF's catalog, info
dictionary, page tree, and so on. Non-PDF output devices simply never
read the buffer, which keeps pdfmark a true no-op for screen / viewer
rendering.
The buffer is document-global: save / restore do not roll it
back, because pdfmark records are catalog-scoped facts about the PDF
being produced rather than VM-level state.
The pdfmark operators (pdfmark, currentdistillerparams,
setdistillerparams) are not registered by the default
stet_ops::build_system_dict — only the PDF rendering paths
(stet::Interpreter::render_to_pdf and the CLI's run_pdf_mode) call
the separate register_pdf_authoring_ops. PostScript prologues that
branch on systemdict /pdfmark known therefore see Distiller-equivalent
semantics on the PDF output path and pre-Distiller semantics on the
screen / viewer / WASM paths — which matters for prologues like
FrameMaker 5.0's that switch between CMYK→setcmykcolor (designed for
print and stet's CMYK→ICC→sRGB conversion) and direct RGB
→setrgbcolor (designed for Distiller). See docs/PDFMARK-AUTHORING.md
for the operator reference.
Type-tag dispatch is layered: the operator scans the operand stack
to a [ mark and reads the type-tag, then the corresponding handler
(parse_docinfo, parse_outline, parse_annotation, …) walks the
payload as alternating /Name value pairs and emits one record. The
buffer's current_page counter — bumped by the showpage continuation
in device_ops.rs — lets page-scoped record handlers (/ANN,
forthcoming /PAGE) resolve to the page being assembled when the
producer omits an explicit /Page key.
Consumer side, the PdfDevice reads the buffer at end-of-job in
build_pdf. /Outlines is wired into /Catalog plus /PageMode /UseOutlines (see outline.rs). Annotations need a different shape:
/Annots arrays attach to per-page dicts, which means each annotation
has to know its target page's indirect ref before the page dict is
written. The output path therefore pre-allocates one indirect-object
number per page up front, then both the annotation writer
(annotations.rs::collect_per_page) and the per-page builder
(build_page) consume that same page_refs slice — annotations
reference page_refs[N-1] for /Page//Dest targets, and the page
dict's /Annots array references the annotation refs.
Named destinations (/DEST records) feed a single-leaf name tree in
names.rs referenced from /Catalog /Names. Page-box overrides
(/PAGE per-page, /PAGES document-wide) are layered into one
EffectivePageOverride per page by compute_page_overrides: per-page
records take precedence over document-wide defaults key-by-key, with
later records winning when the same key appears more than once at the
same scope. The result is a flat per-page table the page builder
consults when emitting /CropBox / /BleedBox / /TrimBox /
/ArtBox / /Rotate.
/VIEWERPREFERENCES records are similarly merged via
collect_viewer_prefs into one effective bag in metadata.rs. The
writer splits the bag in two: boolean entries (/HideToolbar,
/FitWindow, …) and name entries with allow-lists
(/NonFullScreenPageMode, /Direction) go into a single
/ViewerPreferences indirect object referenced from /Catalog, while
/PageLayout and /PageMode are validated against their respective
allow-lists and emitted as catalog-level entries proper. A producer-
supplied /PageMode wins over the UseOutlines default the writer
applies when /OUT records exist. /Metadata records emit one stream
object with /Type /Metadata /Subtype /XML referenced from
/Catalog /Metadata; the bytes are round-tripped verbatim and
written uncompressed (PDF spec requirement for grep-friendly
extraction).
File attachments (/EMBED records) and embedded JavaScript /
named-action passthrough are Phase 7 additions. /EMBED records
flow through attachments.rs::build_embedded_files_leaf, which emits
one flate-compressed /EmbeddedFile stream + one /Filespec dict
per record and groups them into a single-leaf /EmbeddedFiles name
tree. The names.rs writer splits in two: build_dests_leaf returns
the /Dests leaf ref, and write_names_root combines any
combination of /Dests + /EmbeddedFiles leaves into one
/Catalog /Names dict. JavaScript and named actions ride on
OutlineAction::JavaScript(String) / OutlineAction::Named(String)
variants — stet doesn't execute either, the bytes are round-tripped
verbatim. Page-level /AA (additional actions — /O open, /C
close) extends /PAGE and /PAGES: compute_page_overrides
layers per-page actions over document-wide defaults the same way it
layers page boxes. The shared outline::encode_action helper
emits the same on-the-wire shape for outline /Action, annotation
/A, and page /AA so the four action subtypes (URI, GoTo,
JavaScript, Named) stay in one place.
Form fields (/Subtype /Widget annotations and /FORM records) take
a different shape because the AcroForm needs the field tree to
predate any individual field object — /Parent and /Kids cross-
reference each other. form_fields.rs::write_form therefore owns
widget emission end-to-end: it splits widgets out of the standard
/ANN flow (the conventional annotation writer in annotations.rs
skips Widget records), pre-allocates one object number per widget,
groups widgets by canonical dotted /T to detect radio groups
(multiple widgets sharing a name), allocates additional refs for
every dotted-name prefix (implicit container parents), and emits
each object with the right merged-field-keys vs. radio-kid shape.
Single-widget leaves merge field keys (/T, /FT, /V, /Ff,
/MaxLen, …) into the annotation dict so the same object satisfies
both /AcroForm /Fields resolution and the page's /Annots array.
Radio kids omit field-level keys (those lift to a synthetic radio-
group parent). The shared push_field_level_keys helper is reused
between the leaf-merge path and the radio-parent path so encoding
stays consistent. /AcroForm /Fields lists exactly the root field
refs (top-level container parents and standalone single-widget root
fields); the writer also returns per-page widget refs that
pdf_device.rs merges into the existing per_page_annots arrays
before assembling page dicts. /Catalog /AcroForm is wired only
when at least one widget or a /FORM record exists.
PDF bytes ──► Parser ──► Resolver ──► Content Interpreter ──► DisplayList
│ │ │
(xref, objects) (deref) (PDF operators → elements)
The PDF reader (stet-pdf-reader) is completely independent of the PostScript
interpreter. It has no dependency on stet-core — only on stet-fonts
(for font parsing) and stet-graphics (for the DisplayList type).
-
Parser: Reads PDF cross-reference tables, decrypts if needed, decompresses object streams.
-
Resolver: Dereferences indirect object references, applies stream filters (Flate, LZW, DCT, JPX, CCITT, JBIG2, ASCII85, etc.).
-
Content interpreter: Walks the page content stream, interpreting PDF operators (
m,l,c,re,f,S,Do,Tj,TJ, etc.) and building aDisplayList. Handles:- Path construction and painting
- Color spaces (DeviceRGB, DeviceCMYK, ICCBased, Indexed, etc.)
- Images (inline and XObject) with all filter types
- Fonts (Type 1, TrueType, CFF, CID) with encoding and CMap resolution
- Transparency groups and soft masks
- Tiling patterns and shadings
Because both pipelines produce the same DisplayList type, every downstream
consumer (rasterizer, PDF writer, viewport renderer) works with both sources.
In addition to producing display lists for rendering, stet-pdf-reader
exposes the document's structural content as typed Rust data — for
indexers, accessibility tools, link extractors, format converters, and
anything else that needs to read a PDF rather than display it.
Every accessor parses lazily on first call (most behind OnceCell,
per-page annotations behind a Vec<OnceCell<...>>), so a 1000-page
document with bookmarks the caller never asks for doesn't pay to
parse them.
Accessor on PdfDocument |
Returns | Source |
|---|---|---|
metadata() |
&DocumentMetadata |
/Info dict + XMP /Metadata stream |
viewer_preferences() |
&ViewerPreferences |
catalog /ViewerPreferences + /PageLayout + /PageMode |
outline() |
&[OutlineItem] |
catalog /Outlines (recursive /First / /Next) |
destinations() |
&HashMap<String, Destination> |
merged legacy /Catalog /Dests + /Catalog /Names /Dests name tree |
resolve_named_destination(name) |
Option<Destination> |
shorthand for destinations().get(name) |
page_annotations(page) |
&[Annotation] |
per-page /Annots array; per-page OnceCell cache |
form() |
Option<&FormCatalog> |
catalog /AcroForm field tree |
page_boxes(page) |
PageBoxes (value) |
inheritable boxes from PageInfo, page-local boxes from the page dict |
embedded_files() |
&HashMap<String, EmbeddedFile> |
catalog /Names /EmbeddedFiles name tree |
embedded_file_bytes(name) |
Result<Vec<u8>, PdfError> |
on-demand stream decode |
layers() / layer(ocg_id) |
&[Layer] / Option<&Layer> |
catalog /OCProperties /OCGs |
configurations() / default_configuration() / configuration(idx) / layer_tree() |
&[Configuration] / Option<&Configuration> / LayerTree |
/OCProperties /D + /Configs, including /Order parsing |
layer_set_for(intent) |
LayerSet |
default config + /AS automatic-state rules for the intent |
parse_warnings() |
Ref<'_, [ParseWarning]> |
warnings emitted by the structural parsers |
The implementation lives in sibling modules under
crates/stet-pdf-reader/src/: metadata.rs, viewer_prefs.rs,
outline.rs, destination.rs, name_tree.rs (generic name-tree walker
reusable for embedded files and named destinations), annotations.rs,
form_fields.rs, page_boxes.rs, embedded_files.rs, the
layers/ module (metadata.rs, configuration.rs, ocmd.rs), plus
diagnostics.rs for the warning sink. Each module is independently
testable; cross-references are explicit (e.g., a terminal FormField
carries widget_obj_nums: Vec<u32> so a consumer can find the matching
widgets in page_annotations()).
Optional Content support spans crates: the display list (in
stet-graphics) carries each OcgGroup's OcgVisibility predicate
(Single / Membership / Expression) plus a per-variant default_visible
fallback baked from the document's default configuration. The
LayerSet evaluator (also in stet-graphics) lets a consumer
override visibility per OCG without re-parsing the PDF; stet-render
holds an Arc<LayerSet> on SkiaDevice and consults it during
banded / viewport replay. render_to_rgba_with_layers and
PdfDocument::render_page_to_rgba_with_layers are the
LayerSet-aware entry points.
Walkers that recurse over potentially-cyclic PDF structures
(outline tree, name trees, form-field tree) all bound traversal with a
visited-set + depth cap; truncations push a ParseWarning so the
absence of data is never silent.
See docs/PDF-READER-API.md for the public API
reference with examples per accessor, and
docs/PDF-LAYERS.md for the layer / OCG model
(types, LayerSet flow, OCMD semantics, /VE grammar,
intent-driven rendering).
| Device | Crate | Description |
|---|---|---|
SkiaDevice |
stet-render |
Rasterizes to RGBA via the vendored stet-tiny-skia fork. Banded rendering, clip caching, ICC color. |
PdfDevice |
stet-pdf |
Converts display lists to PDF with font embedding, image compression, shadings. |
NullDevice |
stet-core |
Discards all output. Used for test suites and display list capture. |
Output devices implement the OutputDevice trait from stet-core:
pub trait OutputDevice {
// Required:
fn fill_path(&mut self, path: &PsPath, params: &FillParams);
fn stroke_path(&mut self, path: &PsPath, params: &StrokeParams);
fn clip_path(&mut self, path: &PsPath, params: &ClipParams);
fn init_clip(&mut self);
fn erase_page(&mut self);
fn show_page(&mut self, output_path: &str) -> Result<(), String>;
fn draw_image(&mut self, sample_data: &[u8], params: &ImageParams);
fn page_size(&self) -> (u32, u32);
// Optional — default implementations provided:
fn paint_axial_shading(&mut self, params: &AxialShadingParams) {}
fn paint_radial_shading(&mut self, params: &RadialShadingParams) {}
fn paint_mesh_shading(&mut self, params: &MeshShadingParams) {}
fn paint_patch_shading(&mut self, params: &PatchShadingParams) {}
fn paint_pattern_fill(&mut self, params: &PatternFillParams) {}
fn set_trim_box(&mut self, llx: f64, lly: f64, urx: f64, ury: f64) {}
fn replay_and_show(&mut self, list: DisplayList, path: &str) -> Result<(), String>;
fn finish(&mut self) -> Result<(), String> { Ok(()) }
fn finish_with_context(&mut self, ctx: &Context) -> Result<(), String> { self.finish() }
fn as_any(&self) -> &dyn std::any::Any { &() }
}The trait has a default replay_and_show() that iterates over a display list
and dispatches each element to the appropriate method. Group and
SoftMasked elements are no-ops in the default — devices that care about
transparency (currently only SkiaDevice) override replay_and_show()
with their own banded renderer. OcgGroup is unwrapped inline: clip ops
always apply so downstream clips are consistent, but paint ops are gated
on default_visible. Text elements are ignored by rasterizers and only
consumed by PdfDevice.
set_trim_box is only meaningful for PDF output; other devices ignore
it. finish_with_context gives devices a chance to run context-aware
finalization (e.g., PDF output needs access to the font directory) and
defaults to finish(). as_any is the downcast escape hatch for
consumers that need the concrete device (e.g., reading PdfDevice's
in-memory bytes).
To add a new output format (e.g., SVG, TIFF, or a streaming protocol), you have two options:
Option A: Consume the display list directly (recommended for most cases)
Use render_to_display_list() and iterate over the elements yourself:
let mut interp = stet::Interpreter::new();
let pages = interp.render_to_display_list(ps_data, 300.0)?;
for page in &pages {
let mut svg = SvgBuilder::new(page.width, page.height);
for element in page.display_list.elements() {
match element {
DisplayElement::Fill { path, params } => svg.fill(path, params),
DisplayElement::Stroke { path, params } => svg.stroke(path, params),
// ... handle other element types
_ => {}
}
}
svg.save("output.svg")?;
}This is the simplest approach and doesn't require implementing any traits.
Option B: Implement OutputDevice (for tight integration with the interpreter)
Implement the OutputDevice trait and wire it as the device factory on the
interpreter context. This gives you streaming per-page output during
interpretation:
struct MyDevice { /* ... */ }
impl OutputDevice for MyDevice {
fn fill_path(&mut self, path: &PsPath, params: &FillParams) { /* ... */ }
fn stroke_path(&mut self, path: &PsPath, params: &StrokeParams) { /* ... */ }
// ... implement required methods
}
let mut interp = stet::Interpreter::new();
let ctx = interp.context();
ctx.device_factory = Some(Box::new(|w, h| {
Box::new(MyDevice::new(w, h))
}));The rasterizer in stet-render uses a multi-stage pipeline:
DisplayList
│
├──► prepare_display_list() Pre-compute bounding boxes, clip epochs
│ │
│ ▼
│ PreparedDisplayList
│ │
├──► build_icc_cache_for_list() Extract ICC profiles from images
│ │
│ ▼
│ IccCache
│ │
├──► ImageCache::build() Pre-convert images to RGBA
│ │
│ ▼
│ ImageCache
│ │
└──► render_region_prepared() Rasterize viewport region
│
▼
Vec<u8> (RGBA pixels)
The prepare/cache steps are done once per page. Viewport rendering can then be called repeatedly with different regions and zoom levels without re-interpreting or re-preparing.
For large pages, the rasterizer splits the output into horizontal bands sized to fit in L2 cache. Each band is rendered independently, enabling:
- Memory efficiency: Only one band's pixel buffer is live at a time
- Parallelism: Bands are rendered in parallel via rayon (when the
parallelfeature is enabled) - Streaming output: Bands can be written to a
PageSinkincrementally
The PostScript interpreter requires several resource files to function:
- Init scripts (4 files): Bootstrap the resource system, error handlers, font categories, and font name mappings
- Fonts (35 Type 1 files): URW equivalents of the standard PostScript fonts
- Encodings (3 files): StandardEncoding, ISOLatin1Encoding, SymbolEncoding
- CMap (2 files): Identity-H, Identity-V
- ProcSet (2 files): CIDInit, FontSetInit
- ICC profile (1 file): CC0-licensed CMYK → sRGB conversion profile
The stet facade crate embeds all 53 files (4.6 MB) via include_bytes!().
The CLI discovers them relative to the executable. The WASM build embeds
them in a virtual filesystem.
PDFs using CJK fonts with predefined encodings (e.g. GBK-EUC-H,
90ms-RKSJ-H, ETen-B5-H, KSCms-UHC-H) require CMap files that map
character codes to CIDs. These are not embedded in the binary — they
are loaded from the filesystem at runtime.
Search order:
STET_CMAP_DIRenvironment variable — point to a directory containing CMap files (flat layout, e.g.$STET_CMAP_DIR/GBK-EUC-H)~/.local/share/stet/CMap/— user-local conventional location- System poppler-data —
/usr/share/poppler/cMap/Adobe-*/(Linux), Homebrew paths (macOS) - System GhostScript —
/var/lib/ghostscript/CMap/etc.
Setup by platform:
- Linux:
sudo apt install poppler-data(Debian/Ubuntu) or equivalent - macOS:
brew install poppler-data - Windows / other: Download the
Adobe CMap resources
and either set
STET_CMAP_DIRor place them in~/.local/share/stet/CMap/
If a required CMap is not found, a warning is printed and CJK text in the affected font will not render correctly.
stet-tiny-skia-path Vendored fork of tiny-skia-path (BSD-3-Clause)
stet-tiny-skia Vendored fork of tiny-skia — rasterizer (BSD-3-Clause)
│
stet-fonts No dependencies (geometry, font parsing, encoding)
│
stet-graphics Color types, display list, ICC, mesh shading
│
stet-core PS types, Context, VM stores, tokenizer, OutputDevice trait
│
stet-ops ~376 operator implementations
│
stet-engine Eval loop, parse_and_exec, exec_sync
│
stet-render stet-tiny-skia rasterizer, viewport rendering, PNG output
stet-pdf PDF output device (display list → PDF)
stet-pdf-reader PDF parser (PDF → display list) — independent of stet-core
stet-viewer egui desktop viewer
stet (facade) Batteries-included API with embedded resources
stet-cli Binary entry point
stet-wasm WebAssembly bindings (excluded from the main workspace)
The two stet-tiny-skia* crates are vendored forks of the upstream
tiny-skia / tiny-skia-path crates. They carry their own BSD-3-Clause
licence (separate from the workspace's Apache-2.0 OR MIT) and are
modified for stet's specific rasterisation needs.
Not every stet crate is coupled to the PostScript interpreter. The workspace is layered so you can pick up just the pieces you need.
Zero PS-VM dependency — standalone building blocks:
| Crate | Internal deps | What it gives you |
|---|---|---|
stet-tiny-skia-path |
none | Bezier path primitives (vendored, BSD-3) |
stet-tiny-skia |
stet-tiny-skia-path | Software rasterizer (vendored, BSD-3) |
stet-fonts |
none | Type 1 / CFF / TrueType parsing, PsPath, Matrix, AGL, encodings |
stet-graphics |
stet-fonts | DisplayList, DeviceColor, IccCache, mesh-shading parser |
stet-pdf-reader |
stet-fonts, stet-graphics | PDF → DisplayList; no PS interpreter involved |
Output / rendering crates pull in stet-core for the OutputDevice
trait, but do not pull in the interpreter (stet-ops, stet-engine):
| Crate | Internal deps | What it gives you |
|---|---|---|
stet-render |
stet-fonts, stet-graphics, stet-core, stet-tiny-skia | DisplayList → RGBA (banded, viewport, ICC-aware) |
stet-pdf |
stet-fonts, stet-graphics, stet-core | DisplayList → PDF bytes |
Interpreter-only crates that rarely make sense to depend on in
isolation: stet-core (PS VM types), stet-ops (operator
implementations), stet-engine (eval loop), stet-viewer (egui desktop
viewer), stet-cli (binary), stet-wasm (wasm-bindgen glue).
Useful external combos:
- Pure PDF viewer / rasterizer:
stet-pdf-reader+stet-render— no PostScript VM involved. - PDF → PDF normaliser / rewriter:
stet-pdf-reader+stet-pdf. - Custom output format (SVG, TIFF, accessibility tree):
stet-pdf-reader+ your ownDisplayElementiterator — no rendering crate required at all. - Font-only workflows:
stet-fontson its own. - Batteries-included: the
stetfacade, which exposes PS interpretation, rendering, and PDF output behind feature flags.
The deliberate design choice worth highlighting: stet-pdf-reader has
no dependency on stet-core. The full PDF parser can be linked without
the PostScript VM, operator system, or eval loop — it produces the same
DisplayList type that the interpreter produces, and every downstream
consumer treats them identically.