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 sizefont_size: :autodraws (one line, acomb:field's cells or amultiline:value, withinmin_font_size:andmax_font_size:), written in/DAas that number instead of0, so a viewer that redraws the field keeps the size; Firefox's editable input, which showed:autoat 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#layoutandstationery inspectreport 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:andfont_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, andfont_size: :autoas0 Tf, withinmin_font_size:(4 by default) andmax_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, acomb:field's widest character in its cell, amultiline:value wrapped to the width and fitted to the height.comb:ignoresalign:.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. Undermonochromeacolor:is checked like any other colour (Warnings::NotMonochrome, or black withsnap: true);Inspector#layoutandstationery inspectreport a text field's or choice'salign:,font_size:andcolor:. 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/DAis reported asWarnings::ConformanceIssue(CMYK colour in form field "price"), itsborder:andbackground:included, which were not checked before. - The skill (
stationery skill install) and the README it is written from are corrected (#209): themax_pagesexample renders before it readswarnings; a preview readsparams.fetch("id") { … }, so its default is not looked up whenidis given; the streamed-table example preloads what its rows read; the invoice recipe computes VAT withRational, 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 atooltip: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:encryptunder a conformance level raisesArgumentError, notConformanceError;NeedAppearancesis 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. Runstationery skill installagain 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), andobject_streams falseat class level orto_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/Encryptdictionary 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_pngandto_zplrefuseobject_streams:as a PDF option.Document#page_countis how many pages the last render laid out, whichstationery rendernow reports. InPDF::Writer(object_streams:,add_unpacked) andPDF::ObjectStreams. stationery verifychecks 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 (andpdfsig'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 messagesStationery::Verify::Allowlistnames with why each is the check's environment and not the file.stationery verify invoice.pdf report.rb --engines qpdf,pdfjs --password P --jsonrenders a Ruby file first, asinspectdoes, 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 imageghcr.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 verifyneedspdf-reader, asinspectdoes, andrequire "stationery"does not load it, so a render allocates and writes what it did.bundle exec rake verify:readersrenders every example, the conformance renders, an encrypted invoice, a packed invoice and an unpacked tagged report totmp/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 withStationery.finalize!, which the Railtie runs once the app has initialized (a Rails-less app calls it once its setup is done): it freezesStationery.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, ashaperor asignkey 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_counthas the pages), and the file ends in a cross-reference stream, notxrefandtrailer. A reader of PDF 1.4 and earlier cannot open it;object_streams: falsewrites 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_pathsis frozen once the app has initialized (the Railtie runsStationery.finalize!), so a path appended later, in anafter_initializeof the app's own, raisesFrozenError. So does a call that changes the settings of a document class the app had loaded by then: withconfig.eager_load(production, and the CI test runs of a Rails 7.1+ app) that is every class underapp/, without it (development) only those its boot touched, so a class reconfigured after boot fails in production and not in development.Stationery::Fonts::Familyobjects are frozen. The gem's constants are deep-frozen (Stationery::Page::SIZES,Stationery::Text::Markup::STYLES, …), andStationery::Hyphenation::LOCK,Stationery::Preview::REGISTRY_LOCKandStationery::Layout::Table::Cell::BOXESare gone. UntilStationery.finalize!keeps it, the default instrumenter is looked up on every render, soActiveSupport::Notificationsloaded 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 reopenedApplicationPdf) 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
Linkelement, which is what gives it its/StructParent(ISO 14289-1, 7.18.5; veraPDF rule 7.18.5-1). Alink:aheader, afooteror apage_templatepaints (text(link:), markup,html,markdown,box(link:), to a URL or an#anchor) is now tagged as the links of the body are: aLinkelement of theDocumentholding the text of the link as real content and its annotation as/OBJR, with/StructParentand, 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 oneLinkper 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 itsLink.Inspector#structureandhave_structureshow them ([:Link, "example.com"]), andrake verify:conformancevalidates the report example with a linked footer. What cannot be tagged, a link in the header row a table repeats on its next pages andcanvas.linkwithout atag:inside acanvas { }block (in a region too), is an artifact outside the structure tree: underconformance :pdf_ua1the render raisesStationery::ConformanceErrorwith 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 aWarnings::UntaggedLink(target,place,page;placeis:header,:footer,:page_template,:artifactor:canvas), sostrictcatches it. Links in the body (text(link:), markup,box(link:),html,markdown,table_of_contents,canvas.link(…, tag:)with aLinkelement 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: :replacekeeps a PDF/A or PDF/UA claim when a character no font has is drawn (#150):conformance :pdf_a3b, missing_glyphs: :replaceat class level, orto_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 theSpanwhoseActualTextis the character, as.notdefis 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 passes2b,3bandua1with 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.notdeffail rules 6.2.11.8-1 and 7.21.8-1. TheWarnings::MissingGlyphstays, with a newstand_in(the character drawn, nil for.notdef) and a message that names it, sostrictstill raises. A font that has none of the three, which a symbol font may not, still raisesConformanceError, saying so. Whitespace a font lacks stays a blank.:raiseis the default and is what 0.11 does; any other value raisesArgumentError. Withoutconformancethe 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::Unitsconverts to points where a size is written:mm(102),cm(2),inch(4)andpt(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 withinclude Stationery::Units, and asStationery::Units.mm(102). They are private methods andNumericis not patched; a method of that name in a document wins. The class-levelpagereads 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, andmm,cm,inorptin 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,:c5and:c6(short edge first, as ISO 269 has them:layout: :landscapeis the address side) and the label stock:label_4x6,:label_4x3,:label_4x2,:label_100x150and:label_100x50(width by height).Page.newandLayout::Paginatortake 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..3at class level, inherited and added to by subclasses, andto_pdf(print: { copies: 1 })laid over it for one render; aniltakes a hint away (print: { duplex: nil }), andto_pdf(print: nil)orfalseall 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/DisplayDocTitleof 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_openwrites/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 raisesArgumentErrornaming what it takes, and ranges that overlap are refused./ViewerPreferencespasses 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), sodialog: :on_openwithconformance :pdf_a2bor:pdf_a3braisesStationery::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)andassert_print_preference.printin a class body is this declaration; the methods of a document still callKernel#print. Withoutprint, a document is byte for byte what it was.PDF::PrintHints. - A document that must be one page can say so (#199):
max_pages 1in the class body, for a label, a receipt or a card. A render that needs more pages lays them all out as before and reportsWarnings::TooManyPagesnaming 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), sostrictraises.to_pdfandto_pngkeep every page, to be looked at;to_zplwrites no label and raisesWarningsErrorwhether or not the render is strict, since each page past the limit is one more label printed, andstationery render --zplexits 1 with the warning.to_pdf(max_pages:),to_png(max_pages:)andto_zpl(max_pages:)replace the limit for one render,niltakes it away;max_pages nilin a subclass does too, andpagein 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: 203at class level, orto_pdf(monochrome: { dpi: 203 })for one render (monochrome: falsetakes it away), reports every colour that is not black or white and every opacity below 1 as aWarnings::NotMonochromenaming 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 aWarnings::ThinLine, sostrictcatches them.snap: truechanges them instead: text, rules and strokes black (white ones stay white), fills and gradients black when darker thanthreshold:(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,:orderedor:threshold). It sees what elements, SVGs andcanvas { }blocks paint alike, keeps PDF/A and PDF/UA, and leaves form fields alone.Inspector#colorslists the colours a PDF paints with, andhave_pdf_colors/assert_pdf_colorshold them;PNG#pixelsandWebP#pixelsare public. Withoutmonochromeevery 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 isStationery::Canvas::Interface, in top-left coordinates and points and with nothing of PDF in what is handed over or answered;Stationery::Canvasis the PDF canvas, with the methods it had. A path is aStationery::Path, its segments kept as numbers and read back witheach_segment(Canvas::Pathwrites them as operators), a gradient is handed over as anSVG::Gradient::Fill, andDocument#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 aStationery::Fonts::Outline, in font units with y up, for TrueType (glyf, composite glyphs with their offsets, scales, 2×2 matrices, point-matched anchors andSCALED_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 deprecatedseacform ofendcharand the arithmetic operators raiseUnsupportedFont), 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 aStationery::Pathin 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. AFontkeeps up toFont::OUTLINE_MEMOnumbers 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, andto_png("out.png")writes them too (out.pngfor one page,out-1.png,out-2.png, … for several);pages:picks pages by number, Range or Array. The pagesto_pdflays 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 frompdftoppmin 0.0 to 0.5% of their pixels at 72 dpi.monochrome:(asto_pdftakes 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 matchpdftoppm -monoin 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 raiseArgumentErrorwhen given toto_pngand 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_pngdraws a JPEG, and amonochromerender andto_zpldither it like a PNG.Images::JPEG#pixelsanswers anImages::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#unsupportedsays which), draws as a crossed box with aWarnings::SkippedImagenaming why, and is reported asWarnings::NotMonochromein 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 withoutto_pngormonochromeis 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_zplanswers ZPL II as a String, one^XA…^XZlabel per page, andto_zpl("label.zpl")orto_zpl(socket)writes it too. Each page is drawn one bit to a dot asto_png(monochrome: …)draws it (the class'smonochromesettings, or the defaults:to_zplis always monochrome and refusesmonochrome: false) and written as^PW/^LLfrom the page size in dots, one^GFAgraphic field and^PQcopies.dpi:is 152, 203, 300 or 600 (others raise), by default the class's monochrome dpi, else 203;copies:by default the class'sprint copies:;compression: :z64(zlib, Base64 and the CRC-16/XMODEM Zebra's software writes, the default) or:hex;pages:as forto_png.stationery render label.rb --zpl --dpi 300writeslabel.zpl. Labels read back equalto_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#paintand#bitsare what it reuses. - Barcodes (#147):
barcode "SX0042771903"draws a Code 128 (type: :code128, printable ASCII, digit runs in code set C),type: :ean13an EAN-13 (the check digit added or checked),type: :qra QR code (byte mode,level: :l/:m/:q/:h, versions 1 to 40, UTF-8 marked with an ECI) andtype: :datamatrixan 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:orwidth:size it,height:a linear one, the quiet zone is part of it (quiet_zone: falseleaves 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: trueon the barcode orto_zpl(native: true)writes the printer's own^BC,^BE,^BQor^BXwhere 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,ReedSolomonwith its two fieldsReedSolomon::QRandReedSolomon::DATA_MATRIX),Layout::Barcode,ZPL::Native, andCanvas::Interface#barcode, which a canvas may draw its own way (the raster canvas records it asRaster::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 arow,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.rbrenders pixel for pixel).Layout::Flow::Splitter#float,#cut?,#lands?,#split_or_move,Layout::Table::RowSplitter. - A box with a
background:, aborder:, ashadow:or alink: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 withopacity:or ashadow: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'smargin: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 aFigureand a box has itsrole:; a floated box without one beside such a box is aDiv, 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:), arow, atableandcolumnsstay blocks: a table resolves its column widths from its width, a row shares its width andcolumnsdivides 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::Wrapperfound 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::Wrapperchose 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;articleandnewsletterallocate 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.numberrounds a Float below 100,000 to four decimals itself and writes its digits, withoutKernel#format; a Float within a millionth of a tie at the fifth decimal, one of 100,000 and more, a Rational,NaNand the infinities go throughformat("%.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 aStringScannerhanding each token over as one String whereString#scanmade three, and its fragments gathered in a loop wherechunk_whilebuilt 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_missingwalks the codepoints of a text and asks the font once per codepoint (Font#lacks?, remembered), whereParagraph#count_missing,Forms::TypefaceandFallback#splitmade a String of every character of every fragment drawn and every run wrapped; a text the font covers allocates nothing. AGlyphRunwrites itsTJarray in one pass over its hex (GlyphRun#show); a run with.notdefglyphs 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
taggedor a PDF/UA level (a paragraph'sP, a table'sTable,TR,THandTD, a list'sL,LI,LblandLBody, aFigure, aForm, aLink, a box's role, a contents entry'sTOCI) carried aTagging::Elementthat 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 aContextwhosetaggedis true (Layout::Context.new(book:, style:, tagged: true), which now has three fields), or an explicittag:;Layout::Box,Layout::ImageandLayout::Fieldtaketagged: falseortag: nilfor 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 (aString#scanand an Array of pairs, eleven objects); up toColor::MEMO(256) colours are remembered, and past that the memo starts over, as the fonts' memos do. A Color, which is frozen, writes itsfillandstrokeoperators 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).
- Numbers are written with one String where they took two:
- 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'sTR,THandTDare built when a page paints or cuts it (Table#tag_row), where every cell was tagged when the table was built (Table::Cell#tagis 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 passingua1); 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 withincremental: truepeaks 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)) raisesArgumentError, and so do a cell spanning rows and a row with more columns than widths when they are read. A table in arow, in a box withbreak_inside: :avoidor 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), andzebramakes 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 raisedNoMethodError, 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 --pngwrites a picture of each page beside the PDF withto_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-onlyfor the pictures without the PDF.stationery inspect invoice.pdf(orinvoice.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;--jsonprints the same as JSON.Stationery::Testing::Inspector#layoutanswers the same data to a spec, in a stable order and without the dates, so two renders of one document compare equal. It needspdf-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 exampleslists them with the first sentence of their header comment,stationery examples invoiceprints the path of one and--sourceits code, andls "$(bundle show stationery)/examples"orgem contents stationeryfind them without the command. Inter stays the one font the gem ships: the invoice examples are set in the Open Sans of the test suite in the repository, and in Inter where only the gem is. The gem grows by about 0.1 MB. On the docs site, the Examples page has the whole source of each example folded under its preview, read from the file when the page is rendered, and the Markdown twin of the page (/docs/examples.md,/llms-full.txtandget_pageover MCP) carries the sources in full. The header comment ofexamples/invoice.rbsays 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 anEnumerator, a repeating header,incremental),contract.rb(numbered clauses withkeep_with_next, a text field for both parties' initials in the footer of every page, signature fields thatto_pdf(sign: { field: })signs),certificate.rb(landscape, a frame drawn on acanvasin a background page template, centred type, signature fields),resume.rb(columns of 30 % and the rest, links, the body ashtml),menu.rb(columns, two floats, headings in Inter's bold),receipt.rb(a till receipt on an 80 mm roll declaredmonochrome dpi: 203, snap: true: a logo cut to one bit, VAT by rate, a QR code frombarcode type: :qrand a cut line drawn on acanvas),accessible_report.rb(PDF/UA-1 with headings, figures withalt:and a table with header cells;rake verify:conformancevalidates it as PDF/UA-1 and PDF/A-3b) andmultilingual_notice.rb(English, German and Swedish, each hyphenated by its own patterns, and a Japanese paragraph from a fallback font whenCJK_FONTnames one).examples/shaping/rtl_letter.rbis a letter in Arabic through the shaper hook; it needs harfbuzz-ruby and an Arabic font (ARABIC_FONT) and, like the rest ofexamples/shaping/, is left out ofrake 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 installwrites 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;--projectwrites under the current directory,--dirinto any skills directory.stationery skill statussays 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), andstationery skill printwrites it as one Markdown document for an agent that takes a file. ASKILL.mdthat the gem did not write is kept unless--force. The skill is a shortSKILL.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,strictand the matchers, conformance, labels and printing, where to read more: the docs by topic, the MCP endpoint, the examples) withreference/*.mdbeside it, cut from the sections of the README, andrecipes/*.mdfor 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(whatstationery examplesand 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 indocs/):search_docsfinds sections,get_pagereads a page as its Markdown twin andlist_pagesnames them all, stateless JSON-RPC overPOST, no key.claude mcp add --transport http stationery https://stationery.zoolutions.llc/mcpadds it to Claude Code, and any other client takes the same URL; the README and Getting started say so, and/llms.txtends with an## MCPsection 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
Spanwith its text in logical order asActualText, which is what ships; aSpanper cluster in visual order; noSpanwhere the ToUnicode map is enough) and read with theInspector, PDFium, PDFKit, pdf.js, poppler and MuPDF. None is read as written by all: PDFium and theInspectortakeActualTextas 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", andexamples/shaping/extraction_matrix.rbmakes 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 metricsrenders seven more documents (articlefor floats,newsletterforcolumns,webpfor a lossless WebP decoded in the render that is measured,htmlwith a stylesheet,pdf_uaunderconformance :pdf_ua1,text_incrementalwith a footer andincremental,text_shapedthrough 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.mdat 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 withbin/release, and what is out of scope by design. The gem is unchanged.rake verify:conformanceandverify:timestamprun averapdfon the PATH when there is one (brew install verapdf), and Docker'sverapdf/cliotherwise, as before;VERAPDF_IMAGEstill chooses the image and forces Docker. The gem is unchanged.- Behaviour changes:
pagecheckssize:andmargin:when the class is defined and raisesArgumentErrornaming what it takes: an unknown name raised when the document was rendered, andpage size: ["102mm", "74mm"]ormargin: "3mm"raisedNoMethodErrororArgumentError: not a boxfrom inside the layout. A size in points is two positive, finite numbers, sopage 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 methodsmm,cm,inchandpt, andprintin a class body declares print hints where it calledKernel#print(methods of a document still callKernel#print). A render withconformance :pdf_ua1and a link in the header row a table repeats or acanvas.linkwithout atag:now raisesConformanceError(it wrote a file veraPDF rejected), and a tagged render with one has aWarnings::UntaggedLinkmore, so withstrictit raisesWarningsError; a link in a header, a footer or a page template is tagged, so the structure tree of a tagged document with one has aLinkelement more per link and page, and such a document under PDF/UA-1 now passes.Warnings::MissingGlyphhas a fourth member,stand_in(nil unlessmissing_glyphs: :replacedrew one), which itsto_hand pattern matching show.stationery render --zplexits 1 with the warnings whento_zplraisesWarningsError(amax_pageslimit, or astrictdocument), 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 theOverflowis 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 arole:beside such a box is read inside aDiv. 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 raisesArgumentError, and so do a cell spanning rows and a row with more columns than widths; anEnumerator::LazyraisedNoMethodErrorand is read now. A layout node built by hand with aLayout::Contextthat is not tagged carries no structure element (Layout::Context.newhas a third field,tagged), andTable::Cell#tagis nil until a page paints or cuts the cell's row. The gem shipsexamples/(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: 1and a heading is at most one level below the heading before it, soheading: 1followed byheading: 3, or a document that starts atheading: 2, raisesStationery::ConformanceErrorwith 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, then1), 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, fromtext(heading:),htmlandmarkdownalike; 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 aWarnings::SkippedHeading(level,allowed,page), sostrictcatches it. - A blank
alt:is a missing one:image(source, alt: ""),svg(source, alt: " ")and amarkdownimage without a description () are reported asWarnings::MissingAltin a tagged render and raiseConformanceErrorunder PDF/UA-1 (ISO 14289-1, 7.3; veraPDF rule 7.3-1 fails an empty/Alt), where only a missingalt:was caught. Analt:of whitespace alone is refused as well, although veraPDF accepts it: it describes as little as the empty one.alt: falsestill marks decoration, which is an artifact and needs no description, and the message ofMissingAltnow names it:image on page 1 has no alt: text (alt: false marks decoration). - In
html,<img alt="">is decoration, as HTML means it: analtattribute that is there and empty (or whitespace alone) draws the image asalt: falsedoes, an artifact outside the structure tree, floated or not, so it neither warns nor raises. An<img>without analtattribute is still a missing description. Markdown has no way to say that an image is decorative, socounts as a description forgotten: usehtmlor theimageelement for decoration. - A
boxthat 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 anOverflow. 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
columnsfill 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, soorphans:/widows:,keep_with_next,break_inside: :avoidand 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 withoutcolumnsare byte for byte what they were. A block is levelled once, when it is placed, with at most(count - 2) * 41pours 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
boxthat 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 withoutbackground:,border:,shadow:orlink:and withoutwidth:,height:,rotate:,overflow:orvalign:; 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:andwidows: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. Inhtml, the items of a list beside a floatedimgwrap, and so does a block indented bymargin-leftormargin-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 atable, arowandcolumns, 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. htmlreadsmargin-leftandmargin-right, which were reported asWarnings::UnsupportedCss: lengths inpxorpt, asmargin-toptakes them. On a floatedimgthey are that side of the space kept around it, sostyle="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 itskeep_with_next.marginand its sides are read in the order of the cascade, specificity first and position then: a side wins over amargindeclared before it, and amargintakes back the sides declared before it.auto,emand%are reported as they were, and so is a negativemargin-leftormargin-right. HTML without margins renders byte for byte as before.CSS::Stylesheet#matching.- Testing:
Inspector#textand#page_texts, and sohave_pdf_text,have_pdf_text_on_pageandassert_pdf_text, read a sequence with/ActualTextas 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 theActualTextto the first glyph shown and drops a glyph without a width. The helpers now read a page through the receiverInspector#structurealready 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 withoutActualTextis what it was.ActualTextand 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_textandstructurefollow a form XObject the page draws, astextdoes. The PDFs written are unchanged. - Testing:
fields: truereads 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, sohave_pdf_text("Astrid")does not find the value oftext_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 ofmultiline:, every cell ofcomb:), a select's value, a signature field's label, characters no font has from theirActualText, 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, neverOff. 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), whichnot_to have_pdf_textand a comparison ofInspector#textwould notice. Without the option, the failure ofhave_pdf_textover a text that a field shows says so. - Behaviour changes: in
html, the left and right of themarginshorthand indent a block, as CSS means them:margin: 0 20pxdrew no indent. Top and bottom draw what they drew. Amargindeclared aftermargin-topormargin-bottom, or by a more specific rule, takes them back, where the side always won. A negative margin on a floatedimgis 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 withconformance :pdf_ua1whose heading levels skip, or with a figure whosealt:is blank, now raisesConformanceError(it wrote a file veraPDF rejected); a tagged render reports both as warnings, so withstrictit raisesWarningsError. PDF/A alone asks for neither (level B does not ask for structure) and raises for neither. In a tagged render anhtmlimage withalt=""is an artifact, where it was aFigurewith an empty/Alt. The message ofMissingAlt, and with it the issue of theConformanceError, ends in(alt: false marks decoration). Aboxthat ran over the page by the padding or border that closes it breaks a line or more earlier, and no longer reports anOverflow. 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:)andbox(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 aspadding:does; a floated box needs awidth:(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, aFigureor the box'srole:.htmlreadsfloat: left|rightonimgand<img align="left|right">, with the image's CSSmarginorstyles: { img: { float_margin: 8 } };floaton any other element is still reported asWarnings::UnsupportedCss.examples/article.rbshows 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 (arowis 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 insidebreak_inside: :avoid, andkeep_with_nextkeeps a heading in the column of what follows it.balance: trueends the columns at nearly the same height where the content ends (the last page of the block, and before apage_breakinside it) by searching for the shortest column height that holds everything;balance: falsefills 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 anothercolumns; a tagged PDF reads column 1, then column 2, with a paragraph split across columns as oneP.htmlmapscolumn-count,columns: <n>andcolumn-gapondiv-like containers,p, headings, lists, tables, block quotes andpreto 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 extendedVP8Xcontainer, 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 everythingimageoffers,downscale: trueincluded. A 1000 × 1000 px image decodes in 0.2 to 0.4 s, once per process. Lossy and animated WebP still raiseUnsupportedImage, now naming which it is, as do truncated and damaged files and images of more than 33 megapixels (2^25 pixels).image.stationeryreportsformat: "WebP"; the message for an unknown format reads "only JPEG, PNG and lossless WebP images are supported". - Shaper hook:
shaper MyShaperat class level (inherited;shaper niltakes it away) orto_pdf(shaper:)hands every text to an object answeringcall(text, font, size:, features:, language:), so an application with HarfBuzz can set Arabic, Hebrew, Indic or Thai text. It answersStationery::Shaper::Glyphs (gid,advance,x_offset,y_offsetin font units,cluster) in visual order, ornilto decline a text;fontis aStationery::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 asTJadjustments 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 aSpanwith the characters asActualText. Glyph 0 is aWarnings::MissingGlyph; an answer that cannot be drawn raisesStationery::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-leftActualTextreversed.examples/shaping/harfbuzz_shaper.rbis an adapter for theharfbuzz-rubygem. 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_pdfwrites the bytes it wrote. A page's nodes are let go once it is painted (the root flow, the builder andFontBook'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 memorymeasures it. to_pdf(incremental: true), orincrementalat 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 withconformance:orsign:, and astrictone 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
.notdefunder 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), soconformancenow raisesStationery::ConformanceErrorwith 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 withoutconformancea missing glyph stays aMissingGlyphwarning. - Form field appearances keep characters no font has, as body text does since 0.10.1: a text field's value (single line,
multiline:andcomb:cells), a select's value and a signature field's label wrap each run of.notdefglyphs in a/Spanwith/ActualText, inside the text object and inside/Tx BMC … EMC, sotext_field "name", value: "日本語"extracts as日本語. The appearances were already written this way; it is now specified and documented. TheMissingGlyphwarning and the bytes of values the fonts cover are unchanged. A field made without a font book (Forms::Field.newon a bare canvas) still writes a character outside Windows-1252 as?withoutActualText: that is a real glyph, and/Vholds the value. - Behaviour changes: a render with
conformancethat draws a character no font has now raisesConformanceError(it wrote a file veraPDF rejected), under PDF/A-2b and PDF/A-3b as well as PDF/UA-1. Inhtml,<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.imageandboxraiseArgumentErrorformargin:withoutfloat:and forfloat:withat:. The message ofUnsupportedImagefor an unknown format names lossless WebP, and a lossy or animated WebP is named as such.rake verify:conformancealso validatesexamples/article.rbandexamples/newsletter.rb.
0.10.1 (2026-09-28)
- Extracted text keeps characters no font has: each run of
.notdefglyphs is wrapped in a/Spanwith/ActualTextholding 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日日日. TheMissingGlyphwarning 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.
htmlreads CSS:<style>rules (element,.class,#id, compound selectors, comma lists,*; also inside@media print/all) and inlinestyle, cascaded afterstyles:by specificity. Properties:color,font-size(px,pt,em,rem,%, keywords),font-weight,font-style,text-decoration,text-align,background-colorandpaddingon blocks and cells,margin-top/margin-bottom,borderontable/td/th,widthonimg,tableand cells,page-break-before/-after/-insideand thebreak-*forms.<font color size>and<center>are read too. Anything else, and selectors with combinators or pseudo-classes, is ignored and reported once asWarnings::UnsupportedCss;url()and@importare never read. HTML without styles renders as before.- Orphans and widows:
orphans:andwidows:ontext,text_style,default_textand richstyles:(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 withurl:,username:,password:,hash:,client:) requests an RFC 3161 token over the signature and embeds it asid-aa-signatureTimeStampToken, giving a PAdES-B-T signature. A TSA that cannot be reached, refuses, or answers for another imprint or nonce raisesStationery::SignatureError; nothing is written untimestamped.Inspector#signaturesentries gaintimestamp: { 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 ofto_pdfreturns the String and is byte for byte what it was. A block withsign:or with a target raisesArgumentError. - Behaviour changes: a
page_breakinside agroupnow 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, sowidth: 1.0is 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::CSSholds the selector and stylesheet parsers SVG and HTML share (SVG::SelectorandSVG::Stylesheetremain as constants).Text::RunsBuilder#scaleand#with.- Benchmarks:
rake benchcompares three engines when the:benchmarkgroup 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) orto_pdf(sign:). Fills a namedsignature_fieldor adds an invisible one, writes/ETSI.CAdES.detachedCMS 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 stdlibopenssl(required only when signing). Works with encryption and under PDF/A-3b and PDF/UA-1;/SigFlags 3on the form. Verified withopenssl cms -verify,pdfsigand 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/TUfromtooltip:,label:or its name;NeedAppearancesis dropped under a conformance level. Forms are now allowed under PDF/A-3b and PDF/UA-1 (veraPDF passesexamples/form.rbas both). Documents without fields are byte-identical. - Performance: the wrapper measures each segment once and
Font#width_ofmemoises 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:
htmlandmarkdowntakemax_depth:(64); deeper elements, block quotes and lists are flattened into the deepest kept with aWarnings::NestingLimit, never aSystemStackError. Markdown inline parsing is bounded (brackets, labels, emphasis), so pathological input finishes in milliseconds instead of minutes. SVG keeps 128 levels and capsusechains at 32. imagewith an IO source no longer raisesNameErrorforPathnamein 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, nestedsvgandclipPathshipped in 0.8.0; the docs Forms page and the docs image now include the example fonts, so every/examples/<name>.pdfrenders 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; alsoto_pdf(factur_x:)) embeds the invoice XML asfactur-x.xml(xrechnung.xmlfor:xrechnung) with the relationship the profile requires, writes thefx: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.rbshows a minimal EN 16931 document.Inspector#factur_x,have_factur_x,assert_factur_x;rake verify:factur_xvalidates with Mustang. - PDF/A and PDF/UA:
conformance :pdf_a3b(or:pdf_a2b) andconformance :pdf_ua1, combinable, at class level orto_pdf(conformance:). PDF/A writes an sRGB output intent (the ICC'ssRGB2014.icc, bundled with its licence),pdfaididentification and printable link annotations, and refuses encryption and, under A-2b, attachments. PDF/UA turnstaggedon, requiresmetadata title:andlang:, writespdfuaid:part 1, tab order and link descriptions, and raisesConformanceErrorfor a figure withoutalt:. CMYK colour under PDF/A is reported asWarnings::ConformanceIssue. Form fields are refused under every level until their appearances embed their fonts.Inspector#conformance,have_conformance,assert_pdf_conformance;rake verify:conformanceruns 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/Metadatastream;metadata xmp: falseorto_pdf(xmp: false)opts out.Inspector#xmpand#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/Filespecin the catalog's/EmbeddedFilesname 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(hreforxlink:href, onto shapes, groups, text, symbols and otheruseelements, with cycle detection),symboland nestedsvgviewports (viewBox,preserveAspectRatiowithmeet/slice/none, clipped unlessoverflow="visible"), andclipPath(clip-path="url(#id)"on shapes, groups, text anduse;clip-rule,clipPathUnits="objectBoundingBox", nested clip paths). Thecolorproperty feedscurrentColor, so one sprite symbol can be drawn in several colours.image,mask,pattern,filterandtextPathstay unsupported and reported. - CLI:
stationery render file.rbrenders 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"ontext,text_style,default_textand the richstyles:(p,li). Liang's algorithm over the TeXhyph-utf8patterns 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,­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#featureslists 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: 220per document,image(max_ppi:)per call,nildisables) reportsWarnings::OversizedImagenaming the source, pixel width, effective ppi and the limit, sostrictcatches a 1600 px photo drawn 160 pt wide.downscale: trueresamples 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 " }(orto_pdf(page_labels:)) writes the/PageLabelsnumber tree;Inspector#page_labels,have_page_labelsandassert_page_labelsread it back. text(…, markup: true)decodes HTML character references (&,',', the named entities the HTML parser knows), so data escaped withhtml_escapebefore adding your own<b>tags renders as intended instead of showing'.
0.6.0 (2026-09-28)
Instrumentation, so a render shows up in AppSignal and friends, and a faster text pipeline.
- Instrumentation:
Stationery.instrumentemitsrender.stationery(the wholeto_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: :parseon a cache miss,action: :subsetper embedded font withglyphs:) andparse.stationery(format: :html | :markdown).Stationery.instrumenterisActiveSupport::Notificationswhen 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 toinstrument(name, payload, &block)to plug something else in. Output is unchanged. FontBook#resolvememoises by family, weight and style instead of hashing the whole 13-fieldText::Styleon 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 { … }andlayer(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.rbshows 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.columntakes 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 taggedFigurebbox are unchanged; plain images are byte-for-byte as before.Canvas#rotate(degrees, around: [x, y]) { … }andCanvas#transform([a, b, c, d, e, f]) { … }wrap a block in acmtransform given in top-left space, clockwise like CSSrotate(). Link and form-widget rectangles are annotations in page space, so they stay unrotated.html/markdownwrite link annotations only forhttp,https,mailtoandtelhrefs (and#anchor); anything else (javascript:,data:, a relative path) keeps its text without a link and is reported as aWarnings::DroppedLink.links: %w[http https]changes the list,links: :allkeeps every href from a trusted source.html/markdownstyles:takeul:andol:(gap:,indent:,marker_gap:,marker_color:, plusstyle:forulandformat:/suffix:forol), 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 reportsMissingGlyph; font fallback keeps every Unicode White_Space character with its neighbours. - Testing: the RSpec matchers include
RSpec::Matchers::Composable, sohave_pdf_text(…).and have_bookmark(…),.orand use insideall/includework.Inspector#langreads the catalog/Lang;have_pdf_language("de")andassert_pdf_languageassert it. - Previews:
Stationery::Preview#around_render(name, params)wraps both the preview method andto_pdf, for an I18n locale orCurrentAttributesthat 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
Formstructure elements owning their widget annotations (/OBJR+/StructParent), so screen readers reach them in reading order. - Tagged (accessible) PDF:
taggedat class level orto_pdf(tagged: true)writes a structure tree (/StructTreeRootwith a/ParentTree,/MarkInfo,/StructParentson pages) and marks every painted leaf as marked content (/P <</MCID n>> BDC … EMC). Text isP, orH1–H6withtext(…, heading: 1..6); images and SVG drawings areFigurewith/Altfromalt:(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/Paginationartifacts, 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 warningsMissingAltandMissingLanguageletstrictcatch accessibility gaps. Untagged output is byte-for-byte unchanged.Canvas#tag,#structureand#artifact,Stationery::Tagging::{Tree, Element, Writer}. - Tagged PDF structure for lists (
Lwith/ListNumbering>LI>Lbl+LBody; drawn bullets are artifacts), tables (Table>TR>THwith/Scope /ColumnorTD,/ColSpan//RowSpanattributes; oneTableacross pages, repeated header rows as artifacts), links (linked text is aLinkinside its paragraph with the annotation's/OBJR; annotations get/StructParent;box(link:)groups its content in aLink) andtable_of_contents(TOC>TOCI>Link+Reference).html/markdowntag headingsH1–H6and block quotes.Canvas#tag_runs,Canvas#link(…, tag:),Layout::Flow.new(…, tag:),Tagging::Element.new(…, attributes:). - Testing tagged PDFs:
Inspector#structurereads 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; matchershave_structureandhave_tagged_content, assertionsassert_pdf_structureandassert_tagged_content.examples/report.rbis 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:) andcheckbox(withlabel:) lay out like boxes or sit atat: [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;NeedAppearancesis 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#fieldsreturns{ name => value }after a render;Canvas#widgetplaces a field widget;to_pdf(debug: [:field])outlines fields. - Forms:
radiogroups (radios sharing a name form one/Btnfield with the radio flag;/Vis the checked value),selectcombo boxes (/Chwith/Opt,editable:adds the Edit flag) andsignature_field(an empty/Sigfield drawn as a rule over its label).examples/form.rbis 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:
linearGradientandradialGradientfills (fill="url(#id)", also instyle), with stops,href/xlink:hrefinheritance,objectBoundingBoxanduserSpaceOnUseunits andgradientTransform, drawn as PDF axial/radial shadings clipped to the shape. Approximations:reflect/repeatspreads are drawn aspad(reported inunsupported), 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 asurl(#id).Canvas#shadepaints a shading dictionary inside a path.SVG:
textandtspan(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-baselineis ignored and a gradient fill uses its middle colour.Layout::Svgtakescontext:,SVG::Document#drawtakesbook:andfamily:, andFontBook#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 asstyle selectors: …. Cascade: presentation attributes, then rules by specificity (id > class > element, later wins on ties), then inlinestyle. Rules style shapes, text (font-*) and gradient stops (stop-color,stop-opacity);display: noneskips an element and its children,visibility: hiddenskips it (avisiblechild draws).classtakes several classes.Encryption with the standard security handler:
to_pdf(encrypt: { owner_password:, user_password:, permissions:, algorithm: })orencrypt …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
ligafeature (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: falseontext/text_styleor indefault_textopts out; anyletter_spacingturns them off. Text extraction and copy still yield the source characters (ToUnicode maps a ligature glyph to all of them). Fonts withoutligaligatures, 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#Nsuffix (face 0 without one); each face is parsed, cached, subset and embedded on its own.TrueType.new(data, index:),TrueType.collection?andTrueType.faces; an index out of range raisesArgumentErrornaming 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.ttfor.woff.box(min_height:)andcolumn(min_height:): a height floor that, unlikeheight:, 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 bothheight:andmin_height:raisesArgumentError.
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: :avoidfor the old behaviour. A box that fits on a page still moves there whole;break_inside: :autosplits 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
kerntable, 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: falseontext/text_styleor indefault_textrestores 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 listandstationery 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) intovendor/fonts/<pack>/, atomically and offline-capable.font_family "Noto Sans"with no paths finds an installed pack throughStationery.font_paths, as does an unregistered family name at render time. Ruby APIStationery::Fonts.install/catalog/paths, Rails generatorstationery:fonts, andrake 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 asMissingGlyphwarnings counted per drawn occurrence.Font#glyph?is memoised per character. - GPOS pair kerning: the
kernfeature's PairPos lookups (formats 1 and 2, also behind Extension lookups) take precedence over thekerntable, 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 asFontFile3 /CIDFontType0Cwith a subset tag.
Text
align: :justifyontext,text_styleand 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.htmlandmarkdownelements: 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 (orbase_path:), missing/remote images are skipped with aSkippedImagewarning and never fetched,bookmarks: trueoutlines h1–h3. Parsers load on first use.- Markup decodes all 252 HTML 4 named entities (
—,€,…, …); the table loads on first use. - Internal:
Stationery::Richblock model (paragraphs, headings, lists, quotes, code, rules, tables, images; inlines with marks) with lenientHTML.parse(implied end tags, browser whitespace collapsing, Trix/ActionText output) and CommonMark-subsetMarkdown.parse(GFM tables and strikethrough, reference links). Loaded on demand;html/markdownelements follow. - Internal:
Fonts::GlyphRuncarries per-glyph advance adjustments (Tj/TJ emission) for upcoming kerning and justification; output unchanged.
Layout
ul/ol/lilists: drawn (disc, circle, square) or text bullets (dash, any String), decimal / alpha / roman / Proc numbering,start:andsuffix:, aligned bodies, nested lists with depth-cycled bullets, and items that split across pages keeping the marker with their first line.box(break_inside:)andcolumn(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_nextnow moves with a following node that avoids breaking inside and would not start on the page.wrapelement: 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:
splittakesfresh: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
headerandfooterregions that reserve page space, withheight:,gap:andon:(:all,:first,:rest,:odd,:even, a page number, a range or a proc); later declarations win. Apage_template'spage.content_boxnow excludes their space.Page#margin_box.header/footeron: :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:ontext,box,groupandtable, a standaloneanchorelement, andlink: "#name"/<a href="#name">/box(link: "#name")written as PDF/DestGoTo links. Unknown targets are dropped with anUnresolvedLinkwarning; duplicate names warn withDuplicateAnchor. - Bookmarks and document outline:
bookmark:ontext,box,groupandtable(a title, or{ title:, level:, open: }) and a standalonebookmarkelement write a nested PDF/Outlinestree; the document opens with the outline panel. Bookmarks in page templates are ignored. table_of_contentselement: 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.svgelement. - SVG: elements that cannot be drawn (
text,use, …) are listed inSVG::Document#unsupportedand reported as anUnsupportedSvgwarning. - Canvas:
rounded_rectandcliptake 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
UnknownFamilywarning once. Stationery::Warnings: one collector per render, deduplicated, with#messageon every entry (Overflow,MissingGlyph,UnknownFamily,UnsupportedSvg,SkippedImage,UnresolvedLink,DuplicateAnchor).document.warningsnow includes warnings raised while page templates draw.- Strict mode:
to_pdf(strict: true)or class-levelstrictraisesStationery::WarningsErrorwhen a render produced warnings.
Rails
- Rails: a Railtie adds
render pdf: document(filename:,disposition:) andconfig.stationery(renderer,font_paths);Stationery.font_paths. Aspec:railslane tests it against Rails 8 without adding a dependency. - Rails: PDF previews like ActionMailer previews.
Stationery::Previewsubclasses inspec/pdfs/previewsortest/pdfs/previewsrender at/rails/stationery/previews(?debug=1when the document supports it);config.stationery.preview_pathsandshow_previews(development by default).rake spec:railsno longer overwrites the main suite's coverage report.
Tooling
- Documentation site at https://stationery.zoolutions.llc, a docs-kit app under
docs/(not part of the gem): getting started, every element with its options, layout rules, pages and regions, links and bookmarks, fonts, images and SVG, Rails, testing, the CLI, warnings, a cookbook built fromexamples/, performance, limitations and this changelog, rendered from the README and CHANGELOG where they overlap. stationeryexecutable with a command registry;stationery render FILE [--out PATH|-] [--class NAME] [--strict] [--debug]renders the Document a Ruby file defines (throughself.previewwhen 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) andrequire "stationery/minitest"assertions, built onStationery::Testing::Inspector. Needspdf-readerin 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) andexamples/packing_slip.rb(landscape, 120-row table with split rows, canvas barcode), each covered by an integration spec and rendered in CI byrake examples. rake bench: reproducible benchmarks against Prawn + prawn-table (one-page invoice, 1,500-row table) and a StackProf profile script underbenchmark/.
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.rbprofiles 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::Writerraises when a reserved object is never set instead of writingnull.- Gemspec ships every file under
lib/andexe/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.