Releases: AndyCappDev/stet
Release list
stet 0.8.1
A malformed-font crash fix. Font data arrives embedded in a PDF or a
PostScript program, so it is attacker-controlled in the same way any PDF
object is, and the affected code shipped in every release to date.
Fixed
-
A malformed CFF font no longer panics the process. The Private DICT
operator carries[size, offset], and both were bounds-checked with
offset + size <= data.len(). CFF permits a real-number operand wherever an
integer is expected, andf64 as usizesaturates rather than wrapping, so a
font declaring a size of1e49reached that check asusize::MAXand
overflowed the add before it could reject anything. Shipped release builds
are affected, not only overflow-checked ones: with checks off the add wraps
to just below the offset, the<= data.len()bound then passes, and the
slice panics instead. Three sibling sites had the same shape: the per-FD
Private DICT of a CID-keyed font, and both local-Subr offsets, which are
added to their Private DICT offset. DICT operands destined for an offset or
length are now rejected up front unless they are finite, non-negative, and
inside what a CFF offset can address.Font data is attacker-controlled in the same way any PDF object is — it
arrives embedded in a PDF or in a PostScript program — so this was reachable
from an untrusted input. Found by the weekly fuzz job.
Changed
- Crate descriptions and keywords across all eleven published crates now name
what distinguishes stet — pure Rust, no C dependencies, prepress-grade CMYK
and spot colour — rather than restating the crate name.stet-pdf-reader
was previously described as "PDF parser and renderer", five words that fit
every PDF crate on the registry. - The
stetfacade no longer describes itself as a "PDF rendering engine". It
depends onstet-renderandstet-pdf(PDF output) and carries
stet-pdf-readeras a dev-dependency only, so it cannot read a PDF; the old
wording promised the one thing the crate does not do. PDF reading is
stet-pdf-reader.
Downloads
stet-<version>-x86_64-unknown-linux-musl.tar.gz is the one most people
want: statically linked, no glibc requirement, no runtime libraries, no GUI.
It is the right choice for servers, CI and containers.
The -gnu Linux build adds the desktop viewer and needs glibc 2.35 or newer
(Debian 12, Ubuntu 22.04 and later) plus X11 or Wayland and OpenGL to open a
window. Rendering to files works without them.
macOS: the binaries are unsigned, so opening a downloaded archive from
Finder trips Gatekeeper ("cannot be opened because the developer cannot be
verified"). Installing with curl avoids it entirely, because the quarantine
attribute is set by the downloading application and curl does not set one:
curl -L <asset-url> | tar xz
If you already downloaded it in a browser, clear the flag with
xattr -dr com.apple.quarantine stet.
Windows: SmartScreen will warn that the publisher is unknown; choose
More info -> Run anyway. Code signing certificates cost money and stet does
not have one.
Rendering is identical across these artifacts with one caveat: the musl
build's libm differs from glibc's in the last bits, which can shift
antialiasing coverage by a pixel or two on heavily curved PostScript
artwork (measured: 265 of 5.4M pixels on one sample, invisible in practice;
all PDF samples tested were byte-identical). Do not generate rendering
baselines with one build and compare them against another.
Verify a download against SHA256SUMS.
stet 0.8.0
The release you can download. Every release before this one shipped source
only, so trying stet meant installing a Rust toolchain and compiling a
workspace including a GUI stack — a barrier for exactly the people most
likely to want it, who are replacing a Ghostscript call in a pipeline. This
one attaches prebuilt binaries for Linux, macOS and Windows, and adds the
-o flag such a pipeline needs to say where output goes.
Added
-
Prebuilt binaries on every release. Linux (static musl and glibc+viewer),
macOS (Apple Silicon and Intel) and Windows, with aSHA256SUMSfile.The static musl build is the one most people want: no glibc version
requirement, no runtime libraries, no GUI, and nothing to install alongside
it — all 56 resources are embedded in the binary. The glibc build adds the
interactive viewer and needs glibc 2.35 or newer.The binaries are unsigned, and the release notes say so rather than letting
you find out: macOS Gatekeeper quarantines browser downloads (install with
curlinstead), and Windows SmartScreen warns about an unknown publisher. -
-o/--outputfor the CLI. Output no longer has to land next to the
input. The path is a template: a%din it is replaced by the page number
and%0Ndzero-pads (p-%03d.pnggivesp-001.png), while a path without
a token names a single file, written exactly as given with no extension
mangling. This is the shapegs -sOutputFile=uses, so an existing
shell-out pipeline can point at stet without restructuring.Naming is decided by the template rather than by the page count, which is
what makes PostScript and PDF input behave identically — a PostScript page
count is not knowable in advance, since pages appear asshowpageruns.
Where Ghostscript resolves that by opening the literal path once and
streaming every page into it (leaving several concatenated images in one
file, exit 0, no warning), stet stops with an error on the second page,
names a%03dform to use, and leaves page 1 intact. For PDF input the page
count is known up front, so the same mistake is refused before anything is
rendered.-otakes one input file, and is rejected for--device viewerand
--device null, which write no file.--device pdfcollects every page
into one file, so a%dtoken there is an error rather than being ignored.
Writing to stdout (-o -) is not implemented and says so.Default naming without
-ois unchanged:in-0001.pngfor PostScript,
doc.png/doc-001.pngfor PDF.
Fixed
-
stet-clidid not build with--no-default-features. Theviewer
feature was not genuinely optional:render_dropped_pdfnames viewer
channel types in its signature but carried no#[cfg], so a headless build
failed to compile even though both of its callers sit inside the
viewer-gatedrun_viewer_mode. This is the configuration a server, CI or
container install wants, and the one a static musl binary requires. -
A headless build's
--helpadvertised a viewer it does not have. It
listed--device viewer— which exits with "viewer not available" — and
claimed a barestetlaunches the viewer, when it starts the REPL. Both
lines are now conditional on the feature. -
A job that requested a non-zero exit status was reported as having
completed..quitwithcode 1and the new--outputfailure both end the
job throughquit, which printed "completed (quit)" regardless of the code
requested. A non-zero code now prints "FAILED". The process exit status was
already correct.
Downloads
stet-<version>-x86_64-unknown-linux-musl.tar.gz is the one most people
want: statically linked, no glibc requirement, no runtime libraries, no GUI.
It is the right choice for servers, CI and containers.
The -gnu Linux build adds the desktop viewer and needs glibc 2.35 or newer
(Debian 12, Ubuntu 22.04 and later) plus X11 or Wayland and OpenGL to open a
window. Rendering to files works without them.
macOS: the binaries are unsigned, so opening a downloaded archive from
Finder trips Gatekeeper ("cannot be opened because the developer cannot be
verified"). Installing with curl avoids it entirely, because the quarantine
attribute is set by the downloading application and curl does not set one:
curl -L <asset-url> | tar xz
If you already downloaded it in a browser, clear the flag with
xattr -dr com.apple.quarantine stet.
Windows: SmartScreen will warn that the publisher is unknown; choose
More info -> Run anyway. Code signing certificates cost money and stet does
not have one.
Rendering is identical across these artifacts with one caveat: the musl
build's libm differs from glibc's in the last bits, which can shift
antialiasing coverage by a pixel or two on heavily curved PostScript
artwork (measured: 265 of 5.4M pixels on one sample, invisible in practice;
all PDF samples tested were byte-identical). Do not generate rendering
baselines with one build and compare them against another.
Verify a download against SHA256SUMS.
stet 0.7.0 — PDF-only builds drop the PostScript VM
A dependency-hygiene release. A PDF-only consumer no longer compiles the
PostScript interpreter, a mesh-shading memory fault that scaled with core
count is fixed, and --device null works for the scripting use it is
documented for.
Breaking
One change, affecting only consumers who already opt out of stet-render's
default features. This is why the bump is minor rather than patch.
-
stet-renderwithdefault-features = falseno longer provides
SkiaDeviceor itsOutputDeviceimplementation. They now sit behind the
new default-onps-devicefeature. At 0.6.0, opting out of defaults dropped
onlyparallel; it now drops the device as well, so a consumer who declared
stet-render = { version = "0.6", default-features = false }to get a
single-threaded build will fail to compile against 0.7. Add the features you
want by name:stet-render = { version = "0.7", default-features = false, features = ["ps-device"] }
Nothing changes for consumers on default features, which is the overwhelming
majority:default = ["parallel", "ps-device"].
Changed
-
stet-pdf-readerno longer links the PostScript interpreter. Its
default features pulledstet-render, which depended unconditionally on
stet-corefor a single trait, so a PDF-only consumer compiled the whole
PostScript VM — contradicting the README's "PDF-only users don't pay for the
VM".stet-rendernow gatesSkiaDeviceand itsOutputDevice
implementation behind a new default-onps-devicefeature, and the reader
takes the crate without it.stet-pdf-reader's dependency closure is now
stet-fonts,stet-graphics,stet-renderand the two tiny-skia forks,
with PDF → RGBA rendering and parallelism unchanged.Consumers on default features are unaffected; see Breaking above for the
one case that is not.
Fixed
-
--device nullaborted PostScript programs that query the page device.
The flag is documented for "test / scripting use", but it installed the PLRM
nulldeviceoperator rather than a page device. That resets the CTM to
identity and leaves no page-device parameters, socurrentpagedevice /OutputDevice getraisedundefinedandinitmatrixhad no device matrix
to restore — stet's own PostScript test suite aborted partway through under
the flag meant for running it.--device nullnow installs a real page
device (a newOutputDevice/null.psresource) that reports itself as
/null, carries a page size and resolution like any other device, and still
produces no output: nothing is rasterized and/EndPagenever transmits a
page. The suite now passes identically onpng,pdfandnull, and CI
runs it onnullas well so this cannot regress unnoticed. -
Mesh-shaded PDFs could exhaust memory and fail to render, worse the more
cores the machine had.render_patch_shadingtriangulated every patch of
a shading regardless of which band was being drawn, and bands render
concurrently — one per thread — so the same triangle list was built once per
core. Cost scaled with core count rather than with the file. A prepress PDF
with a page-spanning Coons mesh at 300 dpi peaked at 2.1 GB on one thread and
31.4 GB on sixteen, and on a 24-core machine with less than ~50 GB of RAM it
did not render at all. Patches and triangles that cannot reach the band being
drawn are now skipped before they are built. The same file now renders in
0.94 s at 2.2 GB on 24 threads, and CPU time at 16 threads fell from 99.1 s
to 5.8 s — the surplus was duplicated work, so throughput improves alongside
memory. Rendered output is byte-identical.
stet 0.6.0 — hardening sweep, resource governor, decompression ceiling
A hardening release. The public Rust API is strictly additive — nothing was
removed and no signature changed — but the interpreter and the PDF reader now
refuse a number of inputs they previously accepted, which is why this is a
minor bump rather than a patch.
Breaking
Nothing here breaks compilation. Every item changes what happens to a file,
so audit these if you render input you do not control the shape of. All were
previously ways to abort the process, produce silently wrong output, or run
without bound; each is documented in full under Security below.
- Numeric overflow now raises
undefinedresult.1e308 1e308 mul
returnedinfandinf 0 multhen returnedNaN; both now error, per PLRM
and matching Ghostscript at its own boundary. A literal1e999no longer
scans asinf— it declines to be a number and becomes an undefined name.
A program that relied on either value will now stop. - Non-finite path coordinates raise
undefinedresult. Reachable through a
CTM composed past the representable range. Previously drew arbitrary output. VMerrornow halts execution.errordictregistered the handler under
a name that could never match, so the interpreter printed the error and
carried on past the failed allocation. Programs that appeared to survive an
allocation failure will now stop at it.- PostScript VM is capped at 8 GiB by default (
--max-vm,
setuserparams /MaxLocalVM). Jobs above it raiseVMerrorinstead of
growing until the OS intervenes. Separate from the renderer's image and band
buffers, so this does not cap rendering resolution. - Page size is bounded at 14400 pt (200 in) for PostScript input.
Resolution is deliberately not capped — 1200 dpi at 11x17 and larger is
ordinary prepress. - Image dimensions are bounded to 100,000 per side and 4e9 pixels total,
on both the PostScript and PDF paths, with/BitsPerComponentlimited to
1..=16. Sized for prepress, not for the sample corpus: a 60x40 inch page at
1200 dpi is 3.46 Gpx and is accepted. - Decompressed streams are bounded at 512 MiB, raised to whatever an image
raster or an embedded file declares for itself. A chain of decompression
filters no longer multiplies without limit. i64::MIN -1 idiv(andmod) raiseundefinedresultinstead of
panicking in release.
All 691 sample PDFs render byte-identically across every change above, the
6268-file PostScript corpus has the same 31 failures with an identical failing
set, and both visual suites pass.
Fixed
stet-corefailed to compile forwasm32-unknown-unknown. The 8 GiB VM
default is not a largeusizeon a 32-bit target but a const-evaluation
error. The default is now computed inu64, falling back tousize::MAX / 4
where 8 GiB does not fit.currentuserparamsreportedMaxLocalVMas 0, telling a program there
was no limit moments before it hit one.
Added
--timeout <SECONDS>and--max-vm <MB>CLI options.Context::set_timeout,Context::check_deadline,Context::check_vm_alloc,
Context::vm_bytes.stet_graphics::image_limits—MAX_IMAGE_DIMENSION,MAX_IMAGE_PIXELS,
MAX_BITS_PER_COMPONENT, and validators, shared by the PostScript and PDF
paths so two prepress-calibrated numbers cannot drift apart.stet_pdf_reader::filters::{DecodeBudget, decode_stream_bounded, MAX_DECODED_STREAM_BYTES}.decode_streamis unchanged.PdfError::NestingTooDeep.PdfErroris#[non_exhaustive], so this is
not a breaking change.stet-pdf-reader'sparse_object_at_depth,
parse_object_from_token_at_depth,parse_dict_body_at_depth, and
MAX_OBJECT_DEPTH. The existing depth-0 entry points are unchanged.scripts/check-cli-docs.sh, wired into CI and.githooks/pre-push: every
CLI option must appear in--helpand in both READMEs. The crates.io page
had been listing ten of nineteen options.crates/stet-cli/examples/profile_images.rsandprofile_alloc.rs—
per-stage memory attribution for the render path.- Five
cargo-fuzztargets infuzz/, with seeded corpora and a CI smoke gate. [profile.hardened]— release codegen with overflow checks left on.
Security
Five unbounded-recursion vectors in the PDF reader let a small crafted file
abort the process with a native stack overflow. A stack overflow is not a
panic, so none of these could be contained by catch_unwind — any program
rendering untrusted PDFs was exposed to an uncatchable denial of service.
This is the same vulnerability class as RUSTSEC-2026-0187 in lopdf.
Three were depth-based, and are now capped:
- Nested arrays and dictionaries in an object body (
lexer.rs).
parse_object_from_tokenandparse_dict_bodyare mutually recursive with
no bound, so[[[[…or<</A<</A…in any object exhausted the stack. Both
now thread a depth counter and stop atMAX_OBJECT_DEPTH(256), returning
the newPdfError::NestingTooDeep. The existingparse_object,
parse_object_from_token, andparse_dict_bodysignatures are unchanged
and enter at depth 0;parse_object_at_depth,
parse_object_from_token_at_depth, andparse_dict_body_at_depthare new. - Nested arrays in a content stream (
content/mod.rs). Content-stream
operands go through a separate parser,parse_inline_array, which needed
its own cap; it sharesMAX_OBJECT_DEPTH. - Nested procedures in a Type 4 (PostScript calculator) function
(resources/function.rs).parse_token_sequencerecurses once per{
body; now capped atMAX_CALC_DEPTH(64).
Two were cycle-based, which no depth cap alone can fix — the recursion is
infinite, so file size is irrelevant (both reproduce in under 1 KB):
- A Type 3 stitching function that reaches itself through
/Functions
(resources/function.rs), directly or through a ring of siblings.
PdfFunction::parsenow carries a set of the object numbers on the current
path and raisesPdfError::CircularReferenceon re-entry. It is a path set,
not a seen-set — entries are popped on the way out, so the legitimate shape
/Functions [7 0 R 7 0 R]still parses and renders. - A Type 3 CharProc that shows its own glyph (
content/mod.rs), directly
or through a pair of fonts naming each other. This path incremented the
interpreter'sdepthfield but never tested it: the only check lived in
handle_form_xobject. Type 3 glyphs and soft-mask groups — which likewise
re-enterinterpret_streamwithout passing through the Form XObject path —
now check it too. The bound,MAX_CONTENT_NESTING, is 20, the value the
Form XObject and pattern guards already used, so nothing that renders today
changes.
Added PdfError::NestingTooDeep. PdfError is #[non_exhaustive], so this
is not a breaking change.
Separately, image dictionary integers are now validated before use.
/Widthand/Heightwere cast withas u32and then multiplied in
u32. The product overflowed: 65537 x 65536 is2^32 + 65536, so
width * heightcame back as 65536 and the buffer allocated from it was far
smaller than the loops that filled it — an "attempt to multiply with
overflow" panic in debug builds, a silently undersized allocation in
release. The truncating cast was wrong on its own too:/Width 4294967297
became a 1-pixel image rather than an error.- The loop counts alone were a denial of service. Even where the
arithmetic survived, an 800-byte file declaring a 65537 x 65536 image spent
9-19 seconds in release. It now completes in 0.1 s. - Both are fixed by validating at the four points where an image dictionary is
read (image XObject, inline image,/SMask,/Mask): dimensions must be
positive and at mostMAX_IMAGE_DIMENSION(100,000), and their product at
mostMAX_IMAGE_PIXELS(4,000,000,000). The ceiling is sized for prepress
rather than for the sample corpus: a 40x28 inch press sheet at 600 dpi is
403M pixels, an A0 poster at 600 dpi 558M, and 60x40 inch grand format at
1200 dpi 3.46G, all of which a RIP must accept. It stays under 2^32 because
a dozen sites computewidth * heightinu32; anything multiplying
further by a component count uses saturatingusize. /BitsPerComponentis validated too, to 1..=16. It reaches
1u32 << bpcinexpand_bits_to_bytes, which panics in debug builds at 32
or more. That function now also reserves its three-way
width * height * componentsproduct inusize, which overflows au32
sooner than the two-way one does.
Filter and font parameters are now validated the same way.
/Columns,/Colors, and/BitsPerComponentin/DecodeParmswere
cast straight tousizeand multiplied. A zero in any of them drove
row_bytesto zero and reachedslice::chunks(0)— "chunk size must be
non-zero", which panics in release builds, not only debug. A negative
became astronomical under the cast and aborted the process on a 2.3-exabyte
reservation. Both are now range-checked with the row-size products computed
viachecked_mul; a malformed/DecodeParmsleaves the stream unchanged
rather than failing it, which is what the caller would have had if
/Predictorwere absent.- PS CIDFont header counts (
/CIDCount,/SubrCount,/FDBytes,
/GDBytes,/SDBytes)./SubrCountwas passed toVec::with_capacity
before the bounds check that would have rejected it, so a bogus count
panicked with "capacity overflow" in release as well as debug. Separately,
FDBytes + GDBytes == 0made the CID map size zero for any/CIDCount, so
the "binary data too short" check passed and an 8 TB reservation followed
from a 700-byte file. Counts are now bounded against the binary segment
actually present rather than against a fixed ceiling, the byte-widths are
capped at 8, and the reservation happens after the check.
Neither bound rejects anything real: all 691 sample PDFs were re-...
stet 0.5.0 — 64-bit integers, Type 3 font fixes, --page
Minor release. PostScript integers are now 64-bit, which fixes the standard
LCG idiom that programs use for pseudo-randomness and is the reason this is a
breaking release rather than a patch. Three Type 3 font defects and a
clippath coordinate-space bug are also fixed, and the CLI gains --page.
Breaking
Downstream Rust code that reads PostScript integers needs attention; nothing
in the PostScript language surface changed incompatibly.
PsValue::Intnow carriesi64instead ofi32. A
match obj.value { PsValue::Int(v) => … }binds ani64, so any use site
that needs ani32no longer compiles.DictKey::IntandToken::Int
widened with it, as didContext::rand_seed.PsObject::as_i32()now range-checks. It returnsNonefor a value
outsidei32, where before it always returnedSomefor an integer. This
is the one change with no compiler error behind it — audit call sites that
treatNoneas "not an integer". Use the newas_i64()for the full
range; keepas_i32()where the value is genuinely bounded (array and
string indices, character codes), since a too-large value should fail those
callers' range checks rather than wrap into a valid-looking index.PsObject::int()takesimpl Into<i64>. Calls are unaffected; only
code coercing it to afn(i32) -> PsObjectpointer breaks.
Fixed
- Type 3 fonts supplying only
BuildGlyphraisedinvalidfont. The show
path requiredBuildCharunconditionally and pushed the character code.
PLRM 5.7 listsBuildGlyphas preferred and makesBuildCharrequired only
"for LanguageLevel 1 or ifBuildGlyphis absent", so such a font is
well-formed and must be handed the character name fromEncoding.
Ghostscript renders these; stet refused them.xshow/yshow/xyshowhad
the identical defect. stringwidthraisedinvalidfonton every Type 3 font,BuildChar
ones included. It branched for font types 2, 0 and 42 and then fell through
to the Type 1 path, which looks forCharStrings— a Type 3 font has none.
There is no width table to consult: the width is whatever the build
procedure handssetcachedevice/setcharwidth, so the procedure now runs
inside agsave/grestorewith its marks drained, and measuring paints
nothing.glyphshowraisedinvalidfonton every Type 3 font. It read
FontTypebut never branched on 3, going straight to theCharStrings
lookup. Per the PLRM it now invokesBuildGlyphwith the name directly —
bypassingEncoding, which is what letsglyphshowreach glyphs no
character code maps to — or, with onlyBuildChar, reverse-searches
Encodingfor the name and pushes the array index, retrying with
/.notdefand raisinginvalidfontonly when neither is encoded.- PostScript integers are now 64-bit, matching Ghostscript, which fixes the
standard LCG idiom PostScript programs use for pseudo-randomness:
/seed seed 1103515245 mul 12345 add 2147483648 mod def. On 32-bit integers
the product overflowed, promoted to a real, andmod— which is
integer-only — raisedtypecheck. Widening only the real fallback would not
have fixed it: the product needs 55 bits and a real carries 53, so the seed
would have come out one too high and every later draw would have diverged
silently from what other interpreters produce. PLRM Appendix B's 32-bit
range is listed under "Typical Limits" for interpreters "running on 32-bit
machines" which "do not necessarily apply to all PostScript
implementations", so this is not a conformance change. Overflow past the
64-bit range still promotes to a real.bitshiftis correspondingly 64-bit
wide, andcviaccepts the wider range. clippathreturned the page in device space instead of current user
space, so a program that had transformed its coordinate system got a clip
rectangle dragged along with the transform. Theclippath fillidiom for
painting a background then filled an offset region and left part of the page
bare — visible in the tiger EPS, whose grey backdrop was displaced by its
%%BoundingBoxorigin. The default clip is a fixed region of the device, so
it is now derived with the default CTM;pathbboxandfillmap it back
through the current CTM, which is what puts it in user space for the caller.
Ghostscript's values now match exactly under translate, scale and rotate.cvion a long integer-valued string was off by one. The string scanner
returnedf64, so(22358003463039195) cvicame back as...196after the
round trip through a 53-bit mantissa. Integer literals now stay integral.
Added
- The CLI reports a page that was painted but never shown. A program that
paints marks and then ends without a matchingshowpageleaves them on a
page the device is never asked to emit, and the page is discarded. That is
correct — it is what the PLRM specifies and what Ghostscript's file devices
do — but it was indistinguishable from a broken renderer: no file appeared
and nothing said why. The warning distinguishes a program that produced no
output at all from one that lost only its trailing page. Interpreter::warnings()surfaces the same diagnostic to library
callers, where the silence was worse:render()returnedOk(vec![]), an
empty page list that reads as a legitimate result. New public types
ExecWarningandExecWarningKindinstet::diagnostics; the CLI shares
the detector, so the two cannot drift. Programs that installnulldevice
are exempt — that is the PLRM-sanctioned way to ask for no output, so marks
left unemitted are the point rather than a mistake.--pagesets the page size for PostScript/EPS input — a named size
(letter,legal,tabloid,ledger,executive,a0-a6,b4,b5)
orWIDTHxHEIGHTin points, with an optional-landscape/-portrait
suffix that swaps the dimensions. There was previously no way to render a
plain%!PSprogram whose artwork is larger than the default page:
%%BoundingBoxsets the page only for EPS — for a non-EPS document DSC
makes it a description of the artwork's extent, not a page-size request, so
both stet and Ghostscript fall back to US Letter and clip.--page
overrides an EPS%%BoundingBoxwhen both apply, and is rejected for PDF
input, whose pages carry their own size.GlyphCache::by_type3_name, a name-keyed Type 3 glyph cache.glyphshow
can name a glyph that no character code maps to, which leaves nothing for
the existing code-keyed cache to key on.
WebAssembly
stet-wasm0.2.0. Its JavaScript API is unchanged, but the browser
build inherits everything above, so rendering output moves:clippath
backgrounds fill the page, Type 3 fonts that previously raised
invalidfontrender, and PostScript programs using the standard LCG for
pseudo-randomness run instead of failing. A minor rather than a patch
because the pixels change, not because anything you call does.
stet 0.4.1 — systemparams conformance fixes
Patch release. Two currentsystemparams values were wrong in every release
up to and including 0.4.0, and PDFs now record which build wrote them. No API
changes; no rendering changes.
Fixed
-
/PrinterNamereturned(stetIE)instead of(stet). The string was
allocated with the four bytes ofstetbut declared six bytes long, so
reading it ran two bytes into the next allocation — which happened to be
/RealFormat. Not memory-unsafe (the arena is a single buffer), but it put
a neighbouring allocation's bytes into a value any PostScript program can
read, and the value would have changed as soon as allocation order did. -
/RealFormatreturned(IEE)instead of(IEEE)— a missingEin
the literal, independent of the overrun above. The PLRM specifies this key
as naming the internal real representation, and Ghostscript reports
(IEEE).Both lengths are now derived from the literal rather than written out
twice, which is what allowed them to disagree. The other three
allocate-then-declare sites inContext::newwere audited and are correct.
Regression coverage inunit_tests/interpreter_param_tests.psasserts
lengths as well as contents — the contents alone read plausibly, and it was
the overrun that made them wrong.
Changed
- PDF
/Producernow carries the version, e.g.stet 0.4.1, where it
previously wrote a barestet. Every other producer does this —
Ghostscript writesGPL Ghostscript 10.05.1, Distiller
Acrobat Distiller 20.0— and it is the first thing checked when a
prepress shop is chasing a rendering difference between two files. A
pdfmark/DOCINFO /Produceroverride still takes precedence; this
changes only the default. Note that this alters bytes in the/Infodict
of every PDF stet writes, at every release. - Documented MSRV corrected to Rust 1.88. The README badge had claimed
1.85 since it was added — a number inferred fromedition = "2024"and
never compiled against. The real floor is 1.88: first-party code uses
let-chains in 282 places across nine crates,jpeg-encoderdeclares 1.87,
andfearless_simddeclares 1.86. Nothing about what stet requires has
changed; only the claim is now true.rust-version = "1.88"is declared in
[workspace.package]and inherited by all eleven first-party crates, so
cargo now reports a clear "requires rustc 1.88" instead of failing with a
confusing edition parse error on an older toolchain. - A pinned
MSRV 1.88CI job builds the workspace on exactly that toolchain
on every push, andscripts/check-release-versions.shnow ties the README
badge, the README prose, and the CI job's pin torust-versionso the four
cannot drift apart. The script also asserts every publishable crate
declares an MSRV, so none can reach crates.io without one. - Switched to
resolver = "3"(MSRV-aware dependency resolution). Cargo
now prefers dependency versions compatible with the declared
rust-versionrather than always taking the newest, so a routine
cargo updatecan no longer silently break the floor. The lockfile was
byte-identical on adoption, but this is already doing work: it holds back
hayro-jpeg20000.4.0 (needs 1.92) andmoxcms0.9.0 (needs 1.89).
Note for downstream users
Releases up to and including 0.4.0 published with no rust-version in their
manifests, so crates.io and docs.rs show no MSRV for them and cargo cannot
warn an old toolchain before it fails to compile. Published versions are
immutable; this release is the first to carry the metadata.
stet 0.4.0 — VM reclamation and PostScript conformance
Minor release focused on memory and PostScript conformance. restore
now reclaims local VM instead of only reverting values, several
long-standing PLRM 3.7.2/3.7.3 violations in the interpreter's own writes
are fixed, and a 6384-file PostScript corpus sweep drove the job-abort
count from 1158 to 147 — of which 116 fail identically in Ghostscript,
leaving 31 that are genuinely ours.
This is a 0.x minor bump. No public API was removed; Context gained
fields, which is source-breaking only for code constructing one
literally (it has no public constructor other than Context::new).
Highlights
restorereclaims local VM. Allocations made above asave's
high-water mark are released rather than left resident. A
save/restore loop that peaked at 2108 MB now peaks at 74 MB.restoreactually reverts what it is supposed to. Several
interpreter-internal writes bypassed copy-on-write, sorestorehad
no backup to revert to:defineresource,FontDirectory,reverse,
execstackanddictstack. This was a live PLRM 3.7.3 violation, not
a theoretical one.- Global/local VM enforcement is no longer silently disabled.
.errordid not restoresetglobal, so any caught error left the
interpreter in whatever VM mode the failing code had set. - Eleven interpreter defects found by a 6384-file corpus sweep, each
A/B'd against the previous sweep with no regressions: procedure data
sources, the Pattern colour space,bindon nested procedures,
array-form colour spaces,cvi/cvrstring conversion, CIDFontType 0
StartData, 16-bit image samples, EOI-less JPEG,shareddict/scheck,
self-registering resource files,rectclip,cshow, andcharpathon
Type 3 fonts.
Added
charpathsupport for Type 3 fonts: the glyph procedure runs without
marking the page and the paths it would have painted become part of
the current path.- The
Patterncolour space —setcolorspace/setcolor/currentcolor
with a pattern, including the uncoloured (PaintType 2) base-space form. shareddictandscheckinsystemdict.- 16 bits per component for
image/imagemask/colorimage. stet_core::vm_auditand--example audit_vm: a machine check for
dangling references and PLRM 3.7.2 global/local violations.- PostScript corpus build and sweep tooling under
scripts/.
Fixed
restorenow releases local VM allocated above the save mark, and
copy-on-writes the dictionaries and arrays it is required to revert.- Every allocation is stamped with its save level and VM mode, so
restorecan tell a surviving reference from a dangling one and raise
invalidrestorewhen PLRM requires it. .errorrestores the VM allocation mode; page-device arrays are
allocated in the page device's own VM and deep-copied on promotion.filteraccepts procedure data sources everywhere, runs them when the
data is read rather than atfiltertime, and honours SubFileDecode's
EOD semantics.bindmarks nested procedures read-only per PLRM, and terminates on
cyclic procedure graphs.setcolorspaceaccepts array-form base and alternate colour spaces.cviandcvrconvert strings through the scanner, as PLRM specifies.CIDInit'sStartDataconsumes its charstring blob instead of
leaving it to be scanned as tokens.- DCTDecode accepts a JPEG stream that ends before its EOI marker.
- Resource files that register themselves are loaded once, through a
shared.LoadResource, socomposefontcan find a CMap on disk. rectcliptakes every rectangle in a multi-rectangle argument, and
accepts an empty array.cshowhands its procedure the character code, not the CID.- CIE decode tables are memoised, and 8-bit image samples are no longer
copied inunpack_samples— together these took the corpus from 24
out-of-memory jobs to none.
stet 0.3.0 — PDF→PDF round-trip
Minor release adding PDF→PDF round-trip through the PDF output
device. A PDF parsed by stet-pdf-reader into the display list can now
be re-emitted as PDF with its prepress semantics preserved — spot
(Separation/DeviceN) colors, ICCBased spaces, overprint, soft masks,
transparency groups, optional-content layers, and /OutputIntents all
survive the round-trip rather than collapsing to flat process color.
This is a 0.x minor bump. The document-structure IR moved from
stet-core into stet-graphics (still re-exported by stet-core), so
code that reaches those types through stet-core is unaffected.
Highlights
- PDF→PDF round-trip —
--device pdfon a PDF input now routes
throughPdfDocument→PdfDevice, so a PDF can be read to the
display list and written back out as PDF (previously only PS/EPS
input reachedPdfDevice). - Prepress color preserved — Separation/DeviceN spot colors (and
their base spaces, with DeviceGray promotion), ICCBased fill/stroke
spaces, and ICCBased bases inside Indexed image spaces all round-trip;
/Catalog /OutputIntentsis carried through so the CMYK-driving ICC
profile is retained. - Overprint preserved —
/OP//opforced on the first paint of
each content stream,/OPMcarried through the display list, and
overprint state emitted for Image and Shading paints. - Transparency & layers emitted from the display list —
DisplayElement::Groupas a Form XObject,SoftMaskedwith per-paint
alpha/blend, andDisplayElement::OcgGroupwith/OCProperties
optional-content groups.
Added
- PDF output: Separation/DeviceN spot color + base round-trip,
Separation/DeviceN shadings and spot imagemasks, and CMYK imagemask
fill preservation. - PDF output: ICCBased fill/stroke color-space round-trip and ICCBased
base preservation inside Indexed image color spaces. - PDF output:
/Catalog /OutputIntentsround-trip through PDF→PDF. - PDF output:
Group→ Form XObject,SoftMasked+ per-paint
alpha/blend,OcgGroup→/OCProperties, per-paint alpha on the
Image and Shading writer arms, overprint state on Image/Shading, and
/OPMround-trip.
Changed
- Document-structure IR lifted from
stet-coreintostet-graphics
(re-exported bystet-core). - PDF writer: graphics-state tracker restored across
q/Q
boundaries; replayed clips collapsed so a round-trip no longer grows
the display list; implicit page-box clip skipped on PDF→PDF. stet-pdf-reader: spot tint-transform table cached per content
stream.- README: added a Commercial Support section.
Fixed
- Removed a dead
emit_fill_color_rgbhelper, superseded by the
DeviceColor-aware imagemask fill path (cleared adead_codewarning).
Crates published at 0.3.0
stet, stet-cli, stet-fonts, stet-graphics, stet-core,
stet-ops, stet-engine, stet-render, stet-viewer,
stet-pdf-reader, stet-pdf. The vendored stet-tiny-skia /
stet-tiny-skia-path forks remain at 0.11.4. stet-wasm remains at
0.1.1 (excluded from crates.io, independent cadence).
stet 0.2.1 — PDF/X CMYK rendering correctness
[0.2.1] — 2026-05-09
Patch release focused on PDF/X CMYK rendering correctness against the
Ghent PDF Output Suite (GWG)
test corpus. Fixes a family of bugs where ICCBased / Lab / DeviceN
fills, images, and transparency groups didn't round-trip through the
document's /OutputIntents profile correctly, producing visible
"X" markers in calibration swatches that should render uniform.
This is an additive, non-breaking release. Downstream code does
not need to change. New public API on stet-graphics::IccCache and
a new rendering_intent: u8 field on stet-graphics::ImageParams
are documented below.
Highlights
- GWG 13.3 — ICCBased RGB paints with
/OP trueno longer route
into the custom-spot overprint path; per PDF 1.7 §11.7.4.5 they
paint as if/OPwere false. - GWG 16.1 — per-intent PDF/X proofing chain (
source A2B → PCS → OI B2A → CMYK) is built for every registered ICCBased RGB
profile, threaded throughop_ri/ ExtGState/RI. - GWG 16.4 — transparency groups with no
/CS(inherit) now
resolve correctly to the parent's CMYK compositing space when
the parent is a/CS DeviceCMYKgroup. - GWG 17.2 — ICCBased images now go through the proofing chain
viaconvert_image_8bit_with_intent(was bypassing the
OutputIntent roundtrip and rendering via direct source→sRGB). - GWG 22.1 — Lab fills populate
DeviceColor::native_cmykvia
a directLab → PCS → OI B2A → CMYKchain (matches Adobe ACE),
and the OutputIntent install path pre-warms the sRGB→CMYK
reverse transform so the parallel CMYK buffer never falls back
to the PLRM(1-r, 1-g, 1-b, 0)formula. - WASM viewer —
open_pdfnow applies the document's
OutputIntent before storing the cached state, so PDF/X documents
render in the browser the same way they do in the CLI.
Added — public API (additive, non-breaking)
stet-graphics:
IccCache::convert_to_oi_cmyk(hash, components, intent)— run an
RGB ICC color through the proofing chain at the given intent and
return the intermediate OutputIntent CMYK.IccCache::convert_lab_to_oi_cmyk(l, a, b, intent)— direct
Lab → OI CMYKvia the OI's per-intent B2A LUT.IccCache::convert_image_8bit_with_intent(hash, samples, pixel_count, intent)— bulk image conversion with explicit
rendering intent.IccCache::convert_color_with_intentand
convert_color_readonly_with_intent— per-intent single-color
conversion.IccCache::prepare_lab_to_oi_cmyk()— pre-build per-intent
Lab→OI samplers; pair withprepare_reverse_cmyk().IccCache::intent_from_pdf_byte(b: u8)— map PDF rendering-intent
bytes (0..3) toIccRenderingIntent.pub use moxcms::RenderingIntent as IccRenderingIntent.pub struct LabToCmykSampler(inicc::perceptual) with
pub fn sample_pdf_lab(l, a, b).- New field
ImageParams::rendering_intent: u8. Default is0
(Perceptual). Per the documented "be a reader, not a writer"
policy for param structs (CLAUDE.md), this is additive and not
treated as a SemVer break.
stet-pdf-reader:
PdfDocument::apply_output_intent_as_default_cmyk()now also
pre-warms the sRGB→CMYK reverse and per-intent Lab→OI samplers
in addition to its previous behaviour. No signature change.- Image XObjects with
/Intentnow propagate the per-image
rendering intent intoImageParams.rendering_intent, overriding
the gstate/RIper ISO 32000 §11.3.4.
stet-render:
build_icc_cache_for_listnow also pre-warms the per-intent
Lab→OI samplers when proofing is enabled.
Fixed
- DeviceGray painted in a PDF/X DeviceCMYK page group now routes
through the K plate (matches DeviceCMYK 0/0/0/(1−g) byte-for-byte). - DeviceN images with a non-CMYK alternate space go through the
overprint path so process plates aren't disturbed. - Paired
/OP true /op trueExtGStates are now treated as a
"strict overprint" signal (matches Adobe Illustrator's emit). - The custom-spot overprint dispatch and the parallel CMYK buffer's
is_custom_spotheuristic both now require
process_cmyk.is_some()so proofing-chain ICCBased RGB stays out.
Verification
- 11 GWG calibration tests pass: 13.0 / 13.3 / 16.1 / 16.4 / 17.2 /
17.3 / 22.1 / 23.0 / 1.0 / 3.0 / 12.0 / 8.0.1. cargo test --workspace --releaseclean.cargo fmt --all -- --checkclean. No new clippy errors.wasm-pack build --target web --release crates/stet-wasmclean.
Crates published at 0.2.1
stet, stet-cli, stet-fonts, stet-graphics, stet-core,
stet-ops, stet-engine, stet-render, stet-viewer,
stet-pdf-reader, stet-pdf. The vendored stet-tiny-skia /
stet-tiny-skia-path forks remain at 0.11.4. stet-wasm is
excluded from crates.io and bumped to 0.1.1 independently.
Full changelog: v0.2.0...v0.2.1
stet 0.2.0 — ⚠ breaking release
[0.2.0] — 2026-05-01
This release lands a substantial expansion of the stet-pdf-reader
structural API, the PDF imaging-extension operators (transparency,
soft masks, optional content), and the pdfmark PostScript-to-PDF
authoring bridge. Several public match-surface enums are now
#[non_exhaustive] to lock in additive evolution — the breaking
changes are deliberate and documented per-crate below.
⚠ Breaking changes
This is a breaking release. Cargo treats the 0.1 → 0.2 bump as
incompatible (per the SemVer rules for 0.x), so existing users
pinned at stet = "0.1" won't be auto-upgraded.
The breaking surface is concentrated in two places:
-
#[non_exhaustive]markers were added to ~40 public
match-surface enums acrossstet-graphics,stet-core, and
stet-pdf-reader. Any downstreammatchoverDisplayElement,
PsError,Destination,AnnotationKind, the various pdfmark
record enums, etc. now requires a_ => { ... }wildcard arm.
See the "Changed — public API breaking changes" subsection below
for the complete list. -
stet-pdfno longer emits PDF/X-3 OutputIntents. PDF output
is now plain PDF 1.7.PdfDevice::set_output_profile()is
#[deprecated]as a no-op; existing call sites compile but stop
producing the (previously broken) PDF/X-3 conformance label.
For a typical downstream renderer that pattern-matches on
DisplayElement, the migration is one wildcard arm per match
site:
match element {
DisplayElement::Fill { .. } => { /* … */ }
DisplayElement::Stroke { .. } => { /* … */ }
DisplayElement::Image { .. } => { /* … */ }
+ _ => { /* fall through; new variants in 0.2.x are additive */ }
}The #[non_exhaustive] ratchet is intentional: it makes future
variant additions non-breaking, so 0.2.x → 0.3.x will be smaller.
Added — stet-pdf-reader structural API
A read-only structural-content API for PDF inspection and tooling.
Every accessor parses lazily on first call and caches its result.
metadata()—/Infodict (title, author, dates, …) and the
catalog's/MetadataXMP stream.viewer_preferences()— page layout, page mode, print preferences,
and reading direction hints.outline()— bookmark tree asOutlineItems with
destination/action resolution.destinations(),resolve_named_destination(name)— named
destination table merged from/Catalog /Dests(legacy) and the
/Names /Destsname tree.page_annotations(page)— typedAnnotationlist with
destination/action resolution.form(),form_fields()— AcroForm field tree (text, choice,
button, signature) with widget cross-references.page_boxes(page)— MediaBox / CropBox / BleedBox / TrimBox /
ArtBox.embedded_files(),embedded_file_bytes(name)—/EmbeddedFiles
name-tree walker.layers(),layer(ocg_id),configurations(),
default_configuration(),layer_tree(),layer_set_for(intent)
— Optional Content Group (OCG) metadata, hierarchy, render-intent
rules, and a runtimeLayerSetfor visibility overrides.parse_warnings()— diagnostic sink for non-fatal parse issues
(broken outlines, bad name trees, malformed/VEexpressions, …).- New
stet inspect <file.pdf>CLI subcommand surfaces the structural
API at the command line.
See docs/PDF-READER-API.md and docs/PDF-LAYERS.md for full
references.
Added — PDF imaging extensions
Display-list-level support for the PDF transparency and optional-content
imaging models, layered on top of the PostScript interpreter.
- Alpha and blend modes:
setblendmode,setfillalpha,
setstrokealpha,setalphaisshape. All 16 PDF blend modes. - Transparency groups:
begintransparencygroup/
endtransparencygroupwithKnockout,Isolated, and group
colour space (DeviceGray/DeviceRGB/DeviceCMYK/ ICC). - Soft masks:
begintransparencymaskgroup/
endtransparencymaskgroupwithAlphaandLuminositysubtypes,
transfer functions, and backdrop-colour handling. - Optional Content (OCG):
setocg/endocgoperators wrap
display-list content inOcgGroupelements with
OcgVisibility::Single/Membership/Expressionpredicates.
LayerSet(instet-graphics) is the consumer's per-render override
map;render_page_to_rgba_with_layershonours it. - Filters:
JBIG2DecodeandJPXDecodefor embedded image
streams.
See docs/PDF-EXTENSIONS.md for the full reference and
docs/PDF-LAYERS.md for the runtime layer-visibility model.
Added — pdfmark PostScript-to-PDF authoring
pdfmark operator dispatch in stet-ops (gated behind
register_pdf_authoring_ops so it's only visible to systemdict on the
PDF output path) plus matching emitters in stet-pdf. Five phases of
authoring support:
/DOCINFO— document info dictionary (title, author, subject,
keywords, creator, producer, dates, trapped)./OUT— outline (bookmark) tree authoring with destination /
action targets./ANN— Link, Text, FreeText annotations./DEST,/PAGE,/PAGES— named destinations and per-page-box
overrides./VIEWERPREFERENCES,/Metadata— viewer preferences and
document-level XMP metadata./Widgetand/FORM— AcroForm widget annotations and field-tree
emission./EMBED, JavaScript / Named actions, page-level/AAtriggers.
See docs/PDFMARK-AUTHORING.md for the full reference.
Added — colour management
- Hand-rolled colorimetric A2B1 CLUT sampler
(stet-graphics::icc::perceptual). moxcms 0.8'screate_transform
pipeline over-saturates CMYK→sRGB output relative to lcms2 / Acrobat
/ Ghostscript on midtone colours; this module bypasses it for v2
lut16TypeCMYK profiles and matches lcms2 RelCol output to ±1 RGB
level on a 17⁴ sweep against ISO Coated v2 300% (ECI). Out-of-gamut
colours clip to the sRGB boundary (matching lcms2 / GS) so pure
process primaries remain saturated. BPC is calibrated against the
sampler's own (1, 1, 1, 1) output so K-heavy CMYK lands at the
correct darkness. Profiles whose tables are mAB / mft1 fall back
to the moxcms-driven bake. - Soft-mask CMYK-domain blend gate widened to accept Group-wrapped
flat CMYK fills (GWG 16.11 "Gradient Feather"). The GWG 16.10
outer-glow protection still rejects on the inner Fill's blend-mode
check.
Changed — public API breaking changes
These match-surface enums are now #[non_exhaustive] so adding
variants is non-breaking for any consumer that includes a _ => arm.
Existing consumers must add wildcard arms (or update their match
expressions) to keep building.
stet-graphics:DisplayElement,ImageColorSpace,
ShadingColorSpace,SpotColorSpace,LineCap,LineJoin,
FillRule.stet-core:PsError,FilterKind,RleState.stet-core::pdfmark:PdfMarkRecord,AnnotationSubtype,
AnnotationTarget,OutlineDestination,OutlineAction,
GoToTarget,ViewSpec,FieldType,FieldValue,DocDate,
TrappedState,TzSign,LinkHighlight,TextAnnotationIcon,
PageOverrideScope.stet-pdf-reader:PdfError,Destination,ViewSpec,Action,
AnnotationDate,AnnotationKind,AnnotationColor,
AnnotationKindData,FieldKind,ButtonType,FieldValue,
TrappedFlag,PageLayout,PageMode,ReadingDirection,
PrintScaling,Duplex,AfRelationship,ParsePhase,
LocationHint,Severity,RenderIntent,LayerIntent,
UsageState,PageElementSubtype,LayerTreeNode,BaseState,
ListMode,AutoStateEvent.
Param structs (FillParams, StrokeParams, ImageParams, the
pdfmark record structs, Annotation, FormField, Layer, etc.) are
not marked #[non_exhaustive] — adding fields lands additively
and consumers should pattern-match with .. for forward
compatibility.
A scripts/check-non-exhaustive.sh audit runs in the local pre-push
hook; new public enums in the listed files must either carry the
marker or be allow-listed with a one-line justification. See the
"Stable extension points" section of CLAUDE.md and the per-doc
"Stability" sections of docs/DISPLAY-LIST.md,
docs/PDF-READER-API.md, and docs/PDFMARK-AUTHORING.md.
Changed — other
stet-pdf: removed the PDF/X-3 OutputIntent emission. The writer
was emitting soft-mask transparency (prohibited by PDF/X-3) while
labelling output asPDF/X-3:2003— a conformance conflict any
preflight tool would flag. PDF output is now plain PDF 1.7 with no
PDF/X conformance claim. A correct PDF/X-4 implementation is
planned.stet-pdf:PdfDevice::set_output_profile()is#[deprecated]
as a no-op. Retained for forward API compatibility with the planned
PDF/X-4 work.stet-cli:--width/--heightflags for PDF input override
the page's MediaBox at render time.
Added — documentation
docs/PDF-READER-API.md— full reference for the structural API.docs/PDF-LAYERS.md— full reference for the OCG / layer API.docs/PDF-EXTENSIONS.md— full reference for the imaging extension
operators and the JBIG2 / JPX filters.docs/PDFMARK-AUTHORING.md— full reference for the pdfmark
authoring bridge.- New Rendering Correctness section in the root README covering
seam-free rendering on adjacent clipped regions and full overprint
simulation.