stationery 0.11.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -68
  3. data/README.md +728 -77
  4. data/examples/accessible_report.rb +133 -0
  5. data/examples/article.rb +93 -0
  6. data/examples/assets/bay.png +0 -0
  7. data/examples/assets/dunes.png +0 -0
  8. data/examples/assets/hills.png +0 -0
  9. data/examples/assets/logo.png +0 -0
  10. data/examples/assets/ridge.png +0 -0
  11. data/examples/assets/sea.png +0 -0
  12. data/examples/assets/stone.png +0 -0
  13. data/examples/assets/wall.png +0 -0
  14. data/examples/certificate.rb +104 -0
  15. data/examples/contract.rb +159 -0
  16. data/examples/e_invoice.rb +161 -0
  17. data/examples/flyer.rb +336 -0
  18. data/examples/form.rb +113 -0
  19. data/examples/invoice.rb +163 -0
  20. data/examples/letter.rb +121 -0
  21. data/examples/menu.rb +133 -0
  22. data/examples/multilingual_notice.rb +121 -0
  23. data/examples/newsletter.rb +108 -0
  24. data/examples/packing_slip.rb +120 -0
  25. data/examples/postcard.rb +83 -0
  26. data/examples/price_list.rb +107 -0
  27. data/examples/receipt.rb +176 -0
  28. data/examples/report.rb +294 -0
  29. data/examples/resume.rb +121 -0
  30. data/examples/shaping/extraction_matrix/pdfium.py +8 -0
  31. data/examples/shaping/extraction_matrix/pdfjs.mjs +18 -0
  32. data/examples/shaping/extraction_matrix/pdfkit.swift +6 -0
  33. data/examples/shaping/extraction_matrix.rb +208 -0
  34. data/examples/shaping/harfbuzz_shaper.rb +132 -0
  35. data/examples/shaping/rtl_letter.rb +106 -0
  36. data/examples/shipping_label.rb +93 -0
  37. data/lib/stationery/barcode/code128.rb +96 -0
  38. data/lib/stationery/barcode/data_matrix.rb +111 -0
  39. data/lib/stationery/barcode/data_matrix_placement.rb +118 -0
  40. data/lib/stationery/barcode/ean13.rb +66 -0
  41. data/lib/stationery/barcode/qr.rb +120 -0
  42. data/lib/stationery/barcode/qr_matrix.rb +189 -0
  43. data/lib/stationery/barcode/reed_solomon.rb +61 -0
  44. data/lib/stationery/barcode.rb +33 -0
  45. data/lib/stationery/builder.rb +12 -2
  46. data/lib/stationery/canvas/glyphs.rb +40 -0
  47. data/lib/stationery/canvas/interface.rb +169 -0
  48. data/lib/stationery/canvas/marking.rb +32 -3
  49. data/lib/stationery/canvas/path.rb +32 -70
  50. data/lib/stationery/canvas/surfacing.rb +67 -0
  51. data/lib/stationery/canvas/text.rb +4 -25
  52. data/lib/stationery/canvas.rb +24 -75
  53. data/lib/stationery/cli/examples.rb +66 -0
  54. data/lib/stationery/cli/inspect.rb +65 -0
  55. data/lib/stationery/cli/layout_text.rb +132 -0
  56. data/lib/stationery/cli/pictures.rb +50 -0
  57. data/lib/stationery/cli/render.rb +33 -6
  58. data/lib/stationery/cli/skill.rb +166 -0
  59. data/lib/stationery/cli.rb +5 -0
  60. data/lib/stationery/color.rb +27 -13
  61. data/lib/stationery/component.rb +1 -0
  62. data/lib/stationery/document.rb +135 -27
  63. data/lib/stationery/elements/forms.rb +2 -1
  64. data/lib/stationery/elements/lists.rb +6 -2
  65. data/lib/stationery/elements.rb +43 -7
  66. data/lib/stationery/examples.rb +22 -0
  67. data/lib/stationery/fonts/cff.rb +46 -0
  68. data/lib/stationery/fonts/charstring.rb +297 -0
  69. data/lib/stationery/fonts/fallback.rb +22 -1
  70. data/lib/stationery/fonts/font.rb +58 -3
  71. data/lib/stationery/fonts/font_book.rb +7 -3
  72. data/lib/stationery/fonts/glyf_outline.rb +230 -0
  73. data/lib/stationery/fonts/glyph_run.rb +37 -10
  74. data/lib/stationery/fonts/outline.rb +102 -0
  75. data/lib/stationery/fonts/shaped_run.rb +22 -4
  76. data/lib/stationery/fonts/true_type.rb +7 -0
  77. data/lib/stationery/forms/typeface.rb +2 -4
  78. data/lib/stationery/images/images.rb +10 -0
  79. data/lib/stationery/images/jpeg/bit_reader.rb +92 -0
  80. data/lib/stationery/images/jpeg/colors.rb +133 -0
  81. data/lib/stationery/images/jpeg/decoder.rb +253 -0
  82. data/lib/stationery/images/jpeg/huffman.rb +59 -0
  83. data/lib/stationery/images/jpeg/idct.rb +150 -0
  84. data/lib/stationery/images/jpeg/progressive.rb +128 -0
  85. data/lib/stationery/images/jpeg/reduced_idct.rb +106 -0
  86. data/lib/stationery/images/jpeg/scan.rb +116 -0
  87. data/lib/stationery/images/jpeg/upsampler.rb +81 -0
  88. data/lib/stationery/images/jpeg.rb +59 -1
  89. data/lib/stationery/images/png.rb +10 -7
  90. data/lib/stationery/images/resampled.rb +10 -2
  91. data/lib/stationery/images/webp.rb +6 -5
  92. data/lib/stationery/layout/barcode.rb +75 -0
  93. data/lib/stationery/layout/box/wrapping.rb +17 -13
  94. data/lib/stationery/layout/box.rb +3 -3
  95. data/lib/stationery/layout/field.rb +3 -2
  96. data/lib/stationery/layout/floated.rb +12 -1
  97. data/lib/stationery/layout/flow/floating.rb +23 -5
  98. data/lib/stationery/layout/flow/placement.rb +13 -10
  99. data/lib/stationery/layout/flow/splitter.rb +56 -10
  100. data/lib/stationery/layout/flow.rb +1 -0
  101. data/lib/stationery/layout/image.rb +2 -2
  102. data/lib/stationery/layout/list_item.rb +14 -5
  103. data/lib/stationery/layout/mark.rb +2 -0
  104. data/lib/stationery/layout/node.rb +16 -2
  105. data/lib/stationery/layout/opening.rb +61 -0
  106. data/lib/stationery/layout/paginator.rb +24 -5
  107. data/lib/stationery/layout/svg.rb +1 -1
  108. data/lib/stationery/layout/table/cell.rb +39 -10
  109. data/lib/stationery/layout/table/row_splitter.rb +6 -2
  110. data/lib/stationery/layout/table/selection.rb +9 -3
  111. data/lib/stationery/layout/table/stream.rb +123 -0
  112. data/lib/stationery/layout/table/widths.rb +26 -0
  113. data/lib/stationery/layout/table.rb +151 -35
  114. data/lib/stationery/layout/table_of_contents/entry.rb +3 -3
  115. data/lib/stationery/layout/table_of_contents.rb +1 -1
  116. data/lib/stationery/layout/text.rb +22 -2
  117. data/lib/stationery/minitest.rb +5 -0
  118. data/lib/stationery/monochrome/bitmap.rb +78 -0
  119. data/lib/stationery/monochrome/canvas.rb +19 -0
  120. data/lib/stationery/monochrome/dither.rb +74 -0
  121. data/lib/stationery/monochrome/grid.rb +131 -0
  122. data/lib/stationery/monochrome/painting.rb +140 -0
  123. data/lib/stationery/monochrome/rules.rb +141 -0
  124. data/lib/stationery/monochrome.rb +87 -0
  125. data/lib/stationery/page/format.rb +84 -0
  126. data/lib/stationery/page.rb +11 -7
  127. data/lib/stationery/page_templates.rb +13 -10
  128. data/lib/stationery/path.rb +138 -0
  129. data/lib/stationery/pdf/assembler.rb +14 -3
  130. data/lib/stationery/pdf/canvases.rb +51 -0
  131. data/lib/stationery/pdf/conformance.rb +40 -13
  132. data/lib/stationery/pdf/print_hints.rb +137 -0
  133. data/lib/stationery/pdf/serializer.rb +41 -0
  134. data/lib/stationery/raster/adjust.rb +67 -0
  135. data/lib/stationery/raster/canvas.rb +108 -0
  136. data/lib/stationery/raster/canvases.rb +49 -0
  137. data/lib/stationery/raster/flattener.rb +75 -0
  138. data/lib/stationery/raster/glyphs.rb +81 -0
  139. data/lib/stationery/raster/painter.rb +184 -0
  140. data/lib/stationery/raster/picture.rb +69 -0
  141. data/lib/stationery/raster/png.rb +32 -0
  142. data/lib/stationery/raster/render.rb +134 -0
  143. data/lib/stationery/raster/scanner.rb +164 -0
  144. data/lib/stationery/raster/shading.rb +77 -0
  145. data/lib/stationery/raster/spans.rb +66 -0
  146. data/lib/stationery/raster/stroker.rb +208 -0
  147. data/lib/stationery/raster/surface.rb +70 -0
  148. data/lib/stationery/raster.rb +44 -0
  149. data/lib/stationery/skill/SKILL.md.erb +244 -0
  150. data/lib/stationery/skill/recipes/flyer.md.erb +76 -0
  151. data/lib/stationery/skill/recipes/form.md.erb +78 -0
  152. data/lib/stationery/skill/recipes/invoice.md.erb +142 -0
  153. data/lib/stationery/skill/recipes/label.md.erb +116 -0
  154. data/lib/stationery/skill/recipes/receipt.md.erb +79 -0
  155. data/lib/stationery/skill/recipes/report.md.erb +113 -0
  156. data/lib/stationery/skill/reference/conformance.md.erb +45 -0
  157. data/lib/stationery/skill/reference/elements.md.erb +19 -0
  158. data/lib/stationery/skill/reference/fonts_and_images.md.erb +7 -0
  159. data/lib/stationery/skill/reference/forms.md.erb +5 -0
  160. data/lib/stationery/skill/reference/html_and_markdown.md.erb +5 -0
  161. data/lib/stationery/skill/reference/pages.md.erb +15 -0
  162. data/lib/stationery/skill/reference/printing.md.erb +33 -0
  163. data/lib/stationery/skill/reference/testing.md.erb +33 -0
  164. data/lib/stationery/skill.rb +101 -0
  165. data/lib/stationery/structure.rb +7 -6
  166. data/lib/stationery/svg/gradient.rb +12 -0
  167. data/lib/stationery/svg/painter.rb +1 -1
  168. data/lib/stationery/tagging/element.rb +3 -2
  169. data/lib/stationery/tagging/tree.rb +45 -0
  170. data/lib/stationery/testing/inspector.rb +68 -0
  171. data/lib/stationery/testing/layout.rb +166 -0
  172. data/lib/stationery/testing/matchers.rb +37 -0
  173. data/lib/stationery/testing/page_reader.rb +146 -0
  174. data/lib/stationery/text/paragraph.rb +13 -8
  175. data/lib/stationery/text/wrapper/one_word.rb +33 -0
  176. data/lib/stationery/text/wrapper.rb +62 -27
  177. data/lib/stationery/units.rb +45 -0
  178. data/lib/stationery/version.rb +1 -1
  179. data/lib/stationery/warnings.rb +57 -6
  180. data/lib/stationery/zpl/native.rb +51 -0
  181. data/lib/stationery/zpl/render.rb +56 -0
  182. data/lib/stationery/zpl.rb +88 -0
  183. data/lib/stationery.rb +47 -0
  184. metadata +115 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b208319414f7dea1a6af8a89b47aec788e1135ace442031a1d05623240483d7c
4
- data.tar.gz: f9d15f729afcfd2fa6861b01b365462b4a5d413a4ce9b6fb101c2b7c3b9f68db
3
+ metadata.gz: '08814231457b8700e5c8a03e29c1234d219adcfbf29cbc49de819f05667bf994'
4
+ data.tar.gz: 0cdb872927ead42db1e41ab558c828acabd73d7cba89c121edf46a58aafbf26d
5
5
  SHA512:
6
- metadata.gz: fca53eecf78b67b65c7bb9b9582d625f6e693ba9ff29264423f9c1028d3ea8b2477d69af7858edde8169b91b592d43a364d4874703cf719173158c09f187f4b1
7
- data.tar.gz: 9bd72b967a7e1cd32acf650e5662d009cf792e23f1fef9cec3df579b74fad916f2ed4a0d6d96bdd0b0bbefcecc85f7d277510ff69b486c742d0d00ae169ac8da
6
+ metadata.gz: 583090cf9f95cecf4e53ed6a535c9e71d8c62797e455228f70036497554e0b0e594a8b3b74d60212cef2734334611379b8ac301a1411c6eee7597bcf58d720a0
7
+ data.tar.gz: 42541eb732431081e0ff9a00d66e6d0071c46bed006402b5ebee3fc87d7cd679098b77f03692b00b275aa04f20eeefc82a2e50fb949af9ac0e27664dc472bde2
data/CHANGELOG.md CHANGED
@@ -1,5 +1,44 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.12.0 (2026-09-29)
4
+
5
+ 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`.
6
+
7
+ - PDF/UA-1 and links (#149, #148): every link annotation of a tagged render has to belong to a `Link` element, which is what gives it its `/StructParent` (ISO 14289-1, 7.18.5; veraPDF rule 7.18.5-1). A `link:` a `header`, a `footer` or a `page_template` paints (`text(link:)`, markup, `html`, `markdown`, `box(link:)`, to a URL or an `#anchor`) is now tagged as the links of the body are: a `Link` element of the `Document` holding the text of the link as real content and its annotation as `/OBJR`, with `/StructParent` and, under PDF/UA-1, `/Contents`. The rest of the region stays a pagination artifact, its marked-content sequence closed before what the link paints and opened again after it. There is one `Link` per link and page, after the content the body has on that page and in the order header, footer, page templates, so a reader comes to it when the page is read; what a linked box holds is tagged inside its `Link`. `Inspector#structure` and `have_structure` show them (`[:Link, "example.com"]`), and `rake verify:conformance` validates the report example with a linked footer. What cannot be tagged, a link in the header row a table repeats on its next pages and `canvas.link` without a `tag:` inside a `canvas { }` block (in a region too), is an artifact outside the structure tree: under `conformance :pdf_ua1` the render raises `Stationery::ConformanceError` with an issue per link and page (`link to https://example.com drawn by canvas.link without a tag: on page 1 is outside the structure tree (7.18.5)`) instead of writing a file that claims a level it does not keep, and in a tagged render without the claim it is a `Warnings::UntaggedLink` (`target`, `place`, `page`; `place` is `:header`, `:footer`, `:page_template`, `:artifact` or `:canvas`), so `strict` catches it. Links in the body (`text(link:)`, markup, `box(link:)`, `html`, `markdown`, `table_of_contents`, `canvas.link(…, tag:)` with a `Link` element that is in the tree) are what they were. PDF/A alone does not ask for it. Documents that are not tagged, and tagged documents without a link in a region, are byte for byte what they were. `Stationery::Canvas::Surfacing`, `Stationery::Tagging::Tree#follow`.
8
+ - `missing_glyphs: :replace` keeps a PDF/A or PDF/UA claim when a character no font has is drawn (#150): `conformance :pdf_a3b, missing_glyphs: :replace` at class level, or `to_pdf(conformance: :pdf_a3b, missing_glyphs: :replace)` per render. The character is drawn as the first of U+FFFD, U+25A1 and `?` that the font drawing it has, at the stand-in's own advance (so lines are measured as they are drawn), inside the `Span` whose `ActualText` is the character, as `.notdef` is wrapped; the text still extracts, copies and reads aloud as written, and the map to Unicode gives the stand-in its own character wherever the document also writes it. veraPDF passes `2b`, `3b` and `ua1` with each of the three stand-ins in body text, headers and page templates, form field values and shaped text (glyph 0 of a shaper's answer is replaced too), where the same files drawn with `.notdef` fail rules 6.2.11.8-1 and 7.21.8-1. The `Warnings::MissingGlyph` stays, with a new `stand_in` (the character drawn, nil for `.notdef`) and a message that names it, so `strict` still raises. A font that has none of the three, which a symbol font may not, still raises `ConformanceError`, saying so. Whitespace a font lacks stays a blank. `:raise` is the default and is what 0.11 does; any other value raises `ArgumentError`. Without `conformance` the option changes nothing, and documents that do not use it are byte for byte what they were. `Stationery::Fonts::Font#stand_in`, `Stationery::Fonts::ShapedRun::StandIn`, `Stationery::PDF::Conformance#missing_glyphs`.
9
+ - Page sizes in millimetres and inches, and label and envelope sizes (#145). `Stationery::Units` converts to points where a size is written: `mm(102)`, `cm(2)`, `inch(4)` and `pt(12)`, in the body and the instances of every component and document (`page size: [mm(102), mm(74)], margin: mm(3)`, `box(at: [mm(20), mm(45)], width: mm(90))`), in any other class with `include Stationery::Units`, and as `Stationery::Units.mm(102)`. They are private methods and `Numeric` is not patched; a method of that name in a document wins. The class-level `page` reads lengths with their unit: `page size: ["102mm", "74mm"]`, `page size: "4in x 6in"` (also `×`, and `"102 x 74 mm"`, where the first length takes the unit of the second), `margin: "3mm"`, `margin: ["3mm", "5mm"]`, `margin: { x: "5mm" }`. A length is digits, with a fraction after a point if there is one, and `mm`, `cm`, `in` or `pt` in either case, with spaces around them if any: no sign, exponent or comma. `Stationery::Units.points("3mm")` reads one. New names: `:a6`, `:a7`, `:b5`, the envelopes `:dl`, `:c5` and `:c6` (short edge first, as ISO 269 has them: `layout: :landscape` is the address side) and the label stock `:label_4x6`, `:label_4x3`, `:label_4x2`, `:label_100x150` and `:label_100x50` (width by height). `Page.new` and `Layout::Paginator` take the same. The sizes there were have the points they had, and a document written in points is byte for byte what it was. `Stationery::Units`, `Stationery::Page::Format`.
10
+ - Print hints: a document says how it wants to be printed (#146). `print scaling: :none, copies: 2, pick_tray_by_size: true, duplex: :simplex, pages: 1..3` at class level, inherited and added to by subclasses, and `to_pdf(print: { copies: 1 })` laid over it for one render; a `nil` takes a hint away (`print: { duplex: nil }`), and `to_pdf(print: nil)` or `false` all of them. They are written to the catalog's `/ViewerPreferences` (ISO 32000-1, table 150) as `/PrintScaling` (`:none`, `:default`), `/NumCopies`, `/PickTrayByPDFSize`, `/Duplex` (`:simplex`, `:long_edge`, `:short_edge`) and `/PrintPageRange`, beside the `/DisplayDocTitle` of a tagged document. `pages:` is a Range of page numbers from 1 or a list of them (`[1..1, 3..4]`, `2..`), written in page order; a range is cut at the last page of the render and one that starts after it is left out. `print dialog: :on_open` writes `/OpenAction << /S /Named /N /Print >>`, which asks the viewer for its print dialog when the file is opened. Every option is checked where it is written and raises `ArgumentError` naming what it takes, and ranges that overlap are refused. `/ViewerPreferences` passes veraPDF under PDF/A-2b, PDF/A-3b and PDF/UA-1; the print action does not under PDF/A (rule 6.5.1-2), so `dialog: :on_open` with `conformance :pdf_a2b` or `:pdf_a3b` raises `Stationery::ConformanceError`, and PDF/UA-1 alone takes it. Incremental, signed and encrypted renders write them too. They are hints: the README has what Acrobat, Chrome and Firefox are documented to do with each, none of it tried in a running viewer. Testing: `Inspector#print_preferences`, `have_print_preference(scaling: :none)` and `assert_print_preference`. `print` in a class body is this declaration; the methods of a document still call `Kernel#print`. Without `print`, a document is byte for byte what it was. `PDF::PrintHints`.
11
+ - A document that must be one page can say so (#199): `max_pages 1` in the class body, for a label, a receipt or a card. A render that needs more pages lays them all out as before and reports `Warnings::TooManyPages` naming the limit, the pages needed and what the first page past the limit starts with (`the document may have 1 page and needs 2: page 2 starts with a Code 128 barcode`; text is quoted, cut at 40 characters), so `strict` raises. `to_pdf` and `to_png` keep every page, to be looked at; `to_zpl` writes no label and raises `WarningsError` whether or not the render is strict, since each page past the limit is one more label printed, and `stationery render --zpl` exits 1 with the warning. `to_pdf(max_pages:)`, `to_png(max_pages:)` and `to_zpl(max_pages:)` replace the limit for one render, `nil` takes it away; `max_pages nil` in a subclass does too, and `page` in a subclass keeps it. The shipping label and receipt examples, the skill's label and receipt recipes and its "Verify a label" checklist declare it. It writes nothing: a document with it that fits is byte for byte what it was, and one without it is unchanged. `Layout::Paginator`, `Layout::Opening`.
12
+ - Monochrome mode, for thermal label printers and other devices that print black or nothing (#144): `monochrome dpi: 203` at class level, or `to_pdf(monochrome: { dpi: 203 })` for one render (`monochrome: false` takes it away), reports every colour that is not black or white and every opacity below 1 as a `Warnings::NotMonochrome` naming the colour and what painted it (text, a rule, a background, a border or line, a gradient, an image), once per colour, kind and page, and every stroke or rule thinner than one of the printer's dots as a `Warnings::ThinLine`, so `strict` catches them. `snap: true` changes them instead: text, rules and strokes black (white ones stay white), fills and gradients black when darker than `threshold:` (0.5, the tone being the BT.601 luma laid over white at its opacity) and left out when lighter, opacity taken away, thin lines widened to a dot. Filled rectangles and strokes of horizontal and vertical lines are put on the printer's dot grid, edges on whole dots and widths whole numbers of dots; curves and transformed shapes keep their geometry. PNG, WebP and JPEG images are resampled to the dots they cover and dithered to a one-bit DeviceGray image (`dither: :floyd_steinberg`, `:ordered` or `:threshold`). It sees what elements, SVGs and `canvas { }` blocks paint alike, keeps PDF/A and PDF/UA, and leaves form fields alone. `Inspector#colors` lists the colours a PDF paints with, and `have_pdf_colors` / `assert_pdf_colors` hold them; `PNG#pixels` and `WebP#pixels` are public. Without `monochrome` every PDF is byte for byte what it was. `Stationery::Monochrome`, `Monochrome::Rules`, `Monochrome::Grid`.
13
+ - The groundwork for outputs other than PDF (#147). A canvas interface: what a layout node, the SVG renderer and a `canvas { |c| … }` block may call is `Stationery::Canvas::Interface`, in top-left coordinates and points and with nothing of PDF in what is handed over or answered; `Stationery::Canvas` is the PDF canvas, with the methods it had. A path is a `Stationery::Path`, its segments kept as numbers and read back with `each_segment` (`Canvas::Path` writes them as operators), a gradient is handed over as an `SVG::Gradient::Fill`, and `Document#paint_on(canvases)` lays a document out and paints it on the canvases it is given, without writing a file (`Stationery::PDF::Canvases`). Glyph outlines: `font.outline(gid)` answers the contours of a glyph as a `Stationery::Fonts::Outline`, in font units with y up, for TrueType (`glyf`, composite glyphs with their offsets, scales, 2×2 matrices, point-matched anchors and `SCALED_COMPONENT_OFFSET`, nested up to 8 deep), CFF (name-keyed and CID-keyed, `.otf`: Type 2 charstrings with every path operator, flex included, hints skipped by their stem count, local and global subroutines, the advance width and FDSelect; the deprecated `seac` form of `endchar` and the arithmetic operators raise `UnsupportedFont`), WOFF and collection fonts; moves, lines and cubic curves only, a TrueType quadratic raised to the cubic it is exactly. `outline.append_to(path, x, baseline, size / units_per_em.to_f)` adds it to a `Stationery::Path` in top-left space. Every glyph of Inter, Source Sans 3, Noto Sans JP, Open Sans, Latin Modern and TeX Gyre Termes reads as fontTools draws it. A `Font` keeps up to `Font::OUTLINE_MEMO` numbers of outlines, and nothing until one is asked for. Every PDF is byte for byte what it was. `Fonts::GlyfOutline`, `Fonts::Charstring`, `Fonts::TrueType#outline`, `Fonts::CFF#outline`.
14
+ - Pictures of a render (#147): `document.to_png(dpi: 96)` answers a PNG String per page, and `to_png("out.png")` writes them too (`out.png` for one page, `out-1.png`, `out-2.png`, … for several); `pages:` picks pages by number, Range or Array. The pages `to_pdf` lays out are painted in pure Ruby by a scanline rasteriser: text from its glyph outlines (synthetic bold and oblique, letter spacing, rise, shaped runs), fills (nonzero and even-odd) and strokes (caps, joins with PDF's miter limit, dashes), clips, transforms, opacity, SVG linear and radial gradients, PNG, WebP and JPEG images, templates with the background layer under the page, and page numbers. Colour pictures are anti-aliased 24-bit RGB, with glyph origins, stroke edges and image edges on the pixel grid as poppler puts them: the ten examples and the benchmark documents differ from `pdftoppm` in 0.0 to 0.5% of their pixels at 72 dpi. `monochrome:` (as `to_pdf` takes it, the class's applying) draws a 1-bit PNG at the monochrome dpi with the same rules as the PDF, no anti-aliasing (a dot is black when its centre is inside); black and white match `pdftoppm -mono` in all but 0.1 to 2% of the dots. An upright dithered image is drawn at its own size in dots from its corner on the nearest dot, dot for dot the one-bit image the PDF embeds (#192: a box a fractional number of dots wide or tall stretched it by a dot where both edges rounded outwards, drawing a row and a column twice, so that up to 46% of a photo's dots differed; checked for a PNG and a JPEG photo at 203 and 300 dpi; `Raster::Adjust.picture`). Form fields draw nothing. `sign:`, `encrypt:`, `conformance:`, `attachments:`, `print:` and the other PDF-only options raise `ArgumentError` when given to `to_png` and are left alone when the class declares them. A 100 × 150 mm label at 203 dpi takes about 30 ms. Every PDF is byte for byte what it was. Also fixed: a PNG of 1, 2 or 4 bits a sample was decoded at the depth of the first such PNG decoded in the process (a once-only regular expression), which reached a PDF through a transparent palette PNG, a resampled or a monochrome image when the process had decoded one of another depth before. `Stationery::Raster`, `Raster::Canvases`, `Raster::Painter`, `Raster::Scanner`, `Raster::Stroker`, `Raster::PNG`, `Monochrome::Bitmap#pixels`.
15
+ - JPEG pixels, decoded in Ruby (#147): `to_png` draws a JPEG, and a `monochrome` render and `to_zpl` dither it like a PNG. `Images::JPEG#pixels` answers an `Images::Pixels` (8-bit grey or RGB) for baseline, extended (16-bit tables) and progressive JPEGs with Huffman coding: every chroma subsampling (4:4:4, 4:2:2, 4:2:0, 4:4:0, 4:1:1 and other whole factors), restart intervals, scans of one component at a time, spectral selection and successive approximation, grey, RGB, YCbCr, CMYK and YCCK (Adobe's inverted CMYK turned into RGB as Pillow does). It follows libjpeg's accurate integer IDCT, "fancy" chroma upsampling and fixed-point colour conversion, so its pixels are libjpeg-turbo's: 62 test JPEGs (Pillow and cjpeg, 1 × 1 to 1001 × 3, every kind above) decode to exactly Pillow's pixels. `pixels(at_least: [w, h])` decodes a photo drawn small at a half, a quarter or an eighth of its size through libjpeg's reduced IDCT, as a picture and a monochrome bitmap ask; each scale is decoded once per process. A 12-megapixel phone photo takes 2.8 s at full size and 0.7 s at a quarter (progressive: 3.4 s and 1.4 s), a 1000 × 1000 photo 0.25 s (Ruby 3.4 with YJIT; about four times as long without). A lossless, arithmetic-coded, hierarchical or 12-bit JPEG, and one of more than 33 megapixels (`JPEG#unsupported` says which), draws as a crossed box with a `Warnings::SkippedImage` naming why, and is reported as `Warnings::NotMonochrome` in a monochrome PDF. The EXIF orientation is not applied, as the PDF does not apply it. A PDF embeds the JPEG's bytes as it did, and every document without `to_png` or `monochrome` is byte for byte what it was. `Images::JPEG::Decoder`, `JPEG::Scan`, `JPEG::IDCT`, `JPEG::Upsampler`, `JPEG::Colors`, `Images.unreadable`, `Images.pixels`.
16
+ - ZPL for label printers (#147): `document.to_zpl` answers ZPL II as a String, one `^XA…^XZ` label per page, and `to_zpl("label.zpl")` or `to_zpl(socket)` writes it too. Each page is drawn one bit to a dot as `to_png(monochrome: …)` draws it (the class's `monochrome` settings, or the defaults: `to_zpl` is always monochrome and refuses `monochrome: false`) and written as `^PW`/`^LL` from the page size in dots, one `^GFA` graphic field and `^PQ` copies. `dpi:` is 152, 203, 300 or 600 (others raise), by default the class's monochrome dpi, else 203; `copies:` by default the class's `print copies:`; `compression: :z64` (zlib, Base64 and the CRC-16/XMODEM Zebra's software writes, the default) or `:hex`; `pages:` as for `to_png`. `stationery render label.rb --zpl --dpi 300` writes `label.zpl`. Labels read back equal `to_png`'s dots, and Labelary renders them identically. A new example, `examples/shipping_label.rb`, is a 4 × 6 in label with a Code 128 and a QR code. `Stationery::ZPL`, `ZPL::Render`; `Raster::Render#paint` and `#bits` are what it reuses.
17
+ - Barcodes (#147): `barcode "SX0042771903"` draws a Code 128 (`type: :code128`, printable ASCII, digit runs in code set C), `type: :ean13` an EAN-13 (the check digit added or checked), `type: :qr` a QR code (byte mode, `level: :l/:m/:q/:h`, versions 1 to 40, UTF-8 marked with an ECI) and `type: :datamatrix` an ECC 200 Data Matrix (ASCII encodation, two digits a codeword, bytes above 127 after an upper shift, in the smallest square from 10 × 10 to 144 × 144, with Reed–Solomon over GF(256) (0x12D) and the placement of ISO/IEC 16022 annex F; every size is module for module what libdmtx writes for the same data), encoded in pure Ruby and drawn as one filled path of vector bars. `module_size:` or `width:` size it, `height:` a linear one, the quiet zone is part of it (`quiet_zone: false` leaves it out) and it shrinks to fit; a monochrome render puts it on the dot grid, a whole number of dots a module; a tagged PDF makes it a figure described by its kind and data. In ZPL, `native: true` on the barcode or `to_zpl(native: true)` writes the printer's own `^BC`, `^BE`, `^BQ` or `^BX` where the picture had it, unless it is transformed, clipped, pale, over 10 dots a module, UTF-8 in a QR code or a Data Matrix holding `_` (the escape character given to `^BX`). Every symbol was decoded with ZBar (and dmtxread), and the native ones rendered by Labelary. `Stationery::Barcode` (`Code128`, `EAN13`, `QR`, `QR::Matrix`, `DataMatrix`, `DataMatrix::Placement`, `ReedSolomon` with its two fields `ReedSolomon::QR` and `ReedSolomon::DATA_MATRIX`), `Layout::Barcode`, `ZPL::Native`, and `Canvas::Interface#barcode`, which a canvas may draw its own way (the raster canvas records it as `Raster::Canvas::Native`).
18
+ - Floats that stack taller than the page no longer run over it (#153). Three floats of 150 × 60 pt in a page of 260 × 160 pt do not fit side by side, so they stack to 180 pt: the third hung 20 pt over the bottom edge and the render reported an `Overflow`. Below nothing but floats at the top of a page, a float was placed without its height being looked at. Now the float that would reach past the bottom edge starts the next page, with the floats and the text written after it, one float at a time and page after page for a long run; the first float of a page is kept, so only a float taller than a page by itself overflows, and is reported. It is the same for images and boxes, left and right floats mixed, and inside a box, a list item, a column of a `row`, `columns` (the float goes to the next column) and a table cell: the first row of a table on a fresh page cuts a cell that holds floats as the top of a page. What does not fit below the floats at the top of a page (a box that stays whole, the part of a flow that would run over) goes to the next page and leaves the floats, where it was kept and ran over the page; a node in one piece and taller than the page by itself stays with them and is reported, as it was. Below other content the floats are cut in the same way, where they moved to the next page together and ran over that one: with a line above them, the first two stay below the line and the third starts the next page. Floats that fit a page together still move to the next page together when one of them does not fit what is left of this one. A tagged PDF reads the floats in the order they were written. A float copies the children after it only where the flow is cut. The suite paginates 120 random flows of paragraphs, nested boxes, lists, floats and runs of floats from fixed seeds (`spec/stationery/layout/flow_fuzz_spec.rb`, about 3 s): 48 of them overflowed or lost text, none do. Documents without floats are byte for byte what they were, and allocate what they did; floats that fit their page are where they were (`examples/article.rb` renders pixel for pixel). `Layout::Flow::Splitter#float`, `#cut?`, `#lands?`, `#split_or_move`, `Layout::Table::RowSplitter`.
19
+ - A box with a `background:`, a `border:`, a `shadow:` or a `link:` beside a float wraps as a plain box does (#154). It was a block of the width the float left, all the way down: a long box with a background beside a short image stayed narrow for its whole height, with empty space under the image, because a float was painted before the box that followed it and a box that kept the full width would have painted its background over it. Now the box spans the full width of its flow, as a block does in CSS: its background, border and shadow run under the float, its lines wrap beside the float and take the full width below it, and the float is painted after the boxes beside it, so it sits on top of the background (a float with `opacity:` or a `shadow:` shows the background through it, and the link of a floated box lies over the link of a linked box). The padding lies under the float, so beside it the lines are the float's `margin:` away from it. It is the same for a box inside a box, inside a list item, whose marker is painted over the background of its body, and split across pages, where the background spans the full width on both. A tagged PDF reads the float where it was written, painted last or not: an image stays a `Figure` and a box has its `role:`; a floated box without one beside such a box is a `Div`, so that its content keeps its place (veraPDF passes PDF/UA-1 and PDF/A-3b). A box with a size or a place of its own (`width:`, `height:`, `rotate:`, `overflow:`, `valign:`), a `row`, a `table` and `columns` stay blocks: a table resolves its column widths from its width, a row shares its width and `columns` divides it, and none of them can be laid out again at another width from some row on. The random flows of the suite hold boxes with a shadow and a link too. Documents without floats are byte for byte what they were, and allocate what they did; so are documents whose floats have no such box in their flow (`examples/article.rb`). `Layout::Box::Wrapping`, `Layout::Flow::Floating#paint_beside`, `Layout::Floated#reserve`, `Layout::Node#decorated?`.
20
+ - 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.
21
+ - A word whose ligatures or kerning make it wider than its letters no longer hangs the render (#180). Wrapped at a width between the two (in Open Sans, 0.015 pt wide for *office*, *waffle* and *affix*, 0.06 pt in the italic), `Text::Wrapper` found the word too wide as drawn and then measured it letter by letter to break it, found that every letter fitted, moved nothing and went round for ever: a table whose column landed there never finished. A word broken between characters is now measured as it is drawn, a stretch of one style whole, for its break as for its fit: it breaks as any word wider than its line, and none of its pieces is wider than the line, where a piece could be by the width of a ligature. The head of the break is found letter by letter as before, so a word that was broken is broken where it was unless a piece's width as drawn and its width by its letters lie on either side of the line's width. The suite wraps the ligature words of Open Sans, Inter and Source Sans 3 at widths around each of their heads, with and without ligatures and inside a sentence. Documents without such a word are byte for byte what they were, and allocate what they did. `Text::Wrapper#break_long_word`.
22
+ - A hyphenated line keeps within its width when its head forms a ligature (#184). `Text::Wrapper` chose the hyphenation point by the widths of the head's letters and drew the head whole, with its ligatures and kerning, so where the head drawn is wider than its letters (*suffi-* in Open Sans Italic by 0.06 pt at 10 pt, *offi-* in Open Sans by 0.015 pt) the line ran over its width by the difference, which justified text sets to the exact width. The head, hyphen included, now fits both by its letters, as before, and as drawn. The suite wraps ligature words hyphenated in English, German and Swedish in Open Sans, Open Sans Italic and Inter at widths around each hyphenation point, alone and after a word. No example and no document of the metrics gate hits it: all are byte for byte what they were; `article` and `newsletter` allocate 60 and 124 objects more (0.2% and 0.4%), so their baseline is recorded again. `Text::Wrapper#hyphenated`.
23
+ - 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:
24
+ - Numbers are written with one String where they took two: `PDF::Serializer.number` rounds a Float below 100,000 to four decimals itself and writes its digits, without `Kernel#format`; a Float within a millionth of a tie at the fifth decimal, one of 100,000 and more, a Rational, `NaN` and the infinities go through `format("%.4f")` as all of them did. The two were compared over 133 million Floats (every multiple of 0.0001 from -2,000 to 2,000, every tie from 0 to 200 with the Floats beside it, the ties a Float holds exactly, random Floats of every size). 0.5% to 3.8% fewer objects (the 1,500-row table 2,499,522, from 2,597,014).
25
+ - Wrapping allocates less, and a word alone is not taken apart. One run of one word that fits its line and can break nowhere (no space, tab, newline, hyphen, soft hyphen, zero-width space or CJK character), which is what most cells of a table hold, becomes its line of one fragment straight away: 11 objects where cutting it into tokens took 53 (`Text::Wrapper::OneWord`). Any other text is cut as it was, with a `StringScanner` handing each token over as one String where `String#scan` made three, and its fragments gathered in a loop where `chunk_while` built enumerators. 1% to 24% fewer objects (the 1,500-row table 1,985,854 from 2,597,014, ten pages of text 259,535 from 299,086).
26
+ - Missing glyphs are counted, and runs split between fonts, without a String per character: `Fonts::Fallback.each_missing` walks the codepoints of a text and asks the font once per codepoint (`Font#lacks?`, remembered), where `Paragraph#count_missing`, `Forms::Typeface` and `Fallback#split` made a String of every character of every fragment drawn and every run wrapped; a text the font covers allocates nothing. A `GlyphRun` writes its `TJ` array in one pass over its hex (`GlyphRun#show`); a run with `.notdef` glyphs is still written in its pieces. The warnings are the same. 6% to 47% fewer objects (ten pages of text 146,749 from 255,565, hyphenated and justified 174,491 from 329,057, the 1,500-row table 1,738,893 from 1,872,904).
27
+ - A render that is not tagged builds no structure elements. The layout nodes of a render without `tagged` or a PDF/UA level (a paragraph's `P`, a table's `Table`, `TR`, `TH` and `TD`, a list's `L`, `LI`, `Lbl` and `LBody`, a `Figure`, a `Form`, a `Link`, a box's role, a contents entry's `TOCI`) carried a `Tagging::Element` that nothing wrote: 16,500 of them in the 1,500-row table. The builder now knows whether the render writes a tree (`Layout::Context#tagged`, `Builder#element`, `Context#element`) and the nodes carry nil where nothing will hold the element, which the canvas paints as it painted an element it could not attach. A tagged render builds what it built and its structure tree is the same. A node built by hand takes the elements it took: `Layout::Text.new(runs, context:)` with a `Context` whose `tagged` is true (`Layout::Context.new(book:, style:, tagged: true)`, which now has three fields), or an explicit `tag:`; `Layout::Box`, `Layout::Image` and `Layout::Field` take `tagged: false` or `tag: nil` for a node that carries none. Up to 3.8% fewer objects (the 1,500-row table 1,672,825 from 1,738,894).
28
+ - A colour is parsed once per String and writes its operators once: `Color.parse("#000000")` hands over the Color it made the first time, where every fill, stroke and run of text drawn parsed its colour again (a `String#scan` and an Array of pairs, eleven objects); up to `Color::MEMO` (256) colours are remembered, and past that the memo starts over, as the fonts' memos do. A Color, which is frozen, writes its `fill` and `stroke` operators when it is made and hands over the same frozen String each time. Colours given as Arrays are made as before. 1.4% to 19% fewer objects (the 1,500-row table 1,355,895 from 1,672,825).
29
+ - A long table no longer holds a node per cell before its first page (#155). A cell measures its natural and minimum widths from one text node and lets go of it, keeping the two numbers; the node is built again when a page reaches the cell's row and goes with the page. Text of one run that sets as one line (words between single spaces, nothing a line breaks at or trims) is measured as its line would be, without wrapping it (`Layout::Text#natural_width`). The cells of a table share its options and their padding until a selection changes a cell's (`Table::Cell#[]=` copies them then), a table without rowspans resolves its columns and finds its cells without placing every cell on a grid (`Table::Widths.per_column`), and in a tagged render a row's `TR`, `TH` and `TD` are built when a page paints or cuts it (`Table#tag_row`), where every cell was tagged when the table was built (`Table::Cell#tag` is nil until then). A table of 33,000 rows peaks at 174 MB where it peaked at 445 MB, one of 165,000 rows at 584 MB where it did at 1,948 MB (what is alive when its columns are resolved: 105 MB from 913 MB); a tagged one still holds its structure tree until the file is written. The column widths are the same numbers, the structure tree is the same, and every document is byte for byte what it was (the examples, the fourteen of the metrics gate, and tagged and PDF/UA renders of tables, veraPDF passing `ua1`); they allocate up to 6.6% less (the 1,500-row table 1,276,414 objects from 1,355,895), so the baseline of the metrics gate is recorded again.
30
+ - A table reads its rows from an Enumerator as pages reach them (#187). `table(Order.find_each.lazy.map { … }, widths: [80, 0.5, 90])`, an Enumerator lazy or not given to a table whose every column has a width, reads no row when the document is built and holds the rows of the page being filled and the one after them: a price list of a million rows with `incremental: true` peaks at 130 MB, where the same rows as an Array peak at 2,758 MB (81 MB and 485 MB for 100,000 rows; about 1% more objects allocated). Header rows, `zebra`, `split_rows`, cells spanning columns and selections by index from the start are applied to each row as it is read (`Layout::Table::Stream`); a row selected from the end (`t.row(-1)`) raises `ArgumentError`, and so do a cell spanning rows and a row with more columns than widths when they are read. A table in a `row`, in a box with `break_inside: :avoid` or beside a float is measured whole, reading every row. A table whose every column has a width no longer measures its cells' natural and minimum widths (17% fewer objects for the 1,500-row table, the same bytes), and `zebra` makes one selection of its rows where it made one per row, each listing every row of the table: 100,000 rows took minutes. An Enumerator with a column without a width, and an Array, are read when the table is built, as before (a lazy one raised `NoMethodError`, and is read now). The price list example reads its articles this way, its Description column 217 points wide.
31
+ - A way for an agent to see what it rendered (#157). `stationery render invoice.rb --png` writes a picture of each page beside the PDF with `to_png`, in Ruby with nothing to install (`invoice-1.png`, `invoice-2.png`, …), and prints one line per file with its page and size in pixels; `--dpi` (96 by default: an A4 page is 794 × 1123 px), `--pages 1,3-4`, and `--png-only` for the pictures without the PDF. `stationery inspect invoice.pdf` (or `invoice.rb`, rendered first, with the warnings of the render) prints what is on each page as text, for an agent that cannot read a picture and for a diff between two renders: metadata, conformance claims, print hints, attachments, signatures, the outline, then per page its size, its text lines with x, baseline y, font and size, its images, links and form fields with their rectangles in points from the top-left corner, then the structure tree; `--json` prints the same as JSON. `Stationery::Testing::Inspector#layout` answers the same data to a spec, in a stable order and without the dates, so two renders of one document compare equal. It needs `pdf-reader`, as the testing helpers do. What a render writes is unchanged. `Stationery::CLI::Inspect`, `CLI::Pictures`, `CLI::LayoutText`, `Testing::Layout`, `Testing::PageReader`.
32
+ - The examples ship with the gem and show their code (#157). `examples/` is in the gem with the images the examples read, `examples/shaping/`, and without any rendered PDF, so an application that has only the gem can read and run them: `stationery examples` lists them with the first sentence of their header comment, `stationery examples invoice` prints the path of one and `--source` its code, and `ls "$(bundle show stationery)/examples"` or `gem contents stationery` find them without the command. Inter stays the one font the gem ships: the invoice examples are set in the Open Sans of the test suite in the repository, and in Inter where only the gem is. The gem grows by about 0.1 MB. On the docs site, the [Examples](https://stationery.zoolutions.llc/docs/examples) page has the whole source of each example folded under its preview, read from the file when the page is rendered, and the Markdown twin of the page (`/docs/examples.md`, `/llms-full.txt` and `get_page` over MCP) carries the sources in full. The header comment of `examples/invoice.rb` says what it shows; the examples render byte for byte what they did. `Stationery::CLI::Examples`.
33
+ - More examples, one per thing people build (#157), each with a preview on the docs site, its source under it and an integration spec: `price_list.rb` (2,000 rows from an `Enumerator`, a repeating header, `incremental`), `contract.rb` (numbered clauses with `keep_with_next`, a text field for both parties' initials in the footer of every page, signature fields that `to_pdf(sign: { field: })` signs), `certificate.rb` (landscape, a frame drawn on a `canvas` in a background page template, centred type, signature fields), `resume.rb` (columns of 30 % and the rest, links, the body as `html`), `menu.rb` (`columns`, two floats, headings in Inter's bold), `receipt.rb` (a till receipt on an 80 mm roll declared `monochrome dpi: 203, snap: true`: a logo cut to one bit, VAT by rate, a QR code from `barcode type: :qr` and a cut line drawn on a `canvas`), `accessible_report.rb` (PDF/UA-1 with headings, figures with `alt:` and a table with header cells; `rake verify:conformance` validates it as PDF/UA-1 and PDF/A-3b) and `multilingual_notice.rb` (English, German and Swedish, each hyphenated by its own patterns, and a Japanese paragraph from a fallback font when `CJK_FONT` names one). `examples/shaping/rtl_letter.rb` is a letter in Arabic through the shaper hook; it needs harfbuzz-ruby and an Arabic font (`ARABIC_FONT`) and, like the rest of `examples/shaping/`, is left out of `rake examples`, the docs site and CI. The HarfBuzz example shaper now takes the base direction of a stretch from its first letter, not its first digit, so a line of Arabic that starts with a number stays right to left. The shipping label and the packing slip name no real carrier.
34
+ - A skill for coding agents ships with the gem (#157). `stationery skill install` writes it where an agent finds it: `~/.claude/skills/stationery/` for Claude Code, `~/.codex/skills/` for Codex and `~/.agents/skills/` for OpenCode and the others that read it, to each whose directory is there (Claude Code when none is), or one named with `--target claude|codex|agents|all`; `--project` writes under the current directory, `--dir` into any skills directory. `stationery skill status` says per agent whether it is current, outdated (written for an older version of the gem) or missing (and where else to look when it finds none), and `stationery skill print` writes it as one Markdown document for an agent that takes a file. A `SKILL.md` that the gem did not write is kept unless `--force`. The skill is a short `SKILL.md` (the model in a page, the rules that surprise, how to render a document to pictures and look at them before calling it done, the warnings, `strict` and the matchers, conformance, labels and printing, where to read more: the docs by topic, the MCP endpoint, the examples) with `reference/*.md` beside it, cut from the sections of the README, and `recipes/*.md` for an invoice (PDF/A-3b), a report with contents, page numbers and a chart, a form, a flyer with photographs, a shipping label with barcodes and ZPL, and a receipt, each a document that renders and names the example that shows more. It is written from the README and the examples' header comments when it is installed, with the gem's version in its front matter, and the suite fails when a README section it embeds is renamed, when it names a method, command, option, example or docs page that does not exist, or when a recipe's document does not render without a warning. The gem is unchanged otherwise. `Stationery::Skill`, `Stationery::CLI::Skill`, `Stationery::Examples` (what `stationery examples` and the skill read the examples with).
35
+ - The docs over MCP (#157). The docs site serves its pages to an agent over the Model Context Protocol at `https://stationery.zoolutions.llc/mcp` (docs-kit's read-only server: `gem "mcp"` and the two routes in `docs/`): `search_docs` finds sections, `get_page` reads a page as its Markdown twin and `list_pages` names them all, stateless JSON-RPC over `POST`, no key. `claude mcp add --transport http stationery https://stationery.zoolutions.llc/mcp` adds it to Claude Code, and any other client takes the same URL; the README and [Getting started](https://stationery.zoolutions.llc/docs/getting-started) say so, and `/llms.txt` ends with an `## MCP` section that names the endpoint. The gem is unchanged.
36
+ - Shaped right-to-left text: what extractors read is measured, and what the gem writes stays (#151). Seven texts in Arabic and Hebrew were written three ways (the stretch in a `Span` with its text in logical order as `ActualText`, which is what ships; a `Span` per cluster in visual order; no `Span` where the ToUnicode map is enough) and read with the `Inspector`, PDFium, PDFKit, pdf.js, poppler and MuPDF. None is read as written by all: PDFium and the `Inspector` take `ActualText` as it is, poppler and MuPDF reverse it, and without it PDFium returned the words of a line in reverse order. pdf.js and PDFKit read the ToUnicode map, which cannot give the text of a font that draws a letter as several glyphs (Noto Sans, Naskh and Kufi Arabic). veraPDF passes all three as PDF/UA-1. The table is in the README under "Complex scripts: the shaper hook", and `examples/shaping/extraction_matrix.rb` makes it again (HarfBuzz, the extractors and the fonts are installed apart from the gem). The gem and the PDFs it writes are unchanged.
37
+ - The metrics gate holds what 0.11 added (#156): `bundle exec rake metrics` renders seven more documents (`article` for floats, `newsletter` for `columns`, `webp` for a lossless WebP decoded in the render that is measured, `html` with a stylesheet, `pdf_ua` under `conformance :pdf_ua1`, `text_incremental` with a footer and `incremental`, `text_shaped` through a shaper written in Ruby), fourteen in all, so a change that makes any of them allocate more than 3%, write more than 1% more bytes or paginate differently fails CI. The seven documents it held are recorded as they were; the gate takes 4.2 s where it took 3.0. The baseline is recorded again for this release: 7 to 48 objects fewer for four documents, pages and bytes unchanged (0.12.0 is as long a version string as 0.11.1, so the bump moves no byte).
38
+ - `AGENTS.md` at the root of the repository (#157): what a contributor, human or not, has to know and cannot read off the code. A spec first and the suite by its exit status, what the metrics gate holds and when its baseline is recorded, the canvas interface, which validator checks which claim, how the changelog is written, that releases are cut with `bin/release`, and what is out of scope by design. The gem is unchanged.
39
+ - `rake verify:conformance` and `verify:timestamp` run a `verapdf` on the PATH when there is one (`brew install verapdf`), and Docker's `verapdf/cli` otherwise, as before; `VERAPDF_IMAGE` still chooses the image and forces Docker. The gem is unchanged.
40
+ - **Behaviour changes:** `page` checks `size:` and `margin:` when the class is defined and raises `ArgumentError` naming what it takes: an unknown name raised when the document was rendered, and `page size: ["102mm", "74mm"]` or `margin: "3mm"` raised `NoMethodError` or `ArgumentError: not a box` from inside the layout. A size in points is two positive, finite numbers, so `page size: [0, 100]` is refused too, and a margin's sides are finite numbers (negative ones are taken as before). The message for an unknown size names the new sizes and forms. Components and documents have the private methods `mm`, `cm`, `inch` and `pt`, and `print` in a class body declares print hints where it called `Kernel#print` (methods of a document still call `Kernel#print`). A render with `conformance :pdf_ua1` and a link in the header row a table repeats or a `canvas.link` without a `tag:` now raises `ConformanceError` (it wrote a file veraPDF rejected), and a tagged render with one has a `Warnings::UntaggedLink` more, so with `strict` it raises `WarningsError`; a link in a header, a footer or a page template is tagged, so the structure tree of a tagged document with one has a `Link` element more per link and page, and such a document under PDF/UA-1 now passes. `Warnings::MissingGlyph` has a fourth member, `stand_in` (nil unless `missing_glyphs: :replace` drew one), which its `to_h` and pattern matching show. `stationery render --zpl` exits 1 with the warnings when `to_zpl` raises `WarningsError` (a `max_pages` limit, or a `strict` document), where a strict document printed a backtrace. A document whose floats ran over the bottom of a page paginates differently: the float that did not fit, and what was written after it, are on the next page, and the `Overflow` is no longer reported. So does one where a block ran over the page below floats at its top. Floats that fit the page they are on, and what is beside them, stay where they were. Beside a float, a box with a background, a border, a shadow or a link is laid out at the full width with its lines around the float, and the float is painted over its background, where it was a block of the width left beside it and the float lay under nothing; a document with floats and such boxes may break its pages elsewhere, and in a tagged one a floated box without a `role:` beside such a box is read inside a `Div`. The part of a block cut at a page break beside a float is placed by the slot of its whole, below the float where the whole went below it, where it was drawn beside the float narrower and taller than it was cut for. A word broken between characters whose piece fits by its letters but not as drawn (a ligature or a kerning pair wider than its letters) breaks a character earlier, and one whose piece fits as drawn but not by its letters is no longer broken there. A word whose hyphenated head fits by its letters but not as drawn is hyphenated at the point before, or moves to the next line. A PNG of 1, 2 or 4 bits a sample decoded after one of another depth in the same process is decoded at its own depth, so a PDF that embedded it wrongly (a transparent palette PNG, a resampled or a monochrome image) changes. An Enumerator given to a table with a width for every column was read whole when the table was built; it is now read as pages reach it, a row selected from its end raises `ArgumentError`, and so do a cell spanning rows and a row with more columns than widths; an `Enumerator::Lazy` raised `NoMethodError` and is read now. A layout node built by hand with a `Layout::Context` that is not tagged carries no structure element (`Layout::Context.new` has a third field, `tagged`), and `Table::Cell#tag` is nil until a page paints or cuts the cell's row. The gem ships `examples/` (about 0.1 MB), and the HarfBuzz example shaper takes the direction of a stretch from its first letter, not its first digit. Documents that use none of these are byte for byte what they were in 0.11.1.
41
+
3
42
  ## 0.11.1 (2026-09-28)
4
43
 
5
44
  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.
@@ -109,74 +148,6 @@ Interactive forms and tagged (accessible) PDF.
109
148
  - Testing tagged PDFs: `Inspector#structure` reads the structure tree as nested arrays with each element's text taken from its marked content (`[[:Document, [[:H1, "Intro"], [:P, ["Read", [:Link, "the docs"], "now"]]]]]`), `Inspector#tagged?` and `#untagged_text`; matchers `have_structure` and `have_tagged_content`, assertions `assert_pdf_structure` and `assert_tagged_content`. `examples/report.rb` is tagged, with headings and a decorative icon. Empty table cells stay in the tree.
110
149
  - Interactive forms (AcroForm): `text_field` (multiline, `max_length:`, `comb:`, `read_only:`, `required:`) and `checkbox` (with `label:`) lay out like boxes or sit at `at: [x, y]`, also inside table cells. Each widget ships its own appearance stream (Helvetica / ZapfDingbats from the standard 14, listed in the AcroForm `/DR`), so forms render in every viewer; `NeedAppearances` is set so edits redraw. Dotted names (`"address.city"`) build parent fields; widgets sharing a name become one field's kids. Values are Unicode text strings and survive encryption. `Document#fields` returns `{ name => value }` after a render; `Canvas#widget` places a field widget; `to_pdf(debug: [:field])` outlines fields.
111
150
  - Forms: `radio` groups (radios sharing a name form one `/Btn` field with the radio flag; `/V` is the checked value), `select` combo boxes (`/Ch` with `/Opt`, `editable:` adds the Edit flag) and `signature_field` (an empty `/Sig` field drawn as a rule over its label). `examples/form.rb` is a one-page application form using every field type.
112
- - SVG: `linearGradient` and `radialGradient` fills (`fill="url(#id)"`, also in `style`), with stops, `href`/`xlink:href` inheritance, `objectBoundingBox` and `userSpaceOnUse` units and `gradientTransform`, drawn as PDF axial/radial shadings clipped to the shape. Approximations: `reflect`/`repeat` spreads are drawn as `pad` (reported in `unsupported`), stop opacity is the first stop's for the whole gradient, and a gradient stroke is drawn in the gradient's middle colour. A reference to a missing gradient paints its fallback colour or nothing and is reported as `url(#id)`. `Canvas#shade` paints a shading dictionary inside a path.
113
- - SVG: `text` and `tspan` (`x`/`y`/`dx`/`dy`, first value each; `font-family`, first family the font book knows — registered, bundled or an installed pack — else the document's default; `font-size`, `font-weight`, `font-style`, `text-anchor`, `fill`, `opacity`, transforms), drawn through the document's fonts so they subset and extract like any text. Whitespace collapses as in browsers. Glyphs stay upright: a transform moves the baseline origin and scales the size uniformly, so rotated or skewed text is approximated; `dominant-baseline` is ignored and a gradient fill uses its middle colour. `Layout::Svg` takes `context:`, `SVG::Document#draw` takes `book:` and `family:`, and `FontBook#known?` tells whether a family resolves without substitution.
114
- - SVG: `<style>` stylesheets (plain or CDATA) as Illustrator and Inkscape export them. Selectors: element, `.class` (`.a.b`), `#id`, `element.class`, comma lists and `*`; rules with combinators (`g path`, `a > b`, …) are ignored and reported once as `style selectors: …`. Cascade: presentation attributes, then rules by specificity (id > class > element, later wins on ties), then inline `style`. Rules style shapes, text (`font-*`) and gradient stops (`stop-color`, `stop-opacity`); `display: none` skips an element and its children, `visibility: hidden` skips it (a `visible` child draws). `class` takes several classes.
115
- - Encryption with the standard security handler: `to_pdf(encrypt: { owner_password:, user_password:, permissions:, algorithm: })` or `encrypt …` at class level (`to_pdf(encrypt: nil)` opts out). AES-256 (R6, default), AES-128 (R4) and RC4-128 (R3); every string and stream, document info included, is encrypted.
116
- - Standard ligatures from the font's GSUB `liga` feature (LigatureSubst lookups, also behind Extension lookups), **on by default**: "office" in Open Sans draws the ffi ligature, so widths of affected words change slightly. `ligatures: false` on `text`/`text_style` or in `default_text` opts out; any `letter_spacing` turns them off. Text extraction and copy still yield the source characters (ToUnicode maps a ligature glyph to all of them). Fonts without `liga` ligatures, such as the bundled Inter, are unaffected.
117
- - TrueType collections (`.ttc`): `font_family "Brand", regular: "Brand.ttc#0", bold: "Brand.ttc#2"` picks a face by a `#N` suffix (face 0 without one); each face is parsed, cached, subset and embedded on its own. `TrueType.new(data, index:)`, `TrueType.collection?` and `TrueType.faces`; an index out of range raises `ArgumentError` naming the face count.
118
- - WOFF 1.0 web fonts (`.woff`): `font_family "Web", regular: "Brand.woff"`. Tables are inflated with zlib into an in-memory sfnt (`Fonts::WOFF.unpack`), then measured, subset and embedded like the `.ttf`. WOFF2 is still rejected: it needs Brotli, so convert to `.ttf` or `.woff`.
119
- - `box(min_height:)` and `column(min_height:)`: a height floor that, unlike `height:`, still splits across pages. The first fragment keeps as much of the floor as the page holds and the next carries the rest, so a row of equal-height cards or an empty signature area can span a page break. Passing both `height:` and `min_height:` raises `ArgumentError`.
120
- - **Behaviour change:** boxes taller than a page now continue on the next page instead of overflowing; pass `break_inside: :avoid` for the old behaviour. A box that fits on a page still moves there whole; `break_inside: :auto` splits it at any page break. `box(decoration: :slice | :clone)` chooses whether padding repeats at a cut; borders are left open and rounded corners cut square.
121
- - Pair kerning from the font's `kern` table, **on by default**: measured widths shift slightly (kerned lines only get narrower), so wrapping and right-aligned positions can move by a fraction of a point. `kerning: false` on `text`/`text_style` or in `default_text` restores unkerned output. Pairs across a style or font change are not kerned.
122
- - 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.
123
- - Font packs: `stationery fonts list` and `stationery fonts install noto_sans liberation_serif [--into DIR] [--force] [--from PATH]` copy pinned, SHA-256-verified Noto Sans/Serif/Sans Mono, Liberation Sans/Serif/Mono and Inter files (plus their OFL license) into `vendor/fonts/<pack>/`, atomically and offline-capable. `font_family "Noto Sans"` with no paths finds an installed pack through `Stationery.font_paths`, as does an unregistered family name at render time. Ruby API `Stationery::Fonts.install`/`catalog`/`paths`, Rails generator `stationery:fonts`, and `rake fonts:verify`. No runtime downloads.
124
- - Per-character font fallback: `font_fallbacks "Noto Sans Symbols", ...` (inherited by subclasses). A character missing from the text's family is drawn from the first fallback that has it, then bundled Inter; spaces, joiners and combining marks stay with their base. Glyphs no font has are reported as `MissingGlyph` warnings counted per drawn occurrence. `Font#glyph?` is memoised per character.
125
- - GPOS pair kerning: the `kern` feature's PairPos lookups (formats 1 and 2, also behind Extension lookups) take precedence over the `kern` table, so GPOS-only fonts such as Inter are kerned too.
126
- - 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.
127
- - CFF fonts are subset: undrawn glyphs become a bare `endchar` (glyph ids, charset, FDSelect, Private DICTs and Subrs kept), embedded as `FontFile3 /CIDFontType0C` with a subset tag.
128
- - `align: :justify` on `text`, `text_style` and table cells: wrapped lines are stretched to the full width by widening their spaces (kerning is kept); the last line, lines ending in a newline and lines without spaces stay left-aligned. Link areas widen with the stretched text.
129
- - `html` and `markdown` elements: ActionText/Trix HTML and CommonMark (GFM tables and strikethrough) rendered as paragraphs, headings, lists, blockquotes, code blocks, rules, tables and images with inline bold/italic/underline/strike/code/links/sub/sup. `styles:` deep-merges per-block defaults, `images:` resolves image sources (or `base_path:`), missing/remote images are skipped with a `SkippedImage` warning and never fetched, `bookmarks: true` outlines h1–h3. Parsers load on first use.
130
- - Markup decodes all 252 HTML 4 named entities (`&mdash;`, `&euro;`, `&hellip;`, …); the table loads on first use.
131
- - Internal: `Stationery::Rich` block model (paragraphs, headings, lists, quotes, code, rules, tables, images; inlines with marks) with lenient `HTML.parse` (implied end tags, browser whitespace collapsing, Trix/ActionText output) and CommonMark-subset `Markdown.parse` (GFM tables and strikethrough, reference links). Loaded on demand; `html`/`markdown` elements follow.
132
- - Internal: `Fonts::GlyphRun` carries per-glyph advance adjustments (Tj/TJ emission) for upcoming kerning and justification; output unchanged.
133
- - `ul` / `ol` / `li` lists: drawn (disc, circle, square) or text bullets (dash, any String), decimal / alpha / roman / Proc numbering, `start:` and `suffix:`, aligned bodies, nested lists with depth-cycled bullets, and items that split across pages keeping the marker with their first line.
134
- - `box(break_inside:)` and `column(break_inside:)`.
135
- - 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:)`.
136
- - `keep_with_next` now moves with a following node that avoids breaking inside and would not start on the page.
137
- - `wrap` element: children at their own widths, wrapping onto rows; splits between rows.
138
- - `box(link:)` makes the whole box clickable; `box(outset:)` bleeds its background past its edges.
139
- - `keep_with_next:` accepts a number of points of following content to keep.
140
- - `group(align:)`.
141
- - Fixed-width children of a flow (`box(width:)`) are measured, split and paginated at their own width, not the flow's.
142
- - Layout: `split` takes `fresh:` on every node; a flow passes it on to the child that starts a fresh page.
143
- - Table cells span columns and rows: `{ content:, colspan:, rowspan: }`, placed as in HTML; a table only splits between rows no rowspan crosses.
144
- - 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.
145
- - Table cells accept procs built with the DSL (`-> { image logo }`) and components.
146
- - 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).
147
- - `header` and `footer` regions that reserve page space, with `height:`, `gap:` and `on:` (`:all`, `:first`, `:rest`, `:odd`, `:even`, a page number, a range or a proc); later declarations win. A `page_template`'s `page.content_box` now excludes their space. `Page#margin_box`.
148
- - `header`/`footer` `on: :last`: the paginator checks whether the rest of the content fits above a taller last-page region, adding a page when it only fits a normal one.
149
- - Named anchors and internal links: `anchor:` on `text`, `box`, `group` and `table`, a standalone `anchor` element, and `link: "#name"` / `<a href="#name">` / `box(link: "#name")` written as PDF `/Dest` GoTo links. Unknown targets are dropped with an `UnresolvedLink` warning; duplicate names warn with `DuplicateAnchor`.
150
- - Bookmarks and document outline: `bookmark:` on `text`, `box`, `group` and `table` (a title, or `{ title:, level:, open: }`) and a standalone `bookmark` element write a nested PDF `/Outlines` tree; the document opens with the outline panel. Bookmarks in page templates are ignored.
151
- - `table_of_contents` element: one linked row per bookmark with its title indented by level, a dotted/solid leader and a right-aligned page number filled in after pagination (`levels:`, `leader:`, `indent:`, `gap:`, `number_width:`, text style). Paginates across pages.
152
- - SVG: path (every command, including arcs), rect, circle, ellipse, line, polyline, polygon and g, with fill, stroke, caps, joins, fill-rule, opacity, inline styles, transforms and `currentColor`. `svg` element.
153
- - SVG: elements that cannot be drawn (`text`, `use`, …) are listed in `SVG::Document#unsupported` and reported as an `UnsupportedSvg` warning.
154
- - Canvas: `rounded_rect` and `clip` take per-corner radii (`[top_left, top_right, bottom_right, bottom_left]`, scaled down to fit like CSS); `rounded_rect(dash:)`.
155
- - `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.
156
- - An unregistered family name draws with the first registered family (else bundled Inter) and reports an `UnknownFamily` warning once.
157
- - `Stationery::Warnings`: one collector per render, deduplicated, with `#message` on every entry (`Overflow`, `MissingGlyph`, `UnknownFamily`, `UnsupportedSvg`, `SkippedImage`, `UnresolvedLink`, `DuplicateAnchor`). `document.warnings` now includes warnings raised while page templates draw.
158
- - Strict mode: `to_pdf(strict: true)` or class-level `strict` raises `Stationery::WarningsError` when a render produced warnings.
159
- - Rails: a Railtie adds `render pdf: document` (`filename:`, `disposition:`) and `config.stationery` (`renderer`, `font_paths`); `Stationery.font_paths`. A `spec:rails` lane tests it against Rails 8 without adding a dependency.
160
- - Rails: PDF previews like ActionMailer previews. `Stationery::Preview` subclasses in `spec/pdfs/previews` or `test/pdfs/previews` render at `/rails/stationery/previews` (`?debug=1` when the document supports it); `config.stationery.preview_paths` and `show_previews` (development by default). `rake spec:rails` no longer overwrites the main suite's coverage report.
161
- - Documentation site at https://stationery.zoolutions.llc, a docs-kit app under `docs/` (not part of the gem): getting started, every element with its options, layout rules, pages and regions, links and bookmarks, fonts, images and SVG, Rails, testing, the CLI, warnings, a cookbook built from `examples/`, performance, limitations and this changelog, rendered from the README and CHANGELOG where they overlap.
162
- - `stationery` executable with a command registry; `stationery render FILE [--out PATH|-] [--class NAME] [--strict] [--debug]` renders the Document a Ruby file defines (through `self.preview` when it needs arguments) and reports pages and bytes.
163
- - `require "stationery/rspec"` matchers (`have_pdf_text`, `have_pdf_text_on_page`, `have_page_count`, `have_pdf_link`, `have_image_count`, `have_bookmark`, `have_no_warnings`) and `require "stationery/minitest"` assertions, built on `Stationery::Testing::Inspector`. Needs `pdf-reader` in the test group.
164
- - Examples: `examples/report.rb` (multi-page annual report: header/footer regions, contents, bookmarks, lists, tables, a split callout, internal links, SVG icons), `examples/letter.rb` (one-page letter with a vector letterhead) and `examples/packing_slip.rb` (landscape, 120-row table with split rows, canvas barcode), each covered by an integration spec and rendered in CI by `rake examples`.
165
- - `rake bench`: reproducible benchmarks against Prawn + prawn-table (one-page invoice, 1,500-row table) and a StackProf profile script under `benchmark/`.
166
- - 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).
167
- - `benchmark/profile.rb` profiles in wall mode by default (`MODE=cpu|object`).
168
- - Fixed: table cells restyled through a selection after the table had been measured kept their old style.
169
- - Fixed: fonts without an OS/2 v2 table (including every subset) crashed on a nil cap height.
170
- - `PDF::Writer` raises when a reserved object is never set instead of writing `null`.
171
- - Gemspec ships every file under `lib/` and `exe/` when built without git, not only `.rb`.
172
- - PDF writer with Flate streams, per-page resources and link annotations.
173
- - TrueType fonts: cmap 4/12, glyph subsetting, Type0/CIDFontType2 embedding with ToUnicode, synthetic bold and oblique.
174
- - JPEG and PNG images, including alpha soft masks and palette transparency.
175
- - Canvas: rectangles, rounded rectangles, circles, lines, Bézier paths, clipping, opacity, text runs, images, links.
176
- - Text: inline markup, Ruby run builder, wrapping, alignment, leading, truncate and shrink-to-fit.
177
- - 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.
178
- - Components with Phlex's lifecycle; documents with page settings, font families, default text, metadata and page templates.
179
- - `Stationery::Rails#send_pdf`.
180
151
 
181
152
  ## 0.3.0 (2026-09-27)
182
153