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](https://stationery.zoolutions.llc/docs/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](https://stationery.zoolutions.llc/docs/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::Glyph`s (`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](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`.