Reference

Changelog

Every user-facing change, newest first — rendered from CHANGELOG.md.

Releases#

0.13.0

  • Text fields and selects take font_size: :fit (#218): the size font_size: :auto draws (one line, a comb: field's cells or a multiline: value, within min_font_size: and max_font_size:), written in /DA as that number instead of 0, so a viewer that redraws the field keeps the size; Firefox's editable input, which showed :auto at 9 px, now shows the number, capped at the field's height ÷ 1.35 and set in the browser's own face. A longer value typed afterwards does not shrink, and an empty field is written at its largest size, so a field left empty to be filled in wants :auto. A field placed in several places writes its first widget's size. Inspector#layout and stationery inspect report the number. text_field "price", value: "4 990 kr", font_size: :fit, max_font_size: 96, height: 110. The price card example uses it. A field that does not is written as it was. Forms::Field, Forms::Appearance.
  • Text fields and selects take align:, color: and font_size: :auto (#216): align: (:left, :center, :right) is written as /Q, color: (any colour the gem takes, black by default) as the colour operator of /DA, and font_size: :auto as 0 Tf, within min_font_size: (4 by default) and max_font_size: (the field's height by default). The gem's own appearance aligns, colours and sizes the value the same way, so a field looks the same before and after a viewer redraws it: one line at the largest tenth of a point that fits the field's height and width, a comb: field's widest character in its cell, a multiline: value wrapped to the width and fitted to the height. comb: ignores align:. text_field "price", value: "4 990 kr", align: :center, color: "#DC2626", font_size: :auto, height: 110. A field that uses none of them is written as it was. Under monochrome a color: is checked like any other colour (Warnings::NotMonochrome, or black with snap: true); Inspector#layout and stationery inspect report a text field's or choice's align:, font_size: and color:. A price card example (examples/price_card.rb). Forms::Field, Forms::Appearance, Forms::AutoSize, Testing::Layout. Behaviour change: under PDF/A, a CMYK colour in a form field's appearance or /DA is reported as Warnings::ConformanceIssue (CMYK colour in form field "price"), its border: and background: included, which were not checked before.
  • The skill (stationery skill install) and the README it is written from are corrected (#209): the max_pages example renders before it reads warnings; a preview reads params.fetch("id") { … }, so its default is not looked up when id is given; the streamed-table example preloads what its rows read; the invoice recipe computes VAT with Rational, not Float, and says when a document may derive its totals; the report recipe escapes the chart labels it writes into SVG (ERB::Util.html_escape); the form recipe gives its fields a tooltip: and says a field without one is announced by its field name; the label recipe sizes a native QR code by the printer's dpi (a module of at most 720 / dpi points) and says what a barcode left in the picture loses. Statements that overstated are qualified: encrypt under a conformance level raises ArgumentError, not ConformanceError; NeedAppearances is left out under a conformance level or a signature; what the untrusted-input guarantees cover; and what a B-T timestamp proves once the certificate has expired. Run stationery skill install again to update an installed copy.
  • A tagged render is written with object streams (#206): its structure elements, and every other object that is not a stream, are packed 200 to a deflated /Type /ObjStm, with a cross-reference stream in place of the table and trailer (ISO 32000-1, 7.5.7 and 7.5.8). A table of 300 rows, 8 pages, is 115 KB tagged where it was 501 KB, 36 objects where it was 4,544; the tagged examples are 9 to 65% smaller. It is on by default for a render that is tagged (tagged, to_pdf(tagged: true), conformance :pdf_ua1), and object_streams false at class level or to_pdf(object_streams: false) writes the classic table for a reader that cannot read object streams; to_pdf(object_streams: true) packs an untagged render too. Streams, the /Encrypt dictionary and a signature's dictionary stay out, so an encrypted, a signed and an incremental render are packed too, and PDF/A-2b, PDF/A-3b and PDF/UA-1 pass veraPDF packed. to_png and to_zpl refuse object_streams: as a PDF option. Document#page_count is how many pages the last render laid out, which stationery render now reports. In PDF::Writer (object_streams:, add_unpacked) and PDF::ObjectStreams.
  • stationery verify checks PDFs in the engines viewers are built on (#210): qpdf (the file's structure), Poppler (Evince, Okular), MuPDF, PDFium (Chrome, Edge), pdf.js (Firefox) and PDFKit (Preview, Safari). Each engine reads the file and what it reads is held against what pdf-reader reads of it: the page count, each page's text lines (found, not compared: NFKC, no whitespace, no soft hyphens, a list item's marker may be read apart, as a column of markers), that a page with text or an image paints something, links and their targets, outline titles, form fields with their values and appearances, attachments, signatures (and pdfsig's verdict), a tagged file's structure tree, and every font embedded with a ToUnicode map. Any error or warning an engine gives fails the check, but for the three messages Stationery::Verify::Allowlist names with why each is the check's environment and not the file. stationery verify invoice.pdf report.rb --engines qpdf,pdfjs --password P --json renders a Ruby file first, as inspect does, runs every engine installed without --engines, and exits 0, 1 or 2 (usage); it prints one line per file and engine, the problems under it, and the engines not installed with the line that installs each. The engines are tools you install (brew install qpdf poppler mupdf, pip install pypdfium2, npm i pdfjs-dist; PDFKit on macOS), or run in the image ghcr.io/zoolutions/stationery-verify, published with each release, which has every engine but PDFKit: docker run --rm -v "$PWD:/data:ro" ghcr.io/zoolutions/stationery-verify /data/invoice.pdf. The gem gains no dependency: stationery verify needs pdf-reader, as inspect does, and require "stationery" does not load it, so a render allocates and writes what it did. bundle exec rake verify:readers renders every example, the conformance renders, an encrypted invoice, a packed invoice and an unpacked tagged report to tmp/readers/ and checks them; CI runs it through the image on Linux and with qpdf, Poppler, MuPDF and PDFKit on macOS. Testing::Inspector.new(subject, password:) opens an encrypted file. Stationery::Verify, Stationery::CLI::Verify.
  • Renders are safe from many threads at once, from the fibers of one Async reactor, and inside a non-main Ractor on Ruby 4.0 (#222). What renders share is kept once per Ractor, each cache behind a lock of its own (Stationery::Store, Stationery::LRU, Stationery::Memo): parsed fonts, decoded images, colours parsed from Strings, hyphenation patterns, the paddings of table cells and the Reed–Solomon divisors of barcodes; no lock is kept in a constant or a class instance variable any more. A parsed font reads its kerning, ligatures and CFF table with the rest of the parse, so it never changes once built and can be made shareable and handed to a Ractor as it is; a decoded image builds its resamples and JPEG scales under a lock of its own. Every constant of the gem is deep-frozen. Boot ends with Stationery.finalize!, which the Railtie runs once the app has initialized (a Rails-less app calls it once its setup is done): it freezes Stationery.font_paths, keeps the instrumenter in use and finalizes every document class loaded by then, with their subclasses. Document.finalize! freezes a class's settings and makes them Ractor-shareable as far as what they hold lets them be, so the class renders inside any Ractor when all of them are; it never raises: a block that uses an unshareable local from around it, a shaper or a sign key that is not shareable, is kept as it is and the class renders on the main Ractor only, as before (Ractor.shareable?(InvoicePdf.config) says whether its settings are; the instrumenter in use must be shareable too). A String the app gave is copied, not frozen in place, and an object the gem did not build is never frozen (Stationery.settled). A font family is frozen as it is built. A document class's settings are built when it is defined, not when they are first read. conformance-kit holds the gem to it: its thread-safety cops in RuboCop, a render of text, shapes, a JPEG, a table, a QR code and a footer inside a Ractor compared byte for byte with the main Ractor's, the thread and fiber examples over the same render, and an audit of every constant and class instance variable (bundle exec conformance-kit check). Documents render the same bytes as before. README: "Threads, fibers and Ractors". Stationery.finalize!, Stationery::Document.finalize!, Stationery::Store.
  • Behaviour changes: a tagged render is packed: its catalog, pages, annotations and structure elements are inside deflated object streams, so a test or tool that searches the file's bytes for them (/StructParent, /Type /Page) finds nothing (document.page_count has the pages), and the file ends in a cross-reference stream, not xref and trailer. A reader of PDF 1.4 and earlier cannot open it; object_streams: false writes it as 0.12.0 did, byte for byte. A tagged render streamed to a block is a few bytes longer than the String, as each flush closes an object stream. Untagged documents are byte for byte what they were in 0.12.0. In a Rails app, Stationery.font_paths is frozen once the app has initialized (the Railtie runs Stationery.finalize!), so a path appended later, in an after_initialize of the app's own, raises FrozenError. So does a call that changes the settings of a document class the app had loaded by then: with config.eager_load (production, and the CI test runs of a Rails 7.1+ app) that is every class under app/, without it (development) only those its boot touched, so a class reconfigured after boot fails in production and not in development. Stationery::Fonts::Family objects are frozen. The gem's constants are deep-frozen (Stationery::Page::SIZES, Stationery::Text::Markup::STYLES, …), and Stationery::Hyphenation::LOCK, Stationery::Preview::REGISTRY_LOCK and Stationery::Layout::Table::Cell::BOXES are gone. Until Stationery.finalize! keeps it, the default instrumenter is looked up on every render, so ActiveSupport::Notifications loaded after the gem is used. A document class copies its parent's settings when it is defined, not when they are first read, so a setting a parent class gains after a subclass is defined (a reopened ApplicationPdf) no longer reaches that subclass.

0.12.0 (2026-09-29)

Links that conform; labels and printing (page sizes in units, print hints, monochrome, page limits, to_png, JPEG pixels, to_zpl and barcodes); floats that keep to their page; speed and memory, with tables read as pages reach them; and AI first: a skill that ships with the gem, examples with their code, the docs over MCP, and stationery inspect.

  • PDF/UA-1 and links (#149, #148): every link annotation of a tagged render has to belong to a Link element, which is what gives it its /StructParent (ISO 14289-1, 7.18.5; veraPDF rule 7.18.5-1). A link: a header, a footer or a page_template paints (text(link:), markup, html, markdown, box(link:), to a URL or an #anchor) is now tagged as the links of the body are: a Link element of the Document holding the text of the link as real content and its annotation as /OBJR, with /StructParent and, under PDF/UA-1, /Contents. The rest of the region stays a pagination artifact, its marked-content sequence closed before what the link paints and opened again after it. There is one Link per link and page, after the content the body has on that page and in the order header, footer, page templates, so a reader comes to it when the page is read; what a linked box holds is tagged inside its Link. Inspector#structure and have_structure show them ([:Link, "example.com"]), and rake verify:conformance validates the report example with a linked footer. What cannot be tagged, a link in the header row a table repeats on its next pages and canvas.link without a tag: inside a canvas { } block (in a region too), is an artifact outside the structure tree: under conformance :pdf_ua1 the render raises Stationery::ConformanceError with an issue per link and page (link to https://example.com drawn by canvas.link without a tag: on page 1 is outside the structure tree (7.18.5)) instead of writing a file that claims a level it does not keep, and in a tagged render without the claim it is a Warnings::UntaggedLink (target, place, page; place is :header, :footer, :page_template, :artifact or :canvas), so strict catches it. Links in the body (text(link:), markup, box(link:), html, markdown, table_of_contents, canvas.link(…, tag:) with a Link element that is in the tree) are what they were. PDF/A alone does not ask for it. Documents that are not tagged, and tagged documents without a link in a region, are byte for byte what they were. Stationery::Canvas::Surfacing, Stationery::Tagging::Tree#follow.
  • missing_glyphs: :replace keeps a PDF/A or PDF/UA claim when a character no font has is drawn (#150): conformance :pdf_a3b, missing_glyphs: :replace at class level, or to_pdf(conformance: :pdf_a3b, missing_glyphs: :replace) per render. The character is drawn as the first of U+FFFD, U+25A1 and ? that the font drawing it has, at the stand-in's own advance (so lines are measured as they are drawn), inside the Span whose ActualText is the character, as .notdef is wrapped; the text still extracts, copies and reads aloud as written, and the map to Unicode gives the stand-in its own character wherever the document also writes it. veraPDF passes 2b, 3b and ua1 with each of the three stand-ins in body text, headers and page templates, form field values and shaped text (glyph 0 of a shaper's answer is replaced too), where the same files drawn with .notdef fail rules 6.2.11.8-1 and 7.21.8-1. The Warnings::MissingGlyph stays, with a new stand_in (the character drawn, nil for .notdef) and a message that names it, so strict still raises. A font that has none of the three, which a symbol font may not, still raises ConformanceError, saying so. Whitespace a font lacks stays a blank. :raise is the default and is what 0.11 does; any other value raises ArgumentError. Without conformance the option changes nothing, and documents that do not use it are byte for byte what they were. Stationery::Fonts::Font#stand_in, Stationery::Fonts::ShapedRun::StandIn, Stationery::PDF::Conformance#missing_glyphs.
  • Page sizes in millimetres and inches, and label and envelope sizes (#145). Stationery::Units converts to points where a size is written: mm(102), cm(2), inch(4) and pt(12), in the body and the instances of every component and document (page size: [mm(102), mm(74)], margin: mm(3), box(at: [mm(20), mm(45)], width: mm(90))), in any other class with include Stationery::Units, and as Stationery::Units.mm(102). They are private methods and Numeric is not patched; a method of that name in a document wins. The class-level page reads lengths with their unit: page size: ["102mm", "74mm"], page size: "4in x 6in" (also ×, and "102 x 74 mm", where the first length takes the unit of the second), margin: "3mm", margin: ["3mm", "5mm"], margin: { x: "5mm" }. A length is digits, with a fraction after a point if there is one, and mm, cm, in or pt in either case, with spaces around them if any: no sign, exponent or comma. Stationery::Units.points("3mm") reads one. New names: :a6, :a7, :b5, the envelopes :dl, :c5 and :c6 (short edge first, as ISO 269 has them: layout: :landscape is the address side) and the label stock :label_4x6, :label_4x3, :label_4x2, :label_100x150 and :label_100x50 (width by height). Page.new and Layout::Paginator take the same. The sizes there were have the points they had, and a document written in points is byte for byte what it was. Stationery::Units, Stationery::Page::Format.
  • Print hints: a document says how it wants to be printed (#146). print scaling: :none, copies: 2, pick_tray_by_size: true, duplex: :simplex, pages: 1..3 at class level, inherited and added to by subclasses, and to_pdf(print: { copies: 1 }) laid over it for one render; a nil takes a hint away (print: { duplex: nil }), and to_pdf(print: nil) or false all of them. They are written to the catalog's /ViewerPreferences (ISO 32000-1, table 150) as /PrintScaling (:none, :default), /NumCopies, /PickTrayByPDFSize, /Duplex (:simplex, :long_edge, :short_edge) and /PrintPageRange, beside the /DisplayDocTitle of a tagged document. pages: is a Range of page numbers from 1 or a list of them ([1..1, 3..4], 2..), written in page order; a range is cut at the last page of the render and one that starts after it is left out. print dialog: :on_open writes /OpenAction << /S /Named /N /Print >>, which asks the viewer for its print dialog when the file is opened. Every option is checked where it is written and raises ArgumentError naming what it takes, and ranges that overlap are refused. /ViewerPreferences passes veraPDF under PDF/A-2b, PDF/A-3b and PDF/UA-1; the print action does not under PDF/A (rule 6.5.1-2), so dialog: :on_open with conformance :pdf_a2b or :pdf_a3b raises Stationery::ConformanceError, and PDF/UA-1 alone takes it. Incremental, signed and encrypted renders write them too. They are hints: the README has what Acrobat, Chrome and Firefox are documented to do with each, none of it tried in a running viewer. Testing: Inspector#print_preferences, have_print_preference(scaling: :none) and assert_print_preference. print in a class body is this declaration; the methods of a document still call Kernel#print. Without print, a document is byte for byte what it was. PDF::PrintHints.
  • A document that must be one page can say so (#199): max_pages 1 in the class body, for a label, a receipt or a card. A render that needs more pages lays them all out as before and reports Warnings::TooManyPages naming the limit, the pages needed and what the first page past the limit starts with (the document may have 1 page and needs 2: page 2 starts with a Code 128 barcode; text is quoted, cut at 40 characters), so strict raises. to_pdf and to_png keep every page, to be looked at; to_zpl writes no label and raises WarningsError whether or not the render is strict, since each page past the limit is one more label printed, and stationery render --zpl exits 1 with the warning. to_pdf(max_pages:), to_png(max_pages:) and to_zpl(max_pages:) replace the limit for one render, nil takes it away; max_pages nil in a subclass does too, and page in a subclass keeps it. The shipping label and receipt examples, the skill's label and receipt recipes and its "Verify a label" checklist declare it. It writes nothing: a document with it that fits is byte for byte what it was, and one without it is unchanged. Layout::Paginator, Layout::Opening.
  • Monochrome mode, for thermal label printers and other devices that print black or nothing (#144): monochrome dpi: 203 at class level, or to_pdf(monochrome: { dpi: 203 }) for one render (monochrome: false takes it away), reports every colour that is not black or white and every opacity below 1 as a Warnings::NotMonochrome naming the colour and what painted it (text, a rule, a background, a border or line, a gradient, an image), once per colour, kind and page, and every stroke or rule thinner than one of the printer's dots as a Warnings::ThinLine, so strict catches them. snap: true changes them instead: text, rules and strokes black (white ones stay white), fills and gradients black when darker than threshold: (0.5, the tone being the BT.601 luma laid over white at its opacity) and left out when lighter, opacity taken away, thin lines widened to a dot. Filled rectangles and strokes of horizontal and vertical lines are put on the printer's dot grid, edges on whole dots and widths whole numbers of dots; curves and transformed shapes keep their geometry. PNG, WebP and JPEG images are resampled to the dots they cover and dithered to a one-bit DeviceGray image (dither: :floyd_steinberg, :ordered or :threshold). It sees what elements, SVGs and canvas { } blocks paint alike, keeps PDF/A and PDF/UA, and leaves form fields alone. Inspector#colors lists the colours a PDF paints with, and have_pdf_colors / assert_pdf_colors hold them; PNG#pixels and WebP#pixels are public. Without monochrome every PDF is byte for byte what it was. Stationery::Monochrome, Monochrome::Rules, Monochrome::Grid.
  • The groundwork for outputs other than PDF (#147). A canvas interface: what a layout node, the SVG renderer and a canvas { |c| … } block may call is Stationery::Canvas::Interface, in top-left coordinates and points and with nothing of PDF in what is handed over or answered; Stationery::Canvas is the PDF canvas, with the methods it had. A path is a Stationery::Path, its segments kept as numbers and read back with each_segment (Canvas::Path writes them as operators), a gradient is handed over as an SVG::Gradient::Fill, and Document#paint_on(canvases) lays a document out and paints it on the canvases it is given, without writing a file (Stationery::PDF::Canvases). Glyph outlines: font.outline(gid) answers the contours of a glyph as a Stationery::Fonts::Outline, in font units with y up, for TrueType (glyf, composite glyphs with their offsets, scales, 2×2 matrices, point-matched anchors and SCALED_COMPONENT_OFFSET, nested up to 8 deep), CFF (name-keyed and CID-keyed, .otf: Type 2 charstrings with every path operator, flex included, hints skipped by their stem count, local and global subroutines, the advance width and FDSelect; the deprecated seac form of endchar and the arithmetic operators raise UnsupportedFont), WOFF and collection fonts; moves, lines and cubic curves only, a TrueType quadratic raised to the cubic it is exactly. outline.append_to(path, x, baseline, size / units_per_em.to_f) adds it to a Stationery::Path in top-left space. Every glyph of Inter, Source Sans 3, Noto Sans JP, Open Sans, Latin Modern and TeX Gyre Termes reads as fontTools draws it. A Font keeps up to Font::OUTLINE_MEMO numbers of outlines, and nothing until one is asked for. Every PDF is byte for byte what it was. Fonts::GlyfOutline, Fonts::Charstring, Fonts::TrueType#outline, Fonts::CFF#outline.
  • Pictures of a render (#147): document.to_png(dpi: 96) answers a PNG String per page, and to_png("out.png") writes them too (out.png for one page, out-1.png, out-2.png, … for several); pages: picks pages by number, Range or Array. The pages to_pdf lays out are painted in pure Ruby by a scanline rasteriser: text from its glyph outlines (synthetic bold and oblique, letter spacing, rise, shaped runs), fills (nonzero and even-odd) and strokes (caps, joins with PDF's miter limit, dashes), clips, transforms, opacity, SVG linear and radial gradients, PNG, WebP and JPEG images, templates with the background layer under the page, and page numbers. Colour pictures are anti-aliased 24-bit RGB, with glyph origins, stroke edges and image edges on the pixel grid as poppler puts them: the ten examples and the benchmark documents differ from pdftoppm in 0.0 to 0.5% of their pixels at 72 dpi. monochrome: (as to_pdf takes it, the class's applying) draws a 1-bit PNG at the monochrome dpi with the same rules as the PDF, no anti-aliasing (a dot is black when its centre is inside); black and white match pdftoppm -mono in all but 0.1 to 2% of the dots. An upright dithered image is drawn at its own size in dots from its corner on the nearest dot, dot for dot the one-bit image the PDF embeds (#192: a box a fractional number of dots wide or tall stretched it by a dot where both edges rounded outwards, drawing a row and a column twice, so that up to 46% of a photo's dots differed; checked for a PNG and a JPEG photo at 203 and 300 dpi; Raster::Adjust.picture). Form fields draw nothing. sign:, encrypt:, conformance:, attachments:, print: and the other PDF-only options raise ArgumentError when given to to_png and are left alone when the class declares them. A 100 × 150 mm label at 203 dpi takes about 30 ms. Every PDF is byte for byte what it was. Also fixed: a PNG of 1, 2 or 4 bits a sample was decoded at the depth of the first such PNG decoded in the process (a once-only regular expression), which reached a PDF through a transparent palette PNG, a resampled or a monochrome image when the process had decoded one of another depth before. Stationery::Raster, Raster::Canvases, Raster::Painter, Raster::Scanner, Raster::Stroker, Raster::PNG, Monochrome::Bitmap#pixels.
  • JPEG pixels, decoded in Ruby (#147): to_png draws a JPEG, and a monochrome render and to_zpl dither it like a PNG. Images::JPEG#pixels answers an Images::Pixels (8-bit grey or RGB) for baseline, extended (16-bit tables) and progressive JPEGs with Huffman coding: every chroma subsampling (4:4:4, 4:2:2, 4:2:0, 4:4:0, 4:1:1 and other whole factors), restart intervals, scans of one component at a time, spectral selection and successive approximation, grey, RGB, YCbCr, CMYK and YCCK (Adobe's inverted CMYK turned into RGB as Pillow does). It follows libjpeg's accurate integer IDCT, "fancy" chroma upsampling and fixed-point colour conversion, so its pixels are libjpeg-turbo's: 62 test JPEGs (Pillow and cjpeg, 1 × 1 to 1001 × 3, every kind above) decode to exactly Pillow's pixels. pixels(at_least: [w, h]) decodes a photo drawn small at a half, a quarter or an eighth of its size through libjpeg's reduced IDCT, as a picture and a monochrome bitmap ask; each scale is decoded once per process. A 12-megapixel phone photo takes 2.8 s at full size and 0.7 s at a quarter (progressive: 3.4 s and 1.4 s), a 1000 × 1000 photo 0.25 s (Ruby 3.4 with YJIT; about four times as long without). A lossless, arithmetic-coded, hierarchical or 12-bit JPEG, and one of more than 33 megapixels (JPEG#unsupported says which), draws as a crossed box with a Warnings::SkippedImage naming why, and is reported as Warnings::NotMonochrome in a monochrome PDF. The EXIF orientation is not applied, as the PDF does not apply it. A PDF embeds the JPEG's bytes as it did, and every document without to_png or monochrome is byte for byte what it was. Images::JPEG::Decoder, JPEG::Scan, JPEG::IDCT, JPEG::Upsampler, JPEG::Colors, Images.unreadable, Images.pixels.
  • ZPL for label printers (#147): document.to_zpl answers ZPL II as a String, one ^XA…^XZ label per page, and to_zpl("label.zpl") or to_zpl(socket) writes it too. Each page is drawn one bit to a dot as to_png(monochrome: …) draws it (the class's monochrome settings, or the defaults: to_zpl is always monochrome and refuses monochrome: false) and written as ^PW/^LL from the page size in dots, one ^GFA graphic field and ^PQ copies. dpi: is 152, 203, 300 or 600 (others raise), by default the class's monochrome dpi, else 203; copies: by default the class's print copies:; compression: :z64 (zlib, Base64 and the CRC-16/XMODEM Zebra's software writes, the default) or :hex; pages: as for to_png. stationery render label.rb --zpl --dpi 300 writes label.zpl. Labels read back equal to_png's dots, and Labelary renders them identically. A new example, examples/shipping_label.rb, is a 4 × 6 in label with a Code 128 and a QR code. Stationery::ZPL, ZPL::Render; Raster::Render#paint and #bits are what it reuses.
  • Barcodes (#147): barcode "SX0042771903" draws a Code 128 (type: :code128, printable ASCII, digit runs in code set C), type: :ean13 an EAN-13 (the check digit added or checked), type: :qr a QR code (byte mode, level: :l/:m/:q/:h, versions 1 to 40, UTF-8 marked with an ECI) and type: :datamatrix an ECC 200 Data Matrix (ASCII encodation, two digits a codeword, bytes above 127 after an upper shift, in the smallest square from 10 × 10 to 144 × 144, with Reed–Solomon over GF(256) (0x12D) and the placement of ISO/IEC 16022 annex F; every size is module for module what libdmtx writes for the same data), encoded in pure Ruby and drawn as one filled path of vector bars. module_size: or width: size it, height: a linear one, the quiet zone is part of it (quiet_zone: false leaves it out) and it shrinks to fit; a monochrome render puts it on the dot grid, a whole number of dots a module; a tagged PDF makes it a figure described by its kind and data. In ZPL, native: true on the barcode or to_zpl(native: true) writes the printer's own ^BC, ^BE, ^BQ or ^BX where the picture had it, unless it is transformed, clipped, pale, over 10 dots a module, UTF-8 in a QR code or a Data Matrix holding _ (the escape character given to ^BX). Every symbol was decoded with ZBar (and dmtxread), and the native ones rendered by Labelary. Stationery::Barcode (Code128, EAN13, QR, QR::Matrix, DataMatrix, DataMatrix::Placement, ReedSolomon with its two fields ReedSolomon::QR and ReedSolomon::DATA_MATRIX), Layout::Barcode, ZPL::Native, and Canvas::Interface#barcode, which a canvas may draw its own way (the raster canvas records it as Raster::Canvas::Native).
  • Floats that stack taller than the page no longer run over it (#153). Three floats of 150 × 60 pt in a page of 260 × 160 pt do not fit side by side, so they stack to 180 pt: the third hung 20 pt over the bottom edge and the render reported an Overflow. Below nothing but floats at the top of a page, a float was placed without its height being looked at. Now the float that would reach past the bottom edge starts the next page, with the floats and the text written after it, one float at a time and page after page for a long run; the first float of a page is kept, so only a float taller than a page by itself overflows, and is reported. It is the same for images and boxes, left and right floats mixed, and inside a box, a list item, a column of a row, columns (the float goes to the next column) and a table cell: the first row of a table on a fresh page cuts a cell that holds floats as the top of a page. What does not fit below the floats at the top of a page (a box that stays whole, the part of a flow that would run over) goes to the next page and leaves the floats, where it was kept and ran over the page; a node in one piece and taller than the page by itself stays with them and is reported, as it was. Below other content the floats are cut in the same way, where they moved to the next page together and ran over that one: with a line above them, the first two stay below the line and the third starts the next page. Floats that fit a page together still move to the next page together when one of them does not fit what is left of this one. A tagged PDF reads the floats in the order they were written. A float copies the children after it only where the flow is cut. The suite paginates 120 random flows of paragraphs, nested boxes, lists, floats and runs of floats from fixed seeds (spec/stationery/layout/flow_fuzz_spec.rb, about 3 s): 48 of them overflowed or lost text, none do. Documents without floats are byte for byte what they were, and allocate what they did; floats that fit their page are where they were (examples/article.rb renders pixel for pixel). Layout::Flow::Splitter#float, #cut?, #lands?, #split_or_move, Layout::Table::RowSplitter.
  • A box with a background:, a border:, a shadow: or a link: beside a float wraps as a plain box does (#154). It was a block of the width the float left, all the way down: a long box with a background beside a short image stayed narrow for its whole height, with empty space under the image, because a float was painted before the box that followed it and a box that kept the full width would have painted its background over it. Now the box spans the full width of its flow, as a block does in CSS: its background, border and shadow run under the float, its lines wrap beside the float and take the full width below it, and the float is painted after the boxes beside it, so it sits on top of the background (a float with opacity: or a shadow: shows the background through it, and the link of a floated box lies over the link of a linked box). The padding lies under the float, so beside it the lines are the float's margin: away from it. It is the same for a box inside a box, inside a list item, whose marker is painted over the background of its body, and split across pages, where the background spans the full width on both. A tagged PDF reads the float where it was written, painted last or not: an image stays a Figure and a box has its role:; a floated box without one beside such a box is a Div, so that its content keeps its place (veraPDF passes PDF/UA-1 and PDF/A-3b). A box with a size or a place of its own (width:, height:, rotate:, overflow:, valign:), a row, a table and columns stay blocks: a table resolves its column widths from its width, a row shares its width and columns divides it, and none of them can be laid out again at another width from some row on. The random flows of the suite hold boxes with a shadow and a link too. Documents without floats are byte for byte what they were, and allocate what they did; so are documents whose floats have no such box in their flow (examples/article.rb). Layout::Box::Wrapping, Layout::Flow::Floating#paint_beside, Layout::Floated#reserve, Layout::Node#decorated?.
  • The part of a block cut at a page break beside a float is laid out where the whole was (#168). Beside floats a child goes beside them when the width left holds its least width, else below them; the part of it that stays on the page could have a smaller least width than the whole (a list whose wide float is further down, a box whose widest word went to the next page) and was measured and painted beside the float where the whole had been placed below it, narrower and taller than it was cut for, so it ran over the page, and a paragraph cut there was drawn under the float. Now the slot decided for the whole is what its parts are placed by, so what the splitter cut for is what the page measures and paints (Layout::Flow::Placement::Slot#need, Layout::Node#placing_width). The random flows the suite paginates give their floats widths in points again, which is where it showed: 31 of 2,000 seeds overflowed or lost text before, none of 5,000 do. Documents without floats are byte for byte what they were, and allocate what they did.
  • A word whose ligatures or kerning make it wider than its letters no longer hangs the render (#180). Wrapped at a width between the two (in Open Sans, 0.015 pt wide for office, waffle and affix, 0.06 pt in the italic), Text::Wrapper found the word too wide as drawn and then measured it letter by letter to break it, found that every letter fitted, moved nothing and went round for ever: a table whose column landed there never finished. A word broken between characters is now measured as it is drawn, a stretch of one style whole, for its break as for its fit: it breaks as any word wider than its line, and none of its pieces is wider than the line, where a piece could be by the width of a ligature. The head of the break is found letter by letter as before, so a word that was broken is broken where it was unless a piece's width as drawn and its width by its letters lie on either side of the line's width. The suite wraps the ligature words of Open Sans, Inter and Source Sans 3 at widths around each of their heads, with and without ligatures and inside a sentence. Documents without such a word are byte for byte what they were, and allocate what they did. Text::Wrapper#break_long_word.
  • A hyphenated line keeps within its width when its head forms a ligature (#184). Text::Wrapper chose the hyphenation point by the widths of the head's letters and drew the head whole, with its ligatures and kerning, so where the head drawn is wider than its letters (suffi- in Open Sans Italic by 0.06 pt at 10 pt, offi- in Open Sans by 0.015 pt) the line ran over its width by the difference, which justified text sets to the exact width. The head, hyphen included, now fits both by its letters, as before, and as drawn. The suite wraps ligature words hyphenated in English, German and Swedish in Open Sans, Open Sans Italic and Inter at widths around each hyphenation point, alone and after a word. No example and no document of the metrics gate hits it: all are byte for byte what they were; article and newsletter allocate 60 and 124 objects more (0.2% and 0.4%), so their baseline is recorded again. Text::Wrapper#hyphenated.
  • Faster, and fewer objects (#156). Measured again with bundle exec rake bench (the best of three runs): the invoice renders in 11.3 ms (13.1 ms in 0.11.0), the 1,500-row table in 205 ms (395 ms) and the photo document in 6.8 ms (9.0 ms), with 30% to 51% fewer objects. Stationery is 1.6 to 6.3 times faster than Prawn; sghtmltopdf, native code, is 3.5 times faster on the invoice and 2.2 times on the table, and level on the photo document. The README's Performance section and the docs Comparison page say so. Every document is byte for byte what it was through all of it, and the baseline of the metrics gate was recorded again with each step:
    • Numbers are written with one String where they took two: PDF::Serializer.number rounds a Float below 100,000 to four decimals itself and writes its digits, without Kernel#format; a Float within a millionth of a tie at the fifth decimal, one of 100,000 and more, a Rational, NaN and the infinities go through format("%.4f") as all of them did. The two were compared over 133 million Floats (every multiple of 0.0001 from -2,000 to 2,000, every tie from 0 to 200 with the Floats beside it, the ties a Float holds exactly, random Floats of every size). 0.5% to 3.8% fewer objects (the 1,500-row table 2,499,522, from 2,597,014).
    • Wrapping allocates less, and a word alone is not taken apart. One run of one word that fits its line and can break nowhere (no space, tab, newline, hyphen, soft hyphen, zero-width space or CJK character), which is what most cells of a table hold, becomes its line of one fragment straight away: 11 objects where cutting it into tokens took 53 (Text::Wrapper::OneWord). Any other text is cut as it was, with a StringScanner handing each token over as one String where String#scan made three, and its fragments gathered in a loop where chunk_while built enumerators. 1% to 24% fewer objects (the 1,500-row table 1,985,854 from 2,597,014, ten pages of text 259,535 from 299,086).
    • Missing glyphs are counted, and runs split between fonts, without a String per character: Fonts::Fallback.each_missing walks the codepoints of a text and asks the font once per codepoint (Font#lacks?, remembered), where Paragraph#count_missing, Forms::Typeface and Fallback#split made a String of every character of every fragment drawn and every run wrapped; a text the font covers allocates nothing. A GlyphRun writes its TJ array in one pass over its hex (GlyphRun#show); a run with .notdef glyphs is still written in its pieces. The warnings are the same. 6% to 47% fewer objects (ten pages of text 146,749 from 255,565, hyphenated and justified 174,491 from 329,057, the 1,500-row table 1,738,893 from 1,872,904).
    • A render that is not tagged builds no structure elements. The layout nodes of a render without tagged or a PDF/UA level (a paragraph's P, a table's Table, TR, TH and TD, a list's L, LI, Lbl and LBody, a Figure, a Form, a Link, a box's role, a contents entry's TOCI) carried a Tagging::Element that nothing wrote: 16,500 of them in the 1,500-row table. The builder now knows whether the render writes a tree (Layout::Context#tagged, Builder#element, Context#element) and the nodes carry nil where nothing will hold the element, which the canvas paints as it painted an element it could not attach. A tagged render builds what it built and its structure tree is the same. A node built by hand takes the elements it took: Layout::Text.new(runs, context:) with a Context whose tagged is true (Layout::Context.new(book:, style:, tagged: true), which now has three fields), or an explicit tag:; Layout::Box, Layout::Image and Layout::Field take tagged: false or tag: nil for a node that carries none. Up to 3.8% fewer objects (the 1,500-row table 1,672,825 from 1,738,894).
    • A colour is parsed once per String and writes its operators once: Color.parse("#000000") hands over the Color it made the first time, where every fill, stroke and run of text drawn parsed its colour again (a String#scan and an Array of pairs, eleven objects); up to Color::MEMO (256) colours are remembered, and past that the memo starts over, as the fonts' memos do. A Color, which is frozen, writes its fill and stroke operators when it is made and hands over the same frozen String each time. Colours given as Arrays are made as before. 1.4% to 19% fewer objects (the 1,500-row table 1,355,895 from 1,672,825).
  • A long table no longer holds a node per cell before its first page (#155). A cell measures its natural and minimum widths from one text node and lets go of it, keeping the two numbers; the node is built again when a page reaches the cell's row and goes with the page. Text of one run that sets as one line (words between single spaces, nothing a line breaks at or trims) is measured as its line would be, without wrapping it (Layout::Text#natural_width). The cells of a table share its options and their padding until a selection changes a cell's (Table::Cell#[]= copies them then), a table without rowspans resolves its columns and finds its cells without placing every cell on a grid (Table::Widths.per_column), and in a tagged render a row's TR, TH and TD are built when a page paints or cuts it (Table#tag_row), where every cell was tagged when the table was built (Table::Cell#tag is nil until then). A table of 33,000 rows peaks at 174 MB where it peaked at 445 MB, one of 165,000 rows at 584 MB where it did at 1,948 MB (what is alive when its columns are resolved: 105 MB from 913 MB); a tagged one still holds its structure tree until the file is written. The column widths are the same numbers, the structure tree is the same, and every document is byte for byte what it was (the examples, the fourteen of the metrics gate, and tagged and PDF/UA renders of tables, veraPDF passing ua1); they allocate up to 6.6% less (the 1,500-row table 1,276,414 objects from 1,355,895), so the baseline of the metrics gate is recorded again.
  • A table reads its rows from an Enumerator as pages reach them (#187). table(Order.find_each.lazy.map { … }, widths: [80, 0.5, 90]), an Enumerator lazy or not given to a table whose every column has a width, reads no row when the document is built and holds the rows of the page being filled and the one after them: a price list of a million rows with incremental: true peaks at 130 MB, where the same rows as an Array peak at 2,758 MB (81 MB and 485 MB for 100,000 rows; about 1% more objects allocated). Header rows, zebra, split_rows, cells spanning columns and selections by index from the start are applied to each row as it is read (Layout::Table::Stream); a row selected from the end (t.row(-1)) raises ArgumentError, and so do a cell spanning rows and a row with more columns than widths when they are read. A table in a row, in a box with break_inside: :avoid or beside a float is measured whole, reading every row. A table whose every column has a width no longer measures its cells' natural and minimum widths (17% fewer objects for the 1,500-row table, the same bytes), and zebra makes one selection of its rows where it made one per row, each listing every row of the table: 100,000 rows took minutes. An Enumerator with a column without a width, and an Array, are read when the table is built, as before (a lazy one raised NoMethodError, and is read now). The price list example reads its articles this way, its Description column 217 points wide.
  • A way for an agent to see what it rendered (#157). stationery render invoice.rb --png writes a picture of each page beside the PDF with to_png, in Ruby with nothing to install (invoice-1.png, invoice-2.png, …), and prints one line per file with its page and size in pixels; --dpi (96 by default: an A4 page is 794 × 1123 px), --pages 1,3-4, and --png-only for the pictures without the PDF. stationery inspect invoice.pdf (or invoice.rb, rendered first, with the warnings of the render) prints what is on each page as text, for an agent that cannot read a picture and for a diff between two renders: metadata, conformance claims, print hints, attachments, signatures, the outline, then per page its size, its text lines with x, baseline y, font and size, its images, links and form fields with their rectangles in points from the top-left corner, then the structure tree; --json prints the same as JSON. Stationery::Testing::Inspector#layout answers the same data to a spec, in a stable order and without the dates, so two renders of one document compare equal. It needs pdf-reader, as the testing helpers do. What a render writes is unchanged. Stationery::CLI::Inspect, CLI::Pictures, CLI::LayoutText, Testing::Layout, Testing::PageReader.
  • The examples ship with the gem and show their code (#157). examples/ is in the gem with the images the examples read, examples/shaping/, and without any rendered PDF, so an application that has only the gem can read and run them: stationery examples lists them with the first sentence of their header comment, stationery examples invoice prints the path of one and --source its code, and ls "$(bundle show stationery)/examples" or gem contents stationery find them without the command. Inter stays the one font the gem ships: the invoice examples are set in the Open Sans of the test suite in the repository, and in Inter where only the gem is. The gem grows by about 0.1 MB. On the docs site, the Examples page has the whole source of each example folded under its preview, read from the file when the page is rendered, and the Markdown twin of the page (/docs/examples.md, /llms-full.txt and get_page over MCP) carries the sources in full. The header comment of examples/invoice.rb says what it shows; the examples render byte for byte what they did. Stationery::CLI::Examples.
  • More examples, one per thing people build (#157), each with a preview on the docs site, its source under it and an integration spec: price_list.rb (2,000 rows from an Enumerator, a repeating header, incremental), contract.rb (numbered clauses with keep_with_next, a text field for both parties' initials in the footer of every page, signature fields that to_pdf(sign: { field: }) signs), certificate.rb (landscape, a frame drawn on a canvas in a background page template, centred type, signature fields), resume.rb (columns of 30 % and the rest, links, the body as html), menu.rb (columns, two floats, headings in Inter's bold), receipt.rb (a till receipt on an 80 mm roll declared monochrome dpi: 203, snap: true: a logo cut to one bit, VAT by rate, a QR code from barcode type: :qr and a cut line drawn on a canvas), accessible_report.rb (PDF/UA-1 with headings, figures with alt: and a table with header cells; rake verify:conformance validates it as PDF/UA-1 and PDF/A-3b) and multilingual_notice.rb (English, German and Swedish, each hyphenated by its own patterns, and a Japanese paragraph from a fallback font when CJK_FONT names one). examples/shaping/rtl_letter.rb is a letter in Arabic through the shaper hook; it needs harfbuzz-ruby and an Arabic font (ARABIC_FONT) and, like the rest of examples/shaping/, is left out of rake examples, the docs site and CI. The HarfBuzz example shaper now takes the base direction of a stretch from its first letter, not its first digit, so a line of Arabic that starts with a number stays right to left. The shipping label and the packing slip name no real carrier.
  • A skill for coding agents ships with the gem (#157). stationery skill install writes it where an agent finds it: ~/.claude/skills/stationery/ for Claude Code, ~/.codex/skills/ for Codex and ~/.agents/skills/ for OpenCode and the others that read it, to each whose directory is there (Claude Code when none is), or one named with --target claude|codex|agents|all; --project writes under the current directory, --dir into any skills directory. stationery skill status says per agent whether it is current, outdated (written for an older version of the gem) or missing (and where else to look when it finds none), and stationery skill print writes it as one Markdown document for an agent that takes a file. A SKILL.md that the gem did not write is kept unless --force. The skill is a short SKILL.md (the model in a page, the rules that surprise, how to render a document to pictures and look at them before calling it done, the warnings, strict and the matchers, conformance, labels and printing, where to read more: the docs by topic, the MCP endpoint, the examples) with reference/*.md beside it, cut from the sections of the README, and recipes/*.md for an invoice (PDF/A-3b), a report with contents, page numbers and a chart, a form, a flyer with photographs, a shipping label with barcodes and ZPL, and a receipt, each a document that renders and names the example that shows more. It is written from the README and the examples' header comments when it is installed, with the gem's version in its front matter, and the suite fails when a README section it embeds is renamed, when it names a method, command, option, example or docs page that does not exist, or when a recipe's document does not render without a warning. The gem is unchanged otherwise. Stationery::Skill, Stationery::CLI::Skill, Stationery::Examples (what stationery examples and the skill read the examples with).
  • The docs over MCP (#157). The docs site serves its pages to an agent over the Model Context Protocol at https://stationery.zoolutions.llc/mcp (docs-kit's read-only server: gem "mcp" and the two routes in docs/): search_docs finds sections, get_page reads a page as its Markdown twin and list_pages names them all, stateless JSON-RPC over POST, no key. claude mcp add --transport http stationery https://stationery.zoolutions.llc/mcp adds it to Claude Code, and any other client takes the same URL; the README and Getting started say so, and /llms.txt ends with an ## MCP section that names the endpoint. The gem is unchanged.
  • Shaped right-to-left text: what extractors read is measured, and what the gem writes stays (#151). Seven texts in Arabic and Hebrew were written three ways (the stretch in a Span with its text in logical order as ActualText, which is what ships; a Span per cluster in visual order; no Span where the ToUnicode map is enough) and read with the Inspector, PDFium, PDFKit, pdf.js, poppler and MuPDF. None is read as written by all: PDFium and the Inspector take ActualText as it is, poppler and MuPDF reverse it, and without it PDFium returned the words of a line in reverse order. pdf.js and PDFKit read the ToUnicode map, which cannot give the text of a font that draws a letter as several glyphs (Noto Sans, Naskh and Kufi Arabic). veraPDF passes all three as PDF/UA-1. The table is in the README under "Complex scripts: the shaper hook", and examples/shaping/extraction_matrix.rb makes it again (HarfBuzz, the extractors and the fonts are installed apart from the gem). The gem and the PDFs it writes are unchanged.
  • The metrics gate holds what 0.11 added (#156): bundle exec rake metrics renders seven more documents (article for floats, newsletter for columns, webp for a lossless WebP decoded in the render that is measured, html with a stylesheet, pdf_ua under conformance :pdf_ua1, text_incremental with a footer and incremental, text_shaped through a shaper written in Ruby), fourteen in all, so a change that makes any of them allocate more than 3%, write more than 1% more bytes or paginate differently fails CI. The seven documents it held are recorded as they were; the gate takes 4.2 s where it took 3.0. The baseline is recorded again for this release: 7 to 48 objects fewer for four documents, pages and bytes unchanged (0.12.0 is as long a version string as 0.11.1, so the bump moves no byte).
  • AGENTS.md at the root of the repository (#157): what a contributor, human or not, has to know and cannot read off the code. A spec first and the suite by its exit status, what the metrics gate holds and when its baseline is recorded, the canvas interface, which validator checks which claim, how the changelog is written, that releases are cut with bin/release, and what is out of scope by design. The gem is unchanged.
  • rake verify:conformance and verify:timestamp run a verapdf on the PATH when there is one (brew install verapdf), and Docker's verapdf/cli otherwise, as before; VERAPDF_IMAGE still chooses the image and forces Docker. The gem is unchanged.
  • Behaviour changes: page checks size: and margin: when the class is defined and raises ArgumentError naming what it takes: an unknown name raised when the document was rendered, and page size: ["102mm", "74mm"] or margin: "3mm" raised NoMethodError or ArgumentError: not a box from inside the layout. A size in points is two positive, finite numbers, so page size: [0, 100] is refused too, and a margin's sides are finite numbers (negative ones are taken as before). The message for an unknown size names the new sizes and forms. Components and documents have the private methods mm, cm, inch and pt, and print in a class body declares print hints where it called Kernel#print (methods of a document still call Kernel#print). A render with conformance :pdf_ua1 and a link in the header row a table repeats or a canvas.link without a tag: now raises ConformanceError (it wrote a file veraPDF rejected), and a tagged render with one has a Warnings::UntaggedLink more, so with strict it raises WarningsError; a link in a header, a footer or a page template is tagged, so the structure tree of a tagged document with one has a Link element more per link and page, and such a document under PDF/UA-1 now passes. Warnings::MissingGlyph has a fourth member, stand_in (nil unless missing_glyphs: :replace drew one), which its to_h and pattern matching show. stationery render --zpl exits 1 with the warnings when to_zpl raises WarningsError (a max_pages limit, or a strict document), where a strict document printed a backtrace. A document whose floats ran over the bottom of a page paginates differently: the float that did not fit, and what was written after it, are on the next page, and the Overflow is no longer reported. So does one where a block ran over the page below floats at its top. Floats that fit the page they are on, and what is beside them, stay where they were. Beside a float, a box with a background, a border, a shadow or a link is laid out at the full width with its lines around the float, and the float is painted over its background, where it was a block of the width left beside it and the float lay under nothing; a document with floats and such boxes may break its pages elsewhere, and in a tagged one a floated box without a role: beside such a box is read inside a Div. The part of a block cut at a page break beside a float is placed by the slot of its whole, below the float where the whole went below it, where it was drawn beside the float narrower and taller than it was cut for. A word broken between characters whose piece fits by its letters but not as drawn (a ligature or a kerning pair wider than its letters) breaks a character earlier, and one whose piece fits as drawn but not by its letters is no longer broken there. A word whose hyphenated head fits by its letters but not as drawn is hyphenated at the point before, or moves to the next line. A PNG of 1, 2 or 4 bits a sample decoded after one of another depth in the same process is decoded at its own depth, so a PDF that embedded it wrongly (a transparent palette PNG, a resampled or a monochrome image) changes. An Enumerator given to a table with a width for every column was read whole when the table was built; it is now read as pages reach it, a row selected from its end raises ArgumentError, and so do a cell spanning rows and a row with more columns than widths; an Enumerator::Lazy raised NoMethodError and is read now. A layout node built by hand with a Layout::Context that is not tagged carries no structure element (Layout::Context.new has a third field, tagged), and Table::Cell#tag is nil until a page paints or cuts the cell's row. The gem ships examples/ (about 0.1 MB), and the HarfBuzz example shaper takes the direction of a stretch from its first letter, not its first digit. Documents that use none of these are byte for byte what they were in 0.11.1.

0.11.1 (2026-09-28)

What putting 0.11.0 to work found: PDF/UA claims that hold, boxes that stay on their page, columns that fill evenly, and lists that widen below a float.

  • PDF/UA-1 refuses heading levels that are skipped (ISO 14289-1, 7.4.2; veraPDF rule 7.4.2-1): the first heading of a document is heading: 1 and a heading is at most one level below the heading before it, so heading: 1 followed by heading: 3, or a document that starts at heading: 2, raises Stationery::ConformanceError with an issue per heading (heading 3 on page 2 skips a level: heading 2 is the deepest that may follow heading 1) instead of writing a file that claims a level it does not keep. Going back up is free (3, then 1), and a heading that skipped is what the next one is measured against. Headings are read in the order of the structure tree, which is what a validator follows, inside sections, lists, table cells, columns and floats, from text(heading:), html and markdown alike; the headings of headers, footers and page templates are artifacts and a heading without text is not written, so neither counts. In a tagged render without the claim it is a Warnings::SkippedHeading (level, allowed, page), so strict catches it.
  • A blank alt: is a missing one: image(source, alt: ""), svg(source, alt: " ") and a markdown image without a description (![](photo.png)) are reported as Warnings::MissingAlt in a tagged render and raise ConformanceError under PDF/UA-1 (ISO 14289-1, 7.3; veraPDF rule 7.3-1 fails an empty /Alt), where only a missing alt: was caught. An alt: of whitespace alone is refused as well, although veraPDF accepts it: it describes as little as the empty one. alt: false still marks decoration, which is an artifact and needs no description, and the message of MissingAlt now names it: image on page 1 has no alt: text (alt: false marks decoration).
  • In html, <img alt=""> is decoration, as HTML means it: an alt attribute that is there and empty (or whitespace alone) draws the image as alt: false does, an artifact outside the structure tree, floated or not, so it neither warns nor raises. An <img> without an alt attribute is still a missing description. Markdown has no way to say that an image is decorative, so ![](photo.png) counts as a description forgotten: use html or the image element for decoration.
  • A box that splits across pages no longer runs over the page by the padding and border that close it. A head is open at the bottom, so its content was cut at the height less the top of the box alone: content that ended on the page by that measure was placed with the bottom padding under it, up to that much over the edge, and reported as an Overflow. The last of the content now goes to the next page with what closes the box; content that cannot be cut there moves whole, and first on a fresh page it is kept and reported as before. Boxes inside boxes added their paddings up: 29 of 200 random flows of nested padded boxes overflowed, none do. A box that did not overflow is laid out as it was.
  • Balanced columns fill evenly: ten lines in three columns are 4, 3 and 3 (they were 4, 4 and 2, the last column taking what was left over), thirteen in four are 4, 3, 3 and 3. At the balanced height the first column takes what fits, and what it leaves is balanced through the columns after it in the same way, down to the last two, so what cannot be shared goes to the earlier columns. Every column is still cut where a page would cut it, so orphans:/widows:, keep_with_next, break_inside: :avoid and blocks that cannot split hold as they did; the columns filled in order are kept where the levelled ones would not place everything, would change the height of the block, or would have a later column taller than an earlier one where there was none. The block is exactly as tall as it was, so what follows it stays where it is, and a block of two columns, balance: false, the full pages of a block that continues and documents without columns are byte for byte what they were. A block is levelled once, when it is placed, with at most (count - 2) * 41 pours more than its balancing: 700 paragraphs in two and three columns render in the time they did. Layout::Columns::Leveller, Layout::Columns::Pour#first.
  • Beside a float, a list item and a box that paints nothing of its own wrap what they hold: they keep the full width of their flow, and their lines are narrow beside the float and take the full width below it, in the middle of a paragraph if need be. They were blocks of the width the float left, all the way down, so a long item or box beside a short image stayed narrow below it. That is a box without background:, border:, shadow: or link: and without width:, height:, rotate:, overflow: or valign:; its padding lies under the float, as a block's does in CSS. A list item keeps its indent from the float, with the marker beside its first line. They split across pages as they did (break_inside:, keep_with_next:, orphans: and widows: inside them), the lines carried over are wrapped again at the full width, and a tagged PDF reads them in the order they were written. In html, the items of a list beside a floated img wrap, and so does a block indented by margin-left or margin-right, whose margin lies under the float. A box with a background, a border, a shadow or a link stays a block of the width that is left: it is painted after the float written before it, and at the full width it would paint over the float. So do a table, a row and columns, whose columns depend on their width. Documents without floats are byte for byte what they were, and allocate what they did. Layout::Box::Wrapping, Text::Exclusions#inset.
  • html reads margin-left and margin-right, which were reported as Warnings::UnsupportedCss: lengths in px or pt, as margin-top takes them. On a floated img they are that side of the space kept around it, so style="float: right; margin-left: 12pt" keeps 12 pt between the image and the text. On a block (p, div-like containers, headings, lists, tables, blockquote, pre) they indent it from that side, its background and padding inside the margin; the block breaks across pages where it did, and a heading or a paragraph keeps its keep_with_next. margin and its sides are read in the order of the cascade, specificity first and position then: a side wins over a margin declared before it, and a margin takes back the sides declared before it. auto, em and % are reported as they were, and so is a negative margin-left or margin-right. HTML without margins renders byte for byte as before. CSS::Stylesheet#matching.
  • Testing: Inspector#text and #page_texts, and so have_pdf_text, have_pdf_text_on_page and assert_pdf_text, read a sequence with /ActualText as that text, once, whatever the glyphs inside it advance. A shaped stretch whose first glyph has no advance (a mark drawn before its base, as HarfBuzz answers for Arabic) was missing from the text: pdf-reader gives the ActualText to the first glyph shown and drops a glyph without a width. The helpers now read a page through the receiver Inspector#structure already read it with: glyphs outside such a sequence are laid out by pdf-reader as before, and the sequence is one run from where its first glyph is shown to where it ends. The text of a page without ActualText is what it was. ActualText and the structure tree are read with pdf-reader 2.12 and later, where they needed 2.16. For a PDF Stationery did not write, untagged_text and structure follow a form XObject the page draws, as text does. The PDFs written are unchanged.
  • Testing: fields: true reads what form fields show. A field's value is drawn by its widget's appearance, a form XObject of the annotation and no part of the page content, so have_pdf_text("Astrid") does not find the value of text_field "name", value: "Astrid". have_pdf_text("Astrid", fields: true), have_pdf_text_on_page(1, "Astrid", fields: true), assert_pdf_text pdf, "Astrid", fields: true, refute_pdf_text, Inspector#text(fields: true) and #page_texts(fields: true) read the normal appearance of every widget with the page content, each where the widget is on its page: text fields (every line of multiline:, every cell of comb:), a select's value, a signature field's label, characters no font has from their ActualText, a field set in the standard Helvetica, in an encrypted document as in a plain one. A widget that is hidden (Hidden, NoView) is not read, and of a button's appearances the one its state names, never Off. It is an option and not the default, so no text a spec reads today changes: the text of a form with its fields is other text (more words, and other blank lines between those of the page content), which not_to have_pdf_text and a comparison of Inspector#text would notice. Without the option, the failure of have_pdf_text over a text that a field shows says so.
  • Behaviour changes: in html, the left and right of the margin shorthand indent a block, as CSS means them: margin: 0 20px drew no indent. Top and bottom draw what they drew. A margin declared after margin-top or margin-bottom, or by a more specific rule, takes them back, where the side always won. A negative margin on a floated img is drawn as none: it moved the image out of the flow, off the page when it was large, which nothing in a style may do. Beside a float, a list or a box without a background or a border is laid out at the full width with its lines around the float, where it was laid out at the width left beside it; a document with floats and such content may break its pages elsewhere. A render with conformance :pdf_ua1 whose heading levels skip, or with a figure whose alt: is blank, now raises ConformanceError (it wrote a file veraPDF rejected); a tagged render reports both as warnings, so with strict it raises WarningsError. PDF/A alone asks for neither (level B does not ask for structure) and raises for neither. In a tagged render an html image with alt="" is an artifact, where it was a Figure with an empty /Alt. The message of MissingAlt, and with it the issue of the ConformanceError, ends in (alt: false marks decoration). A box that ran over the page by the padding or border that closes it breaks a line or more earlier, and no longer reports an Overflow. Documents that are not tagged, and tagged documents that keep both rules and have no <img alt="">, are byte for byte what they were.

0.11.0 (2026-09-28)

Text wrap around images, balanced columns, lossless WebP, a shaper hook for complex scripts, and far less memory for long documents.

  • Floats: image(source, float: :left | :right, margin:) and box(float:, margin:, width:) { … } take an image or a box to the edge of the flow it is written in (page body, box, column, cell), and what follows wraps beside it: every line of a paragraph takes the width left at its own top, aligned and justified in it, and the full width again below the float, mid-paragraph. margin: is a number (kept on the sides that face the text) or names the sides as padding: does; a floated box needs a width: (points, a fraction or :auto). Tables, boxes, rows, list items and other blocks go beside the float in the width that is left when they fit, else below it. A second float goes beside the first when it fits, else below; left and right floats coexist; a paragraph whose widest word does not fit starts below them. The flow is as tall as its floats. A float never splits: it moves to the next page with the content that wraps around it, and the lines a paragraph carries over a page break are wrapped again at the full width. In a tagged PDF the float stays where it was written, a Figure or the box's role:. html reads float: left|right on img and <img align="left|right">, with the image's CSS margin or styles: { img: { float_margin: 8 } }; float on any other element is still reported as Warnings::UnsupportedCss. examples/article.rb shows a floated photo and a pull quote. Documents without floats are byte for byte what they were, and allocate what they did.
  • columns(count: 2, gap: 12, balance: true, rule: nil) { … }: one flow poured through columns, newspaper style (a row is columns side by side with content of their own). The content is laid out at the column width and cut where a page break would cut it: between children, between the lines of a paragraph (orphans:/widows:), inside boxes and tables by their own rules, never inside break_inside: :avoid, and keep_with_next keeps a heading in the column of what follows it. balance: true ends the columns at nearly the same height where the content ends (the last page of the block, and before a page_break inside it) by searching for the shortest column height that holds everything; balance: false fills each column before the next. A block that does not fit fills the height left on the page and continues on the next; below other content it starts only when every column takes something, and otherwise moves to the next page. What follows the block starts below its tallest column. rule: true | { color:, width: } draws a line between the columns (an artifact in a tagged PDF). Works inside boxes and inside another columns; a tagged PDF reads column 1, then column 2, with a paragraph split across columns as one P. html maps column-count, columns: <n> and column-gap on div-like containers, p, headings, lists, tables, block quotes and pre to it. examples/newsletter.rb. Layout::Columns, Layout::Columns::Pour, Layout::Columns::Balancer.
  • Lossless WebP: image "photo.webp" embeds a lossless WebP (VP8L, in a plain or an extended VP8X container, whose colour profile and metadata chunks are skipped), decoded in Ruby with no dependency: prefix codes, backward references, the colour cache and the predictor, cross-colour, subtract-green and colour-indexing transforms. It is embedded like a PNG, RGB as a Flate stream with the alpha channel as a soft mask when a pixel is not opaque, and takes everything image offers, downscale: true included. A 1000 × 1000 px image decodes in 0.2 to 0.4 s, once per process. Lossy and animated WebP still raise UnsupportedImage, now naming which it is, as do truncated and damaged files and images of more than 33 megapixels (2^25 pixels). image.stationery reports format: "WebP"; the message for an unknown format reads "only JPEG, PNG and lossless WebP images are supported".
  • Shaper hook: shaper MyShaper at class level (inherited; shaper nil takes it away) or to_pdf(shaper:) hands every text to an object answering call(text, font, size:, features:, language:), so an application with HarfBuzz can set Arabic, Hebrew, Indic or Thai text. It answers Stationery::Shaper::Glyphs (gid, advance, x_offset, y_offset in font units, cluster) in visual order, or nil to decline a text; font is a Stationery::Shaper::Face (path, index, data, units_per_em, postscript_name, glyph_count). Line breaking measures words through the shaper, then every stretch of a line in one font and style is shaped whole, measured and drawn from that one answer: advances as TJ adjustments against the font's own widths, offsets as pen movements and text rise. Shaped glyphs are embedded whatever they map to, and glyphs the ToUnicode map cannot speak for (out of logical order, several for one cluster, standing for another text already) are shown in a Span with the characters as ActualText. Glyph 0 is a Warnings::MissingGlyph; an answer that cannot be drawn raises Stationery::ShaperError. The gem shapes nothing itself and has no new dependency: there is no reordering across stretches or lines, justification widens spaces only, form fields are not shaped, and poppler and MuPDF return a right-to-left ActualText reversed. examples/shaping/harfbuzz_shaper.rb is an adapter for the harfbuzz-ruby gem. The memo of shaped runs starts over after 64 KB of text, like the font's own. Without a shaper every render is byte for byte what it was.
  • Long documents hold far less while they render, and every form of to_pdf writes the bytes it wrote. A page's nodes are let go once it is painted (the root flow, the builder and FontBook's record of split runs no longer keep them), the fonts' shape and metrics memos start over after 64 KB of text instead of keeping every line drawn, a table without rowspans measures a row when a page reaches it (Node#height_within), a flow copies the children still to come once a page instead of once a child, and a page nothing paints on again keeps its deflated stream instead of its operators. Peak resident memory of 1,000 pages of text 391 → 111 MB and of 5,001 pages 1,556 → 350 MB; a table of 33,000 rows 901 → 653 MB and one of 165,000 rows 3,792 → 2,952 MB (what is alive when pagination ends: 216 → 17, 1,062 → 41, 494 → 28 and 2,437 → 65 MB). What is left is the document as it was built, which for a table is a node for every cell. bundle exec rake memory measures it.
  • to_pdf(incremental: true), or incremental at class level, writes each page's content as soon as the page is painted, to a block before the next page is painted. For documents with headers, footers, page templates or a table of contents, which otherwise hold every page's operators until the page count is known: 5,189 pages with a footer peak at 446 MB instead of 614 MB (1,643 MB before), with 20 MB alive at the end of pagination instead of 180 MB. The file has its page contents first and fonts, page tree and catalog last, and what is painted once every page is known is a content stream of its own under or over the body, so it is a little larger (1.5 → 1.7 MB for 1,039 pages). A render with conformance: or sign:, and a strict one that goes to a block, take the usual path, since they must be checked before a byte is written; to a block an error on a late page leaves the start of a file. A document without headers, footers, templates or contents gains nothing from it.
  • A character no font has raises under a conformance level: text may not reference .notdef under PDF/UA-1 (ISO 14289-1, 7.21.8) nor under PDF/A-2b and PDF/A-3b (ISO 19005-2/3, 6.2.11.8; veraPDF rules 7.21.8-1 and 6.2.11.8-1), so conformance now raises Stationery::ConformanceError with an issue per character and family ("☃" (U+2603) is in no font of Brand: add a font or font_fallbacks that covers it) instead of writing a file that claims a level it does not keep. Body text, page templates and form field values are all covered. Whitespace a font lacks draws as a blank and is accepted, and without conformance a missing glyph stays a MissingGlyph warning.
  • Form field appearances keep characters no font has, as body text does since 0.10.1: a text field's value (single line, multiline: and comb: cells), a select's value and a signature field's label wrap each run of .notdef glyphs in a /Span with /ActualText, inside the text object and inside /Tx BMC … EMC, so text_field "name", value: "日本語" extracts as 日本語. The appearances were already written this way; it is now specified and documented. The MissingGlyph warning and the bytes of values the fonts cover are unchanged. A field made without a font book (Forms::Field.new on a bare canvas) still writes a character outside Windows-1252 as ? without ActualText: that is a real glyph, and /V holds the value.
  • Behaviour changes: a render with conformance that draws a character no font has now raises ConformanceError (it wrote a file veraPDF rejected), under PDF/A-2b and PDF/A-3b as well as PDF/UA-1. In html, <img align="left|right"> floats the image, where it drew a block of its own. The lines a paragraph carries over a page break are wrapped again when the width they continue at is another. image and box raise ArgumentError for margin: without float: and for float: with at:. The message of UnsupportedImage for an unknown format names lossless WebP, and a lossy or animated WebP is named as such. rake verify:conformance also validates examples/article.rb and examples/newsletter.rb.

0.10.1 (2026-09-28)

  • Extracted text keeps characters no font has: each run of .notdef glyphs is wrapped in a /Span with /ActualText holding the written characters, and ToUnicode maps glyph 0 to U+FFFD instead of the first missing character drawn, so 日本語 set in a font without those glyphs copies out as 日本語, not 日日日. The MissingGlyph warning and the bytes of text the fonts cover are unchanged.

0.10.0 (2026-09-28)

CSS for html, better line and page breaking, timestamped signatures, and streaming to a block.

  • html reads CSS: <style> rules (element, .class, #id, compound selectors, comma lists, *; also inside @media print/all) and inline style, cascaded after styles: by specificity. Properties: color, font-size (px, pt, em, rem, %, keywords), font-weight, font-style, text-decoration, text-align, background-color and padding on blocks and cells, margin-top/margin-bottom, border on table/td/th, width on img, table and cells, page-break-before/-after/-inside and the break-* forms. <font color size> and <center> are read too. Anything else, and selectors with combinators or pseudo-classes, is ignored and reported once as Warnings::UnsupportedCss; url() and @import are never read. HTML without styles renders as before.
  • Orphans and widows: orphans: and widows: on text, text_style, default_text and rich styles: (p, li), default 1 and 1. A paragraph that would leave too few lines behind moves whole; one that would carry too few takes more with it; at the top of a fresh page it splits rather than overflow.
  • Line breaking: a zero-width space (U+200B) and <wbr> are break opportunities that draw nothing and never reach the file. Lines may break between CJK characters (ideographs, kana, Hangul, fullwidth forms) with kinsoku rules: no break before closing punctuation, small kana and iteration marks, none after an opening bracket. Hyphenation and the character-break fallback leave CJK runs alone.
  • Signature timestamps: sign(…, timestamp: "https://tsa.example/tsr") (or a Hash with url:, username:, password:, hash:, client:) requests an RFC 3161 token over the signature and embeds it as id-aa-signatureTimeStampToken, giving a PAdES-B-T signature. A TSA that cannot be reached, refuses, or answers for another imprint or nonce raises Stationery::SignatureError; nothing is written untimestamped. Inspector#signatures entries gain timestamp: { time:, tsa:, valid: }.
  • Streaming: to_pdf { |chunk| … } hands the file over in pieces as objects are written (first bytes sooner, no output buffer) and returns the byte count. Layout still runs in full first, so peak memory is unchanged. Every other form of to_pdf returns the String and is byte for byte what it was. A block with sign: or with a target raises ArgumentError.
  • Behaviour changes: a page_break inside a group now breaks the page (it was ignored unless the group overflowed). image(width: 0.5): a Float up to 1 is a share of the available width, as for boxes and columns, so width: 1.0 is full width, not one point. A table cell may be a Hash of cell options around its content. In SVG and HTML stylesheets an at-rule no longer swallows the rule after it.
  • Stationery::CSS holds the selector and stylesheet parsers SVG and HTML share (SVG::Selector and SVG::Stylesheet remain as constants). Text::RunsBuilder#scale and #with.
  • Benchmarks: rake bench compares three engines when the :benchmark group has sghtmltopdf (Prawn and stationery otherwise) and reports the best render, allocations, bytes and pages; the docs gain a Comparison page with a feature matrix against Prawn and sghtmltopdf.

0.9.0 (2026-09-28)

Forms in the document's fonts, digital signatures, a much faster text and table pipeline, and hardening for untrusted input.

  • Digital signatures: sign certificate:, key:, chain:, passphrase:, reason:, location:, contact:, name:, field:, at: at class level (values may be callables or method names, resolved per render) or to_pdf(sign:). Fills a named signature_field or adds an invisible one, writes /ETSI.CAdES.detached CMS with the ESS signing-certificate-v2 attribute (PAdES baseline B-B shape) over the whole file, RSA or ECDSA with SHA-256, built on the stdlib openssl (required only when signing). Works with encryption and under PDF/A-3b and PDF/UA-1; /SigFlags 3 on the form. Verified with openssl cms -verify, pdfsig and veraPDF; rake verify:signature. Inspector#signatures (validates the digest and the CMS), have_signature, assert_pdf_signature. No timestamps, LTV or multiple signatures yet.
  • Forms: field appearances use the document's fonts, embedded and Identity-H encoded with per-character fallback, so values in any script the fonts cover render; check marks and radio dots are vector paths, so no standard-14 font is referenced. An editable field keeps printable ASCII and Latin-1 (plus a select's options) in its font, a read_only: field only its value. Every field gets /TU from tooltip:, label: or its name; NeedAppearances is dropped under a conformance level. Forms are now allowed under PDF/A-3b and PDF/UA-1 (veraPDF passes examples/form.rb as both). Documents without fields are byte-identical.
  • Performance: the wrapper measures each segment once and Font#width_of memoises advance and kerning together, keyed by feature-tag identity, undoing the 0.7.0 regression (an 8-page text document 163 → 45 ms, byte-identical); tables carry column widths and row heights across page breaks (also fixing column widths drifting between pages), stroke each cell border as one path (the 1,500-row table's PDF 236 → 134 KB) and place rows on a grid only when a page paints them: 913 → 399 ms. A deterministic metrics gate (rake metrics, benchmark/baseline.json: allocations, pages, bytes) runs in CI; README performance figures now reproduce.
  • Untrusted input: html and markdown take max_depth: (64); deeper elements, block quotes and lists are flattened into the deepest kept with a Warnings::NestingLimit, never a SystemStackError. Markdown inline parsing is bounded (brackets, labels, emphasis), so pathological input finishes in milliseconds instead of minutes. SVG keeps 128 levels and caps use chains at 32.
  • image with an IO source no longer raises NameError for Pathname in plain Ruby; a spec now renders every feature in a fresh interpreter without the test bundle so a missing stdlib require cannot hide again.
  • SVG: use, symbol, nested svg and clipPath shipped in 0.8.0; the docs Forms page and the docs image now include the example fonts, so every /examples/<name>.pdf renders on the site.

0.8.0 (2026-09-28)

Compliance: PDF/A-3b, PDF/UA-1 and Factur-X e-invoices, verified with veraPDF and Mustang in CI.

  • Factur-X / ZUGFeRD: factur_x(profile: :en16931) { @invoice.to_cii_xml } (a block, a method name or the XML itself; also to_pdf(factur_x:)) embeds the invoice XML as factur-x.xml (xrechnung.xml for :xrechnung) with the relationship the profile requires, writes the fx: XMP properties with their extension-schema description and turns on PDF/A-3b. Profiles :minimum, :basic_wl, :basic, :en16931, :extended, :xrechnung. The gem does not build the XML; examples/e_invoice.rb shows a minimal EN 16931 document. Inspector#factur_x, have_factur_x, assert_factur_x; rake verify:factur_x validates with Mustang.
  • PDF/A and PDF/UA: conformance :pdf_a3b (or :pdf_a2b) and conformance :pdf_ua1, combinable, at class level or to_pdf(conformance:). PDF/A writes an sRGB output intent (the ICC's sRGB2014.icc, bundled with its licence), pdfaid identification and printable link annotations, and refuses encryption and, under A-2b, attachments. PDF/UA turns tagged on, requires metadata title: and lang:, writes pdfuaid:part 1, tab order and link descriptions, and raises ConformanceError for a figure without alt:. CMYK colour under PDF/A is reported as Warnings::ConformanceIssue. Form fields are refused under every level until their appearances embed their fonts. Inspector#conformance, have_conformance, assert_pdf_conformance; rake verify:conformance runs veraPDF through Docker, and CI runs it on the examples.
  • XMP metadata: every document carries an XMP packet mirroring the Info dictionary (dc:title, dc:creator, dc:description, dc:subject, dc:language, xmp:CreateDate/ModifyDate/MetadataDate, xmp:CreatorTool, pdf:Producer, pdf:Keywords) as an uncompressed /Metadata stream; metadata xmp: false or to_pdf(xmp: false) opts out. Inspector#xmp and #xmp_values.
  • Embedded files: attach_file "terms.pdf", data, mime:, description:, relationship:, modified_at: at class level, to_pdf(attachments: [...]) per render. Each file is a /Filespec in the catalog's /EmbeddedFiles name tree and /AF, with /AFRelationship, size, checksum and date; streams are compressed and encrypted with the document. Inspector#attachments, have_attachment, assert_pdf_attachment.
  • SVG: use (href or xlink:href, onto shapes, groups, text, symbols and other use elements, with cycle detection), symbol and nested svg viewports (viewBox, preserveAspectRatio with meet/slice/none, clipped unless overflow="visible"), and clipPath (clip-path="url(#id)" on shapes, groups, text and use; clip-rule, clipPathUnits="objectBoundingBox", nested clip paths). The color property feeds currentColor, so one sprite symbol can be drawn in several colours. image, mask, pattern, filter and textPath stay unsupported and reported.
  • CLI: stationery render file.rb renders the document the file itself defines, so a file that subclasses a document from another file no longer asks for --class.

0.7.0 (2026-09-28)

Typography and images: hyphenation, OpenType features, oversized-image warnings, page labels.

  • Hyphenation: hyphenate: true | "en" | "de" | "sv" on text, text_style, default_text and the rich styles: (p, li). Liang's algorithm over the TeX hyph-utf8 patterns for English, German and Swedish (bundled with their licences), so a word that does not fit breaks at the longest hyphenation point that does, with the font's hyphen. A soft hyphen (U+00AD, &shy; in HTML) is a break point in any language: never drawn or measured unless the line breaks there, and absent from extracted text. Off by default; unchanged output otherwise.
  • OpenType features: features: %i[smcp onum tnum ss01] on the same entry points applies the font's GSUB single substitutions (formats 1 and 2, also behind Extension lookups) before ligatures; Font#features lists what a font offers, an absent feature is ignored. Substituted glyphs keep their source characters in ToUnicode. Contextual lookups (calt, clig, frac) stay unsupported and are listed as such.
  • Oversized images: a bitmap drawn at more than twice max_ppi (300 by default; images max_ppi: 220 per document, image(max_ppi:) per call, nil disables) reports Warnings::OversizedImage naming the source, pixel width, effective ppi and the limit, so strict catches a 1600 px photo drawn 160 pt wide. downscale: true resamples a PNG (grey, RGB, palette, alpha) to the limit before embedding; JPEG is never re-encoded and only warns.
  • Page labels: page_labels 1 => { style: :roman_lower }, 4 => { style: :decimal }, 12 => { style: :alpha, prefix: "Appendix " } (or to_pdf(page_labels:)) writes the /PageLabels number tree; Inspector#page_labels, have_page_labels and assert_page_labels read it back.
  • text(…, markup: true) decodes HTML character references (&amp;, &#39;, &#x27;, the named entities the HTML parser knows), so data escaped with html_escape before adding your own <b> tags renders as intended instead of showing &#39;.

0.6.0 (2026-09-28)

Instrumentation, so a render shows up in AppSignal and friends, and a faster text pipeline.

  • Instrumentation: Stationery.instrument emits render.stationery (the whole to_pdf: document:, pages:, bytes:, warnings:), build.stationery, paginate.stationery, write.stationery (serialise, subset, deflate), image.stationery (each decode on a cache miss: format:, width:, height:, bytes:), font.stationery (action: :parse on a cache miss, action: :subset per embedded font with glyphs:) and parse.stationery (format: :html | :markdown). Stationery.instrumenter is ActiveSupport::Notifications when it is loaded (the Railtie pins it), so AppSignal, Skylight and log subscribers pick the events up with no setup; otherwise a no-op. Assign any object responding to instrument(name, payload, &block) to plug something else in. Output is unchanged.
  • FontBook#resolve memoises by family, weight and style instead of hashing the whole 13-field Text::Style on every measurement: the lookup is 4x faster and a photo-heavy 4-page document renders 22% faster.

0.5.0 (2026-09-27)

Rotated, overlapping, cropped photos (the collage look), and fixes from the first weeks of use.

  • stack { … } and layer(top:, right:, bottom:, left:, width:, height:, **box) { … }: overlapping content. The stack's ordinary children set its height; each layer is a box placed with CSS-inset semantics relative to the stack (points, or fractions of the stack's width/height; negative insets overhang) and takes no flow height. A stack moves to the next page whole. examples/postcard.rb shows the collage: a cover photo with two tilted, framed, shadowed photos over its corners.
  • box(rotate:) turns a box clockwise around its centre without moving its layout rectangle (a rotated box never splits); box(shadow: true | { offset:, blur:, color:, opacity: }) paints a soft drop shadow as stacked rounded rectangles at fading opacity, an artifact taking no space; box(overflow: :hidden) clips the content to the rounded outline. column takes the same options.
  • image(fit: :cover, width:, height:) scales up to fill the box and clips the excess around the centre; image(radius:) clips to rounded corners; image(rotate:) turns the painted image around its centre. Sizing, measuring and the tagged Figure bbox are unchanged; plain images are byte-for-byte as before.
  • Canvas#rotate(degrees, around: [x, y]) { … } and Canvas#transform([a, b, c, d, e, f]) { … } wrap a block in a cm transform given in top-left space, clockwise like CSS rotate(). Link and form-widget rectangles are annotations in page space, so they stay unrotated.
  • html/markdown write link annotations only for http, https, mailto and tel hrefs (and #anchor); anything else (javascript:, data:, a relative path) keeps its text without a link and is reported as a Warnings::DroppedLink. links: %w[http https] changes the list, links: :all keeps every href from a trusted source.
  • html/markdown styles: take ul: and ol: (gap:, indent:, marker_gap:, marker_color:, plus style: for ul and format:/suffix: for ol), passed straight to the list elements, so dense documents can tighten the 4pt item gap.
  • Whitespace no font has (an ideographic space U+3000, a figure space U+2007, a narrow no-break space U+202F, thin and hair spaces, …) is drawn as a blank of the character's conventional width instead of .notdef, and no longer reports MissingGlyph; font fallback keeps every Unicode White_Space character with its neighbours.
  • Testing: the RSpec matchers include RSpec::Matchers::Composable, so have_pdf_text(…).and have_bookmark(…), .or and use inside all/include work. Inspector#lang reads the catalog /Lang; have_pdf_language("de") and assert_pdf_language assert it.
  • Previews: Stationery::Preview#around_render(name, params) wraps both the preview method and to_pdf, for an I18n locale or CurrentAttributes that must be in effect while the document renders (?locale=de); Preview#to_pdf(name, params, debug:) is the entry point the previews controller uses.

0.4.0 (2026-09-27)

Interactive forms and tagged (accessible) PDF.

  • Tagged PDF: form fields are Form structure elements owning their widget annotations (/OBJR + /StructParent), so screen readers reach them in reading order.
  • Tagged (accessible) PDF: tagged at class level or to_pdf(tagged: true) writes a structure tree (/StructTreeRoot with a /ParentTree, /MarkInfo, /StructParents on pages) and marks every painted leaf as marked content (/P <</MCID n>> BDC … EMC). Text is P, or H1–H6 with text(…, heading: 1..6); images and SVG drawings are Figure with /Alt from alt: (alt: false: decorative) and a /BBox; box(role: :section | :div | :blockquote | :note | :caption | :article | :part) groups its content. A node split across pages stays one element with marked content on each page. Headers, footers and page templates are /Pagination artifacts, other decoration is a layout artifact. metadata lang: writes /Lang (and is kept out of the document info); a tagged document with a title sets /DisplayDocTitle. New warnings MissingAlt and MissingLanguage let strict catch accessibility gaps. Untagged output is byte-for-byte unchanged. Canvas#tag, #structure and #artifact, Stationery::Tagging::{Tree, Element, Writer}.
  • Tagged PDF structure for lists (L with /ListNumbering > LI > Lbl + LBody; drawn bullets are artifacts), tables (Table > TR > TH with /Scope /Column or TD, /ColSpan//RowSpan attributes; one Table across pages, repeated header rows as artifacts), links (linked text is a Link inside its paragraph with the annotation's /OBJR; annotations get /StructParent; box(link:) groups its content in a Link) and table_of_contents (TOC > TOCI > Link + Reference). html/markdown tag headings H1–H6 and block quotes. Canvas#tag_runs, Canvas#link(…, tag:), Layout::Flow.new(…, tag:), Tagging::Element.new(…, attributes:).
  • Testing tagged PDFs: Inspector#structure reads the structure tree as nested arrays with each element's text taken from its marked content ([[:Document, [[:H1, "Intro"], [:P, ["Read", [:Link, "the docs"], "now"]]]]]), Inspector#tagged? and #untagged_text; matchers have_structure and have_tagged_content, assertions assert_pdf_structure and assert_tagged_content. examples/report.rb is tagged, with headings and a decorative icon. Empty table cells stay in the tree.
  • Interactive forms (AcroForm): text_field (multiline, max_length:, comb:, read_only:, required:) and checkbox (with label:) lay out like boxes or sit at at: [x, y], also inside table cells. Each widget ships its own appearance stream (Helvetica / ZapfDingbats from the standard 14, listed in the AcroForm /DR), so forms render in every viewer; NeedAppearances is set so edits redraw. Dotted names ("address.city") build parent fields; widgets sharing a name become one field's kids. Values are Unicode text strings and survive encryption. Document#fields returns { name => value } after a render; Canvas#widget places a field widget; to_pdf(debug: [:field]) outlines fields.
  • Forms: radio groups (radios sharing a name form one /Btn field with the radio flag; /V is the checked value), select combo boxes (/Ch with /Opt, editable: adds the Edit flag) and signature_field (an empty /Sig field drawn as a rule over its label). examples/form.rb is a one-page application form using every field type.

0.3.0 (2026-09-27)

The Limitations page, shortened: TrueType collections, WOFF, ligatures, splittable min_height: boxes, SVG gradients, text and stylesheets, and encryption.

  • SVG: linearGradient and radialGradient fills (fill="url(#id)", also in style), with stops, href/xlink:href inheritance, objectBoundingBox and userSpaceOnUse units and gradientTransform, drawn as PDF axial/radial shadings clipped to the shape. Approximations: reflect/repeat spreads are drawn as pad (reported in unsupported), stop opacity is the first stop's for the whole gradient, and a gradient stroke is drawn in the gradient's middle colour. A reference to a missing gradient paints its fallback colour or nothing and is reported as url(#id). Canvas#shade paints a shading dictionary inside a path.

  • SVG: text and tspan (x/y/dx/dy, first value each; font-family, first family the font book knows — registered, bundled or an installed pack — else the document's default; font-size, font-weight, font-style, text-anchor, fill, opacity, transforms), drawn through the document's fonts so they subset and extract like any text. Whitespace collapses as in browsers. Glyphs stay upright: a transform moves the baseline origin and scales the size uniformly, so rotated or skewed text is approximated; dominant-baseline is ignored and a gradient fill uses its middle colour. Layout::Svg takes context:, SVG::Document#draw takes book: and family:, and FontBook#known? tells whether a family resolves without substitution.

  • SVG: <style> stylesheets (plain or CDATA) as Illustrator and Inkscape export them. Selectors: element, .class (.a.b), #id, element.class, comma lists and *; rules with combinators (g path, a > b, …) are ignored and reported once as style selectors: …. Cascade: presentation attributes, then rules by specificity (id > class > element, later wins on ties), then inline style. Rules style shapes, text (font-*) and gradient stops (stop-color, stop-opacity); display: none skips an element and its children, visibility: hidden skips it (a visible child draws). class takes several classes.

  • Encryption with the standard security handler: to_pdf(encrypt: { owner_password:, user_password:, permissions:, algorithm: }) or encrypt … at class level (to_pdf(encrypt: nil) opts out). AES-256 (R6, default), AES-128 (R4) and RC4-128 (R3); every string and stream, document info included, is encrypted.

  • Standard ligatures from the font's GSUB liga feature (LigatureSubst lookups, also behind Extension lookups), on by default: "office" in Open Sans draws the ffi ligature, so widths of affected words change slightly. ligatures: false on text/text_style or in default_text opts out; any letter_spacing turns them off. Text extraction and copy still yield the source characters (ToUnicode maps a ligature glyph to all of them). Fonts without liga ligatures, such as the bundled Inter, are unaffected.

Fonts

  • TrueType collections (.ttc): font_family "Brand", regular: "Brand.ttc#0", bold: "Brand.ttc#2" picks a face by a #N suffix (face 0 without one); each face is parsed, cached, subset and embedded on its own. TrueType.new(data, index:), TrueType.collection? and TrueType.faces; an index out of range raises ArgumentError naming the face count.

  • WOFF 1.0 web fonts (.woff): font_family "Web", regular: "Brand.woff". Tables are inflated with zlib into an in-memory sfnt (Fonts::WOFF.unpack), then measured, subset and embedded like the .ttf. WOFF2 is still rejected: it needs Brotli, so convert to .ttf or .woff.

  • box(min_height:) and column(min_height:): a height floor that, unlike height:, still splits across pages. The first fragment keeps as much of the floor as the page holds and the next carries the rest, so a row of equal-height cards or an empty signature area can span a page break. Passing both height: and min_height: raises ArgumentError.

0.2.0 (2026-09-27)

Everything from the three planned milestones ("works out of the box", "typography and layout", "documents, Rails and testing") shipped together.

Breaking changes

  • Behaviour change: boxes taller than a page now continue on the next page instead of overflowing; pass break_inside: :avoid for the old behaviour. A box that fits on a page still moves there whole; break_inside: :auto splits it at any page break. box(decoration: :slice | :clone) chooses whether padding repeats at a cut; borders are left open and rounded corners cut square.
  • Pair kerning from the font's kern table, on by default: measured widths shift slightly (kerned lines only get narrower), so wrapping and right-aligned positions can move by a fraction of a point. kerning: false on text/text_style or in default_text restores unkerned output. Pairs across a style or font change are not kerned.

Fonts

  • Inter (OFL) is bundled: documents render without any font_family; font_family "Inter" needs no paths; Stationery.bundled_fonts. An unknown family name without paths raises.
  • Font packs: stationery fonts list and stationery fonts install noto_sans liberation_serif [--into DIR] [--force] [--from PATH] copy pinned, SHA-256-verified Noto Sans/Serif/Sans Mono, Liberation Sans/Serif/Mono and Inter files (plus their OFL license) into vendor/fonts/<pack>/, atomically and offline-capable. font_family "Noto Sans" with no paths finds an installed pack through Stationery.font_paths, as does an unregistered family name at render time. Ruby API Stationery::Fonts.install/catalog/paths, Rails generator stationery:fonts, and rake fonts:verify. No runtime downloads.
  • Per-character font fallback: font_fallbacks "Noto Sans Symbols", ... (inherited by subclasses). A character missing from the text's family is drawn from the first fallback that has it, then bundled Inter; spaces, joiners and combining marks stay with their base. Glyphs no font has are reported as MissingGlyph warnings counted per drawn occurrence. Font#glyph? is memoised per character.
  • GPOS pair kerning: the kern feature's PairPos lookups (formats 1 and 2, also behind Extension lookups) take precedence over the kern table, so GPOS-only fonts such as Inter are kerned too.
  • OpenType fonts with CFF outlines (.otf), name-keyed and CID-keyed: embedded whole as a CIDFontType0 (FontFile3 /OpenType), CID-keyed text written as CIDs with the font's ROS. Variable CFF2 fonts are rejected with a named reason.
  • CFF fonts are subset: undrawn glyphs become a bare endchar (glyph ids, charset, FDSelect, Private DICTs and Subrs kept), embedded as FontFile3 /CIDFontType0C with a subset tag.

Text

  • align: :justify on text, text_style and table cells: wrapped lines are stretched to the full width by widening their spaces (kerning is kept); the last line, lines ending in a newline and lines without spaces stay left-aligned. Link areas widen with the stretched text.
  • html and markdown elements: ActionText/Trix HTML and CommonMark (GFM tables and strikethrough) rendered as paragraphs, headings, lists, blockquotes, code blocks, rules, tables and images with inline bold/italic/underline/strike/code/links/sub/sup. styles: deep-merges per-block defaults, images: resolves image sources (or base_path:), missing/remote images are skipped with a SkippedImage warning and never fetched, bookmarks: true outlines h1–h3. Parsers load on first use.
  • Markup decodes all 252 HTML 4 named entities (&mdash;, &euro;, &hellip;, …); the table loads on first use.
  • Internal: Stationery::Rich block model (paragraphs, headings, lists, quotes, code, rules, tables, images; inlines with marks) with lenient HTML.parse (implied end tags, browser whitespace collapsing, Trix/ActionText output) and CommonMark-subset Markdown.parse (GFM tables and strikethrough, reference links). Loaded on demand; html/markdown elements follow.
  • Internal: Fonts::GlyphRun carries per-glyph advance adjustments (Tj/TJ emission) for upcoming kerning and justification; output unchanged.

Layout

  • ul / ol / li lists: drawn (disc, circle, square) or text bullets (dash, any String), decimal / alpha / roman / Proc numbering, start: and suffix:, aligned bodies, nested lists with depth-cycled bullets, and items that split across pages keeping the marker with their first line.
  • box(break_inside:) and column(break_inside:).
  • Rows split across pages like boxes, every column at the same break; a column that ends early continues as an empty fragment so backgrounds stay aligned. row(break_inside:).
  • keep_with_next now moves with a following node that avoids breaking inside and would not start on the page.
  • wrap element: children at their own widths, wrapping onto rows; splits between rows.
  • box(link:) makes the whole box clickable; box(outset:) bleeds its background past its edges.
  • keep_with_next: accepts a number of points of following content to keep.
  • group(align:).
  • Fixed-width children of a flow (box(width:)) are measured, split and paginated at their own width, not the flow's.
  • Layout: split takes fresh: on every node; a flow passes it on to the child that starts a fresh page.

Tables

  • Table cells span columns and rows: { content:, colspan:, rowspan: }, placed as in HTML; a table only splits between rows no rowspan crosses.
  • A table row taller than the page continues on the next page below the repeated header, its cells cut at the page bottom; table(split_rows: true) cuts any row that reaches the page bottom rather than moving it whole.
  • Table cells accept procs built with the DSL (-> { image logo }) and components.
  • Faster tables: cell padding is normalised once per cell, and PDF numbers are trimmed without a regexp (about 13% off a 1,500-row table render).

Pages and structure

  • header and footer regions that reserve page space, with height:, gap: and on: (:all, :first, :rest, :odd, :even, a page number, a range or a proc); later declarations win. A page_template's page.content_box now excludes their space. Page#margin_box.
  • header/footer on: :last: the paginator checks whether the rest of the content fits above a taller last-page region, adding a page when it only fits a normal one.
  • Named anchors and internal links: anchor: on text, box, group and table, a standalone anchor element, and link: "#name" / <a href="#name"> / box(link: "#name") written as PDF /Dest GoTo links. Unknown targets are dropped with an UnresolvedLink warning; duplicate names warn with DuplicateAnchor.
  • Bookmarks and document outline: bookmark: on text, box, group and table (a title, or { title:, level:, open: }) and a standalone bookmark element write a nested PDF /Outlines tree; the document opens with the outline panel. Bookmarks in page templates are ignored.
  • table_of_contents element: one linked row per bookmark with its title indented by level, a dotted/solid leader and a right-aligned page number filled in after pagination (levels:, leader:, indent:, gap:, number_width:, text style). Paginates across pages.

Drawing

  • SVG: path (every command, including arcs), rect, circle, ellipse, line, polyline, polygon and g, with fill, stroke, caps, joins, fill-rule, opacity, inline styles, transforms and currentColor. svg element.
  • SVG: elements that cannot be drawn (text, use, …) are listed in SVG::Document#unsupported and reported as an UnsupportedSvg warning.
  • Canvas: rounded_rect and clip take per-corner radii ([top_left, top_right, bottom_right, bottom_left], scaled down to fit like CSS); rounded_rect(dash:).
  • to_pdf(debug: true) outlines every layout rectangle (boxes, padding, columns, flow slots, cells, positioned boxes, images, the page content box), colour-coded by kind; debug: %i[cell …] outlines only those kinds.

Warnings

  • An unregistered family name draws with the first registered family (else bundled Inter) and reports an UnknownFamily warning once.
  • Stationery::Warnings: one collector per render, deduplicated, with #message on every entry (Overflow, MissingGlyph, UnknownFamily, UnsupportedSvg, SkippedImage, UnresolvedLink, DuplicateAnchor). document.warnings now includes warnings raised while page templates draw.
  • Strict mode: to_pdf(strict: true) or class-level strict raises Stationery::WarningsError when a render produced warnings.

Rails

  • Rails: a Railtie adds render pdf: document (filename:, disposition:) and config.stationery (renderer, font_paths); Stationery.font_paths. A spec:rails lane tests it against Rails 8 without adding a dependency.
  • Rails: PDF previews like ActionMailer previews. Stationery::Preview subclasses in spec/pdfs/previews or test/pdfs/previews render at /rails/stationery/previews (?debug=1 when the document supports it); config.stationery.preview_paths and show_previews (development by default). rake spec:rails no longer overwrites the main suite's coverage report.

Tooling

  • Documentation site at https://stationery.zoolutions.llc, a docs-kit app under docs/ (not part of the gem): getting started, every element with its options, layout rules, pages and regions, links and bookmarks, fonts, images and SVG, Rails, testing, the CLI, warnings, a cookbook built from examples/, performance, limitations and this changelog, rendered from the README and CHANGELOG where they overlap.
  • stationery executable with a command registry; stationery render FILE [--out PATH|-] [--class NAME] [--strict] [--debug] renders the Document a Ruby file defines (through self.preview when it needs arguments) and reports pages and bytes.
  • require "stationery/rspec" matchers (have_pdf_text, have_pdf_text_on_page, have_page_count, have_pdf_link, have_image_count, have_bookmark, have_no_warnings) and require "stationery/minitest" assertions, built on Stationery::Testing::Inspector. Needs pdf-reader in the test group.
  • Examples: examples/report.rb (multi-page annual report: header/footer regions, contents, bookmarks, lists, tables, a split callout, internal links, SVG icons), examples/letter.rb (one-page letter with a vector letterhead) and examples/packing_slip.rb (landscape, 120-row table with split rows, canvas barcode), each covered by an integration spec and rendered in CI by rake examples.
  • rake bench: reproducible benchmarks against Prawn + prawn-table (one-page invoice, 1,500-row table) and a StackProf profile script under benchmark/.

Performance

  • Layout caches: container nodes (box, row, flow, wrap, list item) memoise their height per width; table cells remember their height per width and their natural and minimum widths across the fragments of a split table; tables memoise row heights and column metrics; table row boundaries are found in one pass. Fonts memoise style resolution and advance/kerning totals per string, and runs already split for fallback are not split again. Output is byte-identical. A 1,500-row table renders ~26x faster (17.9 s → 0.68 s, 186M → 4.5M allocations), the invoice example 1.3x faster (77k → 33k allocations).
  • benchmark/profile.rb profiles in wall mode by default (MODE=cpu|object).

Fixes

  • Fixed: table cells restyled through a selection after the table had been measured kept their old style.
  • Fixed: fonts without an OS/2 v2 table (including every subset) crashed on a nil cap height.
  • PDF::Writer raises when a reserved object is never set instead of writing null.
  • Gemspec ships every file under lib/ and exe/ when built without git, not only .rb.

0.1.0 (2026-09-26)

  • PDF writer with Flate streams, per-page resources and link annotations.
  • TrueType fonts: cmap 4/12, glyph subsetting, Type0/CIDFontType2 embedding with ToUnicode, synthetic bold and oblique.
  • JPEG and PNG images, including alpha soft masks and palette transparency.
  • Canvas: rectangles, rounded rectangles, circles, lines, Bézier paths, clipping, opacity, text runs, images, links.
  • Text: inline markup, Ruby run builder, wrapping, alignment, leading, truncate and shrink-to-fit.
  • Layout: flow, box, row/column, table, image, rule, spacer, page break, group, canvas escape hatch; pagination with header rows, keep-together and keep-with-next.
  • Components with Phlex's lifecycle; documents with page settings, font families, default text, metadata and page templates.
  • Stationery::Rails#send_pdf.