Skip to content

stet 0.8.5

Choose a tag to compare

@github-actions github-actions released this 09 Oct 13:54
· 104 commits to main since this release

Added

  • PdfDocument::set_max_image_pixels: an application's own ceiling on
    image size.
    stet holds every image to limits sized for prepress
    (100,000 samples a side, four billion in all) and to nothing lower,
    because the right figure depends on the machine. That leaves JPEG 2000
    exposed: its decoder needs about 39 bytes of memory per sample the
    image's header declares, whatever the size of the stream, so a
    legitimate 212-megapixel scan costs 8.5 GB and a hostile 25 KB stream
    declaring 60,000 × 60,000 asks for over 100 GB — and nothing in the
    file tells the two apart. With a ceiling set, an image over it is left
    out of the page with a warning naming its size. The size counted is the
    one decoded: for JPEG 2000 the size in the stream's own header, or the
    reduced size under ImageResolution::Rendered, so a very large image
    drawn small is still drawn; for every other image, mask and soft mask,
    the size in its dictionary. Off by default. Also new:
    DecodeBudget::with_image_pixels. See docs/PDF-READER-API.md, "A
    ceiling on image size".

  • --box-snap, and PdfDocument::page_area_rect_on_pixel_grid in
    stet-pdf-reader: a --box area whose edges fall between device pixels
    is rasterised at its own sub-pixel phase, so it antialiases differently
    from the same area cropped out of a full-page render — 20 % of the pixels
    of a 751 × 666 crop of a textbook spread at 150 dpi differed, by up to 200
    levels. --box-snap widens the area outward to whole pixels of the crop
    box's pixel grid at the chosen resolution, so it renders the page's own
    pixels; the output can be up to one pixel larger per side. An area
    outside the crop box, a bleed for one, keeps its reach. --device png
    only, and not with --width/--height. Contributed by @jungseohaan
    (#5).

  • PdfDocument::set_image_resolution, and ImageResolution. A
    display list keeps every image at its stored resolution, so it can be
    zoomed into or written to another format; that is
    ImageResolution::Full, the default, and it is unchanged. A caller that
    builds a list with render_page only to rasterise it at that resolution
    can now say so with ImageResolution::Rendered, and get what
    render_page_to_rgba does on its own (see "Changed"). See
    docs/PDF-READER-API.md, "Display lists and resolution".

  • PdfDocument::from_owned: a document that owns its bytes.
    from_bytes borrows the file, so a PdfDocument<'a> cannot outlive the
    buffer, and an application that keeps documents open — a viewer, a
    server with a cache — had to store each buffer beside its document or
    resort to a self-referential struct. from_owned, from_owned_with_icc
    and from_owned_with_password take the buffer and return a
    PdfDocument<'static>. They accept a Vec<u8>, a Box<[u8]> or an
    Arc<[u8]> (through the new PdfBytes) and copy none of them; an Arc
    stays shared, so a caller can keep its handle and try again with a
    password. The from_bytes constructors are unchanged.

  • PdfDocument::set_annotation_filter: draw some annotations and not
    others.
    set_render_annotations is all or nothing, which does not
    suit an editor that draws review comments itself, as objects the user
    can move, but wants form fields left in the page. An AnnotationFilter
    selects by class — AnnotationClass::Markup, Widget, Link, Other,
    from the new AnnotationKind::class() — and by what the render is for:
    RenderIntent::View leaves out Hidden and NoView annotations,
    RenderIntent::Print draws only those with the Print flag.
    AnnotationFilter::draws(&annotation) is the renderer's own test, for a
    caller that draws the rest. The default is every class, for viewing;
    set_render_annotations(false) still turns them all off. See
    docs/PDF-READER-API.md, "Drawing some annotations and not others".

  • stet_render::RegionRender: a region of a page rendered with a layer
    set.
    render_region_prepared and its parallel variants take twelve to
    fifteen positional arguments and no LayerSet, so a viewer that draws a
    page in tiles could not show or hide layers without interpreting the
    page again. RegionRender::new(list, prepared, viewport, w, h, dpi)
    takes the options by name — .icc(), .image_cache(), .no_aa(),
    .layer_set() — and renders with .render(), .render_parallel(),
    .render_parallel_with_progress(), .render_parallel_cancellable() or
    .render_band(). With no layer set it gives the pixels the existing
    functions give, and those are unchanged. One PreparedDisplayList and
    one ImageCache serve a page under every layer set: the precomputed
    bounds used to leave out a layer hidden by default, which an override
    could then not bring back in a tile that the layer alone touched. Also
    re-exported from the stet facade.

  • stet-wasm: set_max_vm(interp, bytes) caps PostScript VM in the
    browser, as --max-vm does on the command line. The default there is
    1 GiB, a quarter of the address space, which is more than most pages
    embedding the viewer want to grant one document.

  • Licence note for the embedded CMYK profile. default_cmyk.icc has
    been the public-domain (CC0) CGATS001Compat-v2-micro profile since
    before 0.1.0, but only its filename — Ghostscript's — said anything about
    where it came from, and a reader could take it for Ghostscript's
    AGPL-licensed profile. LICENSE-CMYK-PROFILE in the stet and
    stet-wasm crates now records its source and terms, and the prebuilt
    archives' THIRD-PARTY-NOTICES.txt carries it.

Changed

  • stet_core::graphics_state::PatternData has a new public field,
    shading_background
    : the Background colour of a shading pattern
    (see Fixed). Code that builds a PatternData with a struct literal
    needs the field.

  • stet_core::graphics_state::GraphicsState has two new public fields,
    clip_chain and clip_chain_stack
    (see Fixed, "A restored clip
    forgot all but its last region"), with a new ClipChain type and the
    methods intersect_clip, set_clip and reset_clip to change the
    clip through. Code that builds a GraphicsState with a struct literal
    needs the two fields; GraphicsState::new() and .. updates are
    unaffected. As with overprint_mode in 0.8.3, the struct becomes
    #[non_exhaustive] in 0.9.0. Code that sets clip_path directly still
    works: a state with a path and an empty chain is restored from the path.

  • stet exits 1 when a job fails. A PostScript job stopped by an
    error, a timeout or a memory limit was reported on standard error as
    Job N FAILED while the process exited 0, so a script, a make rule
    or a CI step took a failed conversion for a good one. The status is now
    1 if any job failed — the remaining files are still processed, as
    before, and the closing line counts the failures (Processed 3 jobs, 1 failed). A status a program asks for with .quitwithcode still wins,
    quit is still a success, and the interactive viewer is unaffected.
    Anything that ran stet over PostScript known to raise errors and
    relied on a zero status needs to allow for the new one.

  • A page that draws one gradient thousands of times renders in a tenth
    of the time and a third of the memory.
    Some producers build a gradient
    out of thin clipped strips, each a separate sh of the same shading:
    pdf.js bug1721218_reduced.pdf draws 41 shadings 3,531 times on one
    letter page, and took 12.6 s and 2.6 GB at 72 dpi. It now takes 1.2 s
    and 1.0 GB, and the output is byte for byte the same, as it is for the
    first two pages of all 692 PDFs in the test set. Three things were
    wrong. The renderer visited every pixel of a shading's bounding box —
    the whole page, when it has no /BBox — before asking the clip whether
    to paint it; it now finds the stretch of each row the clip lets through
    first. Its cache of rasterised clip masks had no limit and held one for
    each of the page's thousands of clips; it is now bounded at 32 MB. And
    the reader parsed and sampled a shading's function afresh for every use;
    the colour stops are now kept for the page.

  • A very large JPEG 2000 image drawn small is decoded small. Where a
    page is rasterised at once — render_page_to_rgba and its variants, and
    stet --device png for PDF input — a JPEG 2000 image the page draws at
    half its stored size or less is decoded at a lower resolution level of
    its codestream rather than in full and then scaled down. A 212-megapixel
    scan (pdf.js issue19517.pdf) rendered 3,152 pixels wide went from
    8.6 GB and 8 s to 0.6 GB and 1.1 s. The level is chosen to be at least
    the size drawn and to keep the picture within a quarter of a device
    pixel of where the full image would put it; a soft mask is averaged down
    to match. Pixels of such pages differ slightly from before: the
    wavelet's own smaller image is not the same filter as averaging blocks
    of the large one. Images with a /Mask, palette images, images inside
    tiling patterns or Type 3 glyphs and images in encrypted files are
    decoded in full as before, and so is everything in a display list from
    render_page, the viewer and PDF output, which stay independent of
    resolution.

  • Resolver::data() returns &[u8] borrowed from the resolver, where
    it returned &'a [u8] borrowed from the file. A resolver may now own
    its bytes, so the longer lifetime can no longer be promised. Code that
    uses the slice while it holds the resolver is unaffected; code that kept
    the slice after letting the resolver go needs to keep the resolver, or
    its own reference to the file.

  • What the reader skips, it now reports. stet-pdf-reader carries on
    past most of what is wrong with a page, which is right, but several of
    the things it carried on past left no trace: the page simply had
    something missing. These are now in PdfDocument::parse_warnings():

    • An operator that fails. It is skipped and the stream goes on, as
      before, with a ParsePhase::Content warning naming it: operator Do: object 12 0 not found, operator Do: decompression error: …,
      operator sh: sampled function has 18 inputs (1 to 16 supported),
      operator c: need 6 operands, have 4. So an image that will not
      decode and a shading whose function is refused both say so.
    • A form, pattern, glyph procedure or soft-mask group whose stream
      ends in an error
      , and a page content stream that cannot be
      decoded
      when the page has others that can.
    • A page tree that loses pages, under the new ParsePhase::PageTree:
      nested more than 256 levels deep, or a node listed twice. Such a
      document could open with no pages and no explanation.

    A page keeps at most 64 distinct content warnings, then one saying the
    list is cut short, and a message repeated by every operator in a stream
    costs a hash lookup. Nothing is drawn differently. The stet command
    line prints these
    , so a file that rendered silently with something
    missing now says what: of 1,681 test PDFs, 29 gain warning: lines (at
    most 28 each), most of them paths and text operators with the wrong
    number of operands.

  • stet-pdf-reader no longer prints to stderr. A content stream
    error, a font or soft mask that would not load, a missing predefined
    CMap, a page cut short by the nesting budget and a CCITT image that
    needed a fallback were each written straight to the terminal of
    whatever application linked the library. They are now recorded in
    PdfDocument::parse_warnings() under the new ParsePhase::Content,
    with the page as the warning's location, when the page is first
    rendered; rendering it again adds nothing. Two of them were silenced
    after their first occurrence in the whole process, so a second document
    with the same fault said nothing; each document now has its own record.
    The stet command line prints them itself, so its output is unchanged
    apart from the wording of the two CCITT messages. Also new:
    Resolver::warnings() and WarningSink::record_once. A lint keeps
    the library from printing again.

  • An annotation flagged NoView is no longer drawn. The flag means
    "do not display on screen"; it marks print-only content such as a
    watermark that should appear on paper alone. stet honoured Hidden and
    ignored NoView, so such an annotation showed in every render. A render
    is now for viewing unless told otherwise, and
    set_annotation_filter(AnnotationFilter::new(RenderIntent::Print)) gives
    the print reading, in which these annotations are drawn.

  • The minimum supported Rust version is now 1.92 (was 1.88). The JBIG2,
    CCITT and JPEG 2000 decoders stet uses — hayro-jbig2, hayro-ccitt and
    hayro-jpeg2000 — were two to three releases behind, held there by the
    old floor: their current versions need 1.92. They are now 0.3.1, 0.4.0
    and 0.4.1. A JBIG2 image is also packed to one bit per pixel as it is
    decoded, where it used to pass through a one-byte-per-pixel copy first.
    The 212-megapixel JPEG 2000 page of pdf.js's issue19517.pdf peaks at
    8.6 GB where it took 11 GB; that is still far too much, and is not
    finished work.

Fixed

  • A gradient mesh from Illustrator raised typecheck in shfill. A
    mesh or patch shading (ShadingType 4 to 7) may take its DataSource
    from a file as well as a string or an array (PLRM 3, 4.9.3), and
    Illustrator writes a gradient mesh that way: the vertex data follows
    the dictionary in the program, behind a filter on currentfile. stet
    accepted only the string and the array. A file is now read to its end,
    from where it stands, and painted as the same bytes in a string would
    be. Reported by @vivozi (#6), with a reproducer and the place in the
    source. Two more of the same kind, found beside it:

    • A sampled (Type 0) function may likewise take its samples from a
      positionable file; a ReusableStreamDecode filter, the way in-line
      data becomes one, was a typecheck there too. The samples are read
      from position 0, as the manual says, wherever the file was left.
    • setfileposition raised ioerror for a ReusableStreamDecode
      filter
      , the one filter that exists to be positioned. A program that
      paints one reusable stream twice repositions it in between, and could
      not.
  • Four ways a damaged PDF could stop the process, found by fuzzing
    stet-pdf-reader and present in every build:

    • An embedded OpenType font whose CFF table lies past the end of
      the font
      panicked: the table was sliced at its declared offset and
      length without checking either. It is now read as far as the data
      goes. New: stet_fonts::truetype::table_data.
    • A mesh shading (Types 4 to 7) with a short or missing /Decode
      panicked, and one whose coordinates and colour components were
      declared zero bits wide never finished reading, taking memory until
      none was left. A layout that cannot describe a vertex is now refused,
      with a warning from the reader and rangecheck from shfill. New:
      stet_graphics::mesh_shading::mesh_layout_is_readable.
    • An image smaller than its soft mask, with less data than its
      dimensions promise,
      panicked while being enlarged to the mask's
      size. The missing samples are read as zero.
    • A TrueType glyph whose contours end out of order could claim all
      of its points again for every contour, so a 256 KB file asked for
      3.7 GB. Such a contour is now empty, and a composite glyph is held to
      the 65,535 points the format allows in total.
  • Three ways a small damaged PDF could take a very long time, found
    by fuzzing stet-pdf-reader:

    • A font that cannot be resolved, used inside nested Type 3 glyphs,
      was resolved again, and its substitute read again, for every glyph
      shown: a 7 KB file took fifteen seconds. The substitute is now
      remembered for the font object and not only for its resource name.
    • An object stream that cannot be read was decoded again for every
      object the cross-reference table placed in it, so opening a file with
      a costly broken stream took eleven seconds. It is now tried once.
    • The limit on a page's nested content counted streams, not their
      length.
      A long form or glyph procedure run thousands of times went
      almost uncharged — 23 seconds on 6% of the allowance, and a quarter
      of an hour had it all been spent. The charge now grows with the
      stream's length beyond 4 KB, which bounds a page at about 4 GB of
      nested content; shorter streams, which is every Type 3 glyph and
      small form, cost what they did. No page of the 2,690 PDFs the limit
      was first measured on reaches it.
  • A tiling pattern painted under a soft mask ignored the mask. In
    stet-pdf-reader, a soft mask set with gs covers what the stream
    paints from then on, which the reader tracks as a scope over its
    display list. A tiling pattern's cell is interpreted into a display
    list of its own, and the scope was left open across that: it was closed
    inside the cell, around the cell's own contents, and the fill or stroke
    that used the pattern was painted whole. A page that set a mask and
    then filled with a tiling pattern — a patterned shape with a feathered
    edge, text punched out of a texture — showed the pattern across the
    whole shape, and the pattern's cell could lose part of its own content
    for every later use. A Type 3 glyph procedure that sets a soft mask of
    its own had the same fault, dropping the mask from everything painted
    before the glyph. The cell and the glyph procedure are now interpreted
    with no scope open, and the page's scope carries on after them. Shading
    patterns were not affected.

  • A restored clip forgot all but its last region. The clip is the
    intersection of every region set since initclip, but the interpreter
    kept only the most recent one. Nothing went wrong while clips only
    narrowed; once a grestore, restore, setgstate or cliprestore
    brought an earlier clip back, it came back as its last region alone, so
    a page that clipped twice and then ran any nested gsave … clip … grestore painted outside the first clip from then on. An eoclip also
    came back as a non-zero winding clip, filling its holes. Every region is
    now kept, with its rule, and the restored clip is rebuilt from all of
    them. Successive rectangles are kept as the one rectangle they share,
    which clippath now returns in place of the last rectangle set.

  • A PostScript shading pattern painted nothing, and a tiling pattern
    painted fills only.
    A pattern is a paint, used by whatever fill,
    stroke and show mark (PLRM 3, 4.9). A shading pattern
    (PatternType 2) was accepted by makepattern and setpattern and
    then drew nothing at all, for any shading type; a tiling pattern was
    honoured by fill, eofill and rectfill, while a stroke, text or a
    stencil mask set in it came out in a solid colour. Now, for both kinds:

    • Fills, strokes (stroke, rectstroke) and text are painted with
      the pattern.
      A patterned stroke is painted as the area the stroke
      covers and patterned text as its outlines, so every output device sees
      an ordinary pattern fill; in PDF output such text is therefore
      outlines rather than text.
    • A shading pattern stays where makepattern placed it, whatever
      the current matrix when it is used, paints its shading's Background
      across what it fills, and reads a mesh's DataSource from the start
      of a positionable file each time one is made.
    • imagemask is painted with the pattern too, where it had used a
      solid colour: the pattern shows through the mask's painted samples.
    • makepattern no longer disturbs the graphics state. PaintProc
      ran in the program's own state, so the colour, line width and path it
      set stayed set afterwards and the current path was lost; and a cell
      made while another pattern was current was painted with that pattern,
      which turned an uncoloured pattern's cell into stripes of the wrong
      colour. It now runs between an implicit gsave and grestore, with
      no pattern current.
  • A JPEG 2000 image whose own header claims an absurd size aborted the
    process.
    stet-pdf-reader checks an image's /Width and /Height,
    but a JPEG 2000 stream carries its own, and those are the ones the
    decoder allocates for. A real 25 KB image with its header rewritten to
    2³¹ × 2³¹ samples, in a dictionary still saying 512 × 384, asked for
    1.8 × 10¹⁶ bytes: an allocation failure, which no caller can catch. The
    size in the stream is now held to the limits every other image is
    (100,000 samples a side, 4 × 10⁹ in all), and an image over them is
    refused with a warning. Found while measuring where the memory goes on a
    212-megapixel JPEG 2000 page.

  • A CMap with a byte that is not UTF-8 inside a hex string panicked.
    stet-pdf-reader reads CMap streams as text, so such a byte becomes a
    three-byte replacement character, and two places then cut the hex string
    at a fixed byte offset — inside the character. A /ToUnicode stream with
    <0041> <ÿÿÿ> did it, and so did an embedded encoding CMap with a
    codespace range of <ÿ>; both are a panic in a release build, on opening
    the page. Found by the fuzzer. Such a string is now not a hex string, and
    the entry is skipped.

  • A PDF function could abort the process, panic, or take all the memory
    there is.
    Functions colour every shading and convert every spot colour,
    and stet-pdf-reader took their dictionaries at their word. Found by the
    fuzzer, then by reading the rest of the file; each of these is a PDF
    under 1 KB:

    • A sampled (Type 0) function whose /Size is [65535 65535 65535]
      asked for its whole table in one reservation, 2.2 × 10¹⁵ bytes, and the
      allocator aborted the process — which no caller can catch. One
      entry of two billion, or -1, filled memory instead. The table now
      holds the samples the stream contains; one the stream does not reach
      reads as zero, as it did before.
    • /Size [] panicked on an index; an entry of 0, or of 1 with two or
      more inputs, and a /BitsPerSample of 64, overflowed (a panic in a
      debug build). /Size entries must now be positive, one per input, and
      /BitsPerSample one of the eight values the format allows; a function
      that fails either is refused, and what it would have coloured is not
      drawn.
    • A sampled function with more than 16 inputs is refused, as poppler
      refuses it: evaluating one point reads 2^m samples. One of the pdf.js
      test files, axial_shading_many_inputs.pdf, has 18; it drew a black
      page and now draws nothing, as poppler and Ghostscript do.
    • A calculator (Type 4) function has no loops, but 2 copy 4 copy 8 copy … doubles its operand stack with each pair of tokens: forty of them
      took 4 GB. The stack now stops at 100 operands, the figure in the
      standard, which poppler and pdf.js also use.
    • idiv and mod by a number between −1 and 1 divided by zero and
      panicked in a release build, since both work on truncated
      integers; they give 0, as division by zero already did. bitshift by
      64 or more, and a function leaving fewer results than it has outputs,
      no longer overflow.

    Every valid file renders as before: two pages of each of 1,681 PDFs are
    byte-identical, bar the one above.

  • A PostScript program could raise --max-vm. MaxLocalVM is a user
    parameter, and << /MaxLocalVM 2000000000 >> setuserparams replaced
    whatever ceiling the command line had set — so --max-vm 16 bounded only
    a program that left it alone. The host's ceiling now holds: a program may
    lower its limit and raise it again, but not past what the host allows,
    and currentuserparams reports the limit in force rather than the
    request. The built-in 8 GiB default is a ceiling in the same sense; pass
    --max-vm to allow more. Library users get the same guarantee from
    Context::max_local_vm, and Context::vm_limit() returns the limit in
    force.

  • A TrueType or OpenType font among the embedded resources never
    loaded.
    resources/Font/ may hold .ttf and .otf files beside the
    Type 1 ones, but they were read from disk even where the resources are
    embedded in the binary and there is no disk — a WebAssembly build. None
    of the 35 bundled fonts is affected, being Type 1; this matters to a
    build that embeds fonts of its own.

  • A large image drawn small no longer costs its full size in memory, and
    an image with a soft mask no longer costs it once per band.
    Rendering
    converted every image to RGBA at full size before scaling it down, and
    an image inside a soft mask or a transparency group — every PDF image
    with an /SMask is one — was converted again by each band that touched
    it, with bands running in parallel. A 4500×6442 image with a soft mask,
    rendered 800 pixels wide, peaked at 1,075 MB on 24 threads; it now peaks
    at about 156 MB on any number, most of which is the decoded image
    itself. Four changes, none of which alters a pixel:

    • images inside soft masks and transparency groups are converted once
      per page, like images outside them;
    • an image that is scaled down is converted a strip of rows at a time,
      straight into the scaling filter, and never held at full size;
    • a page's images are converted in parallel;
    • an image drawn several times on a page — a logo, a texture — is
      converted once for all the placements that come out as the same
      pixels. That image drawn nine times renders in a quarter of the time
      on 24 threads and a tenth of it on one.

    One cost: converted images inside groups and soft masks are now held
    until the page is finished, so a single-threaded render of a page with
    many of them that are not drawn small can peak higher than before.

  • A colour key on a 16-bit image cleared the wrong pixels. The
    renderer looked up each pixel's key one byte per sample, so with
    bits_per_component: 16 it tested low bytes and neighbouring pixels'
    samples. It now tests each sample's high byte, the value the pixel is
    painted with. stet's own PostScript and PDF front ends never produce
    such an image (they reduce samples to 8 bits first), so only a display
    list built elsewhere was affected. docs/DISPLAY-LIST.md now says what
    sample layouts and keys a renderer is given ("Sample depth"); it listed
    depths of 1, 2 and 4 bits that no display list carries.

  • A PDF image declaring 9 to 15 bits per component rendered as noise.
    PDF allows 1, 2, 4, 8 and 16; the reader unpacked the first three and
    reduced 16-bit samples, but handed any depth in between on as packed
    bytes, which were then read as 8-bit samples. Such an image, or soft
    mask, is now unpacked bit by bit and reduced to the nearest 8-bit
    value, as the other depths are, and a colour key or /Decode on it
    applies as it does to them. Ghostscript draws nothing for these files
    and poppler keeps the low 8 bits of each sample.

  • A PDF cut off just after its startxref keyword did not open. The
    reader took the keyword as a promise of an offset and failed with
    "expected integer" when none followed, though a missing keyword was
    already handled. Such a file now opens the same way: from the
    cross-reference table when one is there, otherwise by scanning for its
    objects. Found with issue6069.pdf from the pdf.js test set.

  • A PDF page with a zero-size MediaBox crashed the PNG writer. A page
    declaring /MediaBox [0 0 0 0] rendered to a 0 × 0 image, and stet --device png panicked writing it. A MediaBox that encloses no area is
    now treated as a missing one always was, and replaced by US Letter; a
    CropBox with nothing left inside the MediaBox — empty, or lying wholly
    outside it — is replaced by the MediaBox. Each replacement is reported
    by PdfDocument::parse_warnings, as ParsePhase::PageBoxes. The
    command line also reports a page too small to have any pixels at the
    chosen resolution as an error and exits 1, where it would have
    panicked. Found with boundingBox_invalid.pdf from the pdf.js test set.

  • A DeviceN colour space with many colourants could exhaust memory, in
    PDF and in PostScript.
    A tint transform is sampled into a table of
    samples ^ colourants entries, and the colourant count is the file's to
    choose. A 1.1 KB PDF with an axial shading in a nine-colourant space took
    4.2 GB to open; eight colourants cost 540 to 690 MB for each such space;
    and in PostScript setcolorspace ran the tint transform procedure once
    per entry, 387 million times for nine colourants. Every such table now
    shares one ceiling, TintLookupTable::MAX_GRID_POINTS, sized by
    TintLookupTable::grid_samples. Up to seven colourants nothing changes.
    Eight get six samples a side where they had nine (eight, in a shading).
    Beyond eight there is no table: colours are computed from the tint
    transform itself, which is exact — a fill or stroke as before, an image
    pixel by pixel, an Indexed palette entry by entry — and what is given up
    is the spot identity of that colour in --device pdf output, which is
    written in the alternate space. TintLookupTable::lookup_1d and
    lookup_nd also return zeros for a table they cannot interpolate, where
    lookup_nd indexed out of bounds for more than eight inputs. Found with
    postscript_type4_many_outputs.pdf from the pdf.js test set.

  • A small PDF could ask for an unbounded amount of nested content.
    Form XObjects, tiling patterns, Type 3 glyphs, soft-mask groups and
    annotation appearances may nest twenty deep, and nothing limited how many
    ran: a form drawing itself eight times, or a Type 3 glyph showing three
    copies of itself, ran until the process was killed, and so did nineteen
    ordinary forms each drawing the next twice. Two things bound it now. A
    stream that is already being interpreted is not entered again — a form
    cannot draw itself, so there is nothing to draw — which makes every
    self-referencing file finish at once. And each page has a budget for
    nested content, charged by depth, 115 times what the busiest of 33,567
    real pages used; a page that spends it draws what it had drawn, warns,
    and stops. The files that used to be killed now take up to a second and
    1.2 GB, which is the display list of what they did draw.
    Reuse is unaffected: a form or glyph used any number of times in a row is
    drawn each time.

  • An AES-256 PDF did not open when its password needed Unicode
    preparation.
    PDF 2.0 has the writer run a password through SASLprep
    (RFC 4013) before hashing it: compatibility forms are folded, characters
    such as the soft hyphen are dropped. stet hashed the bytes as typed, so
    a password entered with a full-width letter, a decomposed accent or a
    non-breaking space was refused although it was the right one. The reader
    now prepares the password and tries that first, then the bytes as given
    for files whose writer skipped the step; both are cut to the 127 bytes
    the algorithm reads. The preparation is the new saslprep feature of
    stet-pdf-reader, on by default; it brings in the stringprep crate and
    its Unicode tables (about 170 KB in the stet binary), and a build
    without it behaves as before. RC4 and AES-128 files are unaffected.

  • A PDF's owner password did not open it. An encrypted PDF has two
    passwords, and either one decrypts the file. stet tried the password it
    was given only as the user password, so --password with the owner
    password, or PdfDocument::from_bytes_with_password with it, was refused
    as wrong at every revision of the Standard security handler. It is now
    tried as each, user first: for RC4 and AES-128 files by decrypting /O
    to recover the user password, for AES-256 files against the owner hash
    and the key wrapped in /OE.

  • A password with an accented letter did not open an RC4 or AES-128
    PDF.
    Those security handlers store the password in PDFDocEncoding —
    Latin-1 for accented letters, its own codes for a few characters such as
    the euro sign — and a caller passes what the user typed, which is UTF-8.
    The two agree only for ASCII, so --password æøå was refused on a file
    whose password is exactly that. The bytes as given are still tried
    first; when they are UTF-8 with something beyond ASCII, the
    PDFDocEncoding form is tried after them, for the user and the owner
    password alike. AES-256 files, which store UTF-8, already worked.

  • An encrypted PDF stating an impossible key length panicked. The
    /Length in an /Encrypt dictionary was used as the key size without a
    check: zero divided by zero in RC4, and anything over 128 bits read past
    the end of a digest. The value is now held to the 40 to 128 bits the RC4
    and AES-128 handlers define, so such a file is refused as needing a
    password, or opens if its key works at the nearest valid length.

  • A /Matte image whose soft mask was cut short panicked. An image
    premultiplied against a matte colour is divided back out by its soft
    mask; when the mask's stream ended early the loop read past its last
    sample. Pixels with no mask sample are now left as they are. Found in a
    public PDF corpus.

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 -fL <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.