stationery 0.11.1 → 0.12.1

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 (187) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +44 -68
  3. data/README.md +737 -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 +35 -7
  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 +165 -32
  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/acro_form.rb +1 -1
  78. data/lib/stationery/forms/typeface.rb +2 -4
  79. data/lib/stationery/images/images.rb +10 -0
  80. data/lib/stationery/images/jpeg/bit_reader.rb +92 -0
  81. data/lib/stationery/images/jpeg/colors.rb +133 -0
  82. data/lib/stationery/images/jpeg/decoder.rb +253 -0
  83. data/lib/stationery/images/jpeg/huffman.rb +59 -0
  84. data/lib/stationery/images/jpeg/idct.rb +150 -0
  85. data/lib/stationery/images/jpeg/progressive.rb +128 -0
  86. data/lib/stationery/images/jpeg/reduced_idct.rb +106 -0
  87. data/lib/stationery/images/jpeg/scan.rb +116 -0
  88. data/lib/stationery/images/jpeg/upsampler.rb +81 -0
  89. data/lib/stationery/images/jpeg.rb +59 -1
  90. data/lib/stationery/images/png.rb +10 -7
  91. data/lib/stationery/images/resampled.rb +10 -2
  92. data/lib/stationery/images/webp.rb +6 -5
  93. data/lib/stationery/layout/barcode.rb +75 -0
  94. data/lib/stationery/layout/box/wrapping.rb +17 -13
  95. data/lib/stationery/layout/box.rb +3 -3
  96. data/lib/stationery/layout/field.rb +3 -2
  97. data/lib/stationery/layout/floated.rb +12 -1
  98. data/lib/stationery/layout/flow/floating.rb +23 -5
  99. data/lib/stationery/layout/flow/placement.rb +13 -10
  100. data/lib/stationery/layout/flow/splitter.rb +56 -10
  101. data/lib/stationery/layout/flow.rb +1 -0
  102. data/lib/stationery/layout/image.rb +2 -2
  103. data/lib/stationery/layout/list_item.rb +14 -5
  104. data/lib/stationery/layout/mark.rb +2 -0
  105. data/lib/stationery/layout/node.rb +16 -2
  106. data/lib/stationery/layout/opening.rb +61 -0
  107. data/lib/stationery/layout/paginator.rb +24 -5
  108. data/lib/stationery/layout/svg.rb +1 -1
  109. data/lib/stationery/layout/table/cell.rb +39 -10
  110. data/lib/stationery/layout/table/row_splitter.rb +6 -2
  111. data/lib/stationery/layout/table/selection.rb +9 -3
  112. data/lib/stationery/layout/table/stream.rb +123 -0
  113. data/lib/stationery/layout/table/widths.rb +26 -0
  114. data/lib/stationery/layout/table.rb +151 -35
  115. data/lib/stationery/layout/table_of_contents/entry.rb +3 -3
  116. data/lib/stationery/layout/table_of_contents.rb +1 -1
  117. data/lib/stationery/layout/text.rb +22 -2
  118. data/lib/stationery/minitest.rb +5 -0
  119. data/lib/stationery/monochrome/bitmap.rb +78 -0
  120. data/lib/stationery/monochrome/canvas.rb +19 -0
  121. data/lib/stationery/monochrome/dither.rb +74 -0
  122. data/lib/stationery/monochrome/grid.rb +131 -0
  123. data/lib/stationery/monochrome/painting.rb +140 -0
  124. data/lib/stationery/monochrome/rules.rb +141 -0
  125. data/lib/stationery/monochrome.rb +87 -0
  126. data/lib/stationery/page/format.rb +84 -0
  127. data/lib/stationery/page.rb +11 -7
  128. data/lib/stationery/page_templates.rb +13 -10
  129. data/lib/stationery/path.rb +138 -0
  130. data/lib/stationery/pdf/assembler.rb +17 -4
  131. data/lib/stationery/pdf/canvases.rb +51 -0
  132. data/lib/stationery/pdf/conformance.rb +40 -13
  133. data/lib/stationery/pdf/object_streams.rb +41 -0
  134. data/lib/stationery/pdf/print_hints.rb +137 -0
  135. data/lib/stationery/pdf/serializer.rb +41 -0
  136. data/lib/stationery/pdf/writer.rb +59 -5
  137. data/lib/stationery/raster/adjust.rb +67 -0
  138. data/lib/stationery/raster/canvas.rb +108 -0
  139. data/lib/stationery/raster/canvases.rb +49 -0
  140. data/lib/stationery/raster/flattener.rb +75 -0
  141. data/lib/stationery/raster/glyphs.rb +81 -0
  142. data/lib/stationery/raster/painter.rb +184 -0
  143. data/lib/stationery/raster/picture.rb +69 -0
  144. data/lib/stationery/raster/png.rb +32 -0
  145. data/lib/stationery/raster/render.rb +134 -0
  146. data/lib/stationery/raster/scanner.rb +164 -0
  147. data/lib/stationery/raster/shading.rb +77 -0
  148. data/lib/stationery/raster/spans.rb +66 -0
  149. data/lib/stationery/raster/stroker.rb +208 -0
  150. data/lib/stationery/raster/surface.rb +70 -0
  151. data/lib/stationery/raster.rb +44 -0
  152. data/lib/stationery/skill/SKILL.md.erb +244 -0
  153. data/lib/stationery/skill/recipes/flyer.md.erb +76 -0
  154. data/lib/stationery/skill/recipes/form.md.erb +78 -0
  155. data/lib/stationery/skill/recipes/invoice.md.erb +142 -0
  156. data/lib/stationery/skill/recipes/label.md.erb +116 -0
  157. data/lib/stationery/skill/recipes/receipt.md.erb +79 -0
  158. data/lib/stationery/skill/recipes/report.md.erb +113 -0
  159. data/lib/stationery/skill/reference/conformance.md.erb +45 -0
  160. data/lib/stationery/skill/reference/elements.md.erb +19 -0
  161. data/lib/stationery/skill/reference/fonts_and_images.md.erb +7 -0
  162. data/lib/stationery/skill/reference/forms.md.erb +5 -0
  163. data/lib/stationery/skill/reference/html_and_markdown.md.erb +5 -0
  164. data/lib/stationery/skill/reference/pages.md.erb +15 -0
  165. data/lib/stationery/skill/reference/printing.md.erb +33 -0
  166. data/lib/stationery/skill/reference/testing.md.erb +33 -0
  167. data/lib/stationery/skill.rb +101 -0
  168. data/lib/stationery/structure.rb +7 -6
  169. data/lib/stationery/svg/gradient.rb +12 -0
  170. data/lib/stationery/svg/painter.rb +1 -1
  171. data/lib/stationery/tagging/element.rb +3 -2
  172. data/lib/stationery/tagging/tree.rb +45 -0
  173. data/lib/stationery/testing/inspector.rb +68 -0
  174. data/lib/stationery/testing/layout.rb +166 -0
  175. data/lib/stationery/testing/matchers.rb +37 -0
  176. data/lib/stationery/testing/page_reader.rb +146 -0
  177. data/lib/stationery/text/paragraph.rb +13 -8
  178. data/lib/stationery/text/wrapper/one_word.rb +33 -0
  179. data/lib/stationery/text/wrapper.rb +62 -27
  180. data/lib/stationery/units.rb +45 -0
  181. data/lib/stationery/version.rb +1 -1
  182. data/lib/stationery/warnings.rb +57 -6
  183. data/lib/stationery/zpl/native.rb +51 -0
  184. data/lib/stationery/zpl/render.rb +56 -0
  185. data/lib/stationery/zpl.rb +88 -0
  186. data/lib/stationery.rb +48 -0
  187. metadata +116 -1
data/README.md CHANGED
@@ -7,6 +7,29 @@ TrueType and OpenType fonts, JPEG, PNG and lossless WebP images and SVG drawings
7
7
 
8
8
  **Documentation: [stationery.zoolutions.llc](https://stationery.zoolutions.llc)**
9
9
 
10
+ The same docs for an agent: over MCP (`search_docs`, `get_page`, `list_pages`), as
11
+ [`/llms.txt`](https://stationery.zoolutions.llc/llms.txt) and
12
+ [`/llms-full.txt`](https://stationery.zoolutions.llc/llms-full.txt), and every page
13
+ as Markdown at its URL with `.md`. In Claude Code:
14
+
15
+ ```sh
16
+ claude mcp add --transport http stationery https://stationery.zoolutions.llc/mcp
17
+ ```
18
+
19
+ Any other MCP client takes the same URL, `https://stationery.zoolutions.llc/mcp`,
20
+ as a remote server over HTTP: no key, read-only.
21
+
22
+ A skill for coding agents ships with the gem: how a document is built, the rules that surprise,
23
+ how to look at a render before calling it done, conformance, labels and a recipe per thing people
24
+ ask for, written from this README for the version installed:
25
+
26
+ ```sh
27
+ stationery skill install # ~/.claude/skills/stationery, and Codex's and ~/.agents' when they are there
28
+ stationery skill install --project # the project's .claude/skills/stationery, to commit with the app
29
+ stationery skill status # current, outdated or missing, by the gem's version
30
+ stationery skill print > stationery.md # one file, for an agent that takes one
31
+ ```
32
+
10
33
  - **No runtime dependencies.** Standard library only.
11
34
  - **No native extensions and no other processes.** No Prawn, no headless
12
35
  Chrome, nothing to leak or kill.
@@ -70,9 +93,26 @@ InvoicePdf.new(invoice).to_pdf # => "%PDF-1.7…" (binary String)
70
93
  InvoicePdf.new(invoice).to_pdf("a.pdf") # also writes a path or an IO
71
94
  ```
72
95
 
73
- `examples/` has a complete, runnable invoice, annual report, letter, packing slip, fillable form, postcard collage, magazine article, event flyer, two-column newsletter and Factur-X e-invoice
96
+ `examples/` has a complete, runnable invoice, annual report, letter, packing slip, shipping label, fillable form, postcard collage, magazine article, event flyer, two-column newsletter and Factur-X e-invoice,
97
+ and one per thing people build: a 2,000-row price list written `incremental`, a contract with initials
98
+ on every page and signature fields, a landscape certificate, a two-column résumé with its body in HTML,
99
+ a restaurant menu in `columns` with floats, a till receipt on an 80 mm roll, a PDF/UA-1 accessible report and a notice in English, German
100
+ and Swedish, each hyphenated in its language
74
101
  ([previews and live PDFs](https://stationery.zoolutions.llc/docs/examples)); `bundle exec rake examples`
75
102
  renders them all, or render one with `stationery render examples/report.rb`.
103
+ `examples/shaping/rtl_letter.rb`, a letter in Arabic, needs HarfBuzz and an Arabic font, which the gem
104
+ does not ship (see [Complex scripts](#complex-scripts-the-shaper-hook)).
105
+
106
+ The examples ship with the gem, with the images they read, so they are there to read and to run in an
107
+ application that has only the gem. Inter is the one font the gem ships: the invoice examples are set in
108
+ the Open Sans of the test suite in the repository and in Inter elsewhere.
109
+
110
+ ```sh
111
+ stationery examples # their names and what each shows
112
+ stationery examples invoice # the path of one; --source prints its code
113
+ stationery render "$(stationery examples invoice)" --out invoice.pdf
114
+ ls "$(bundle show stationery)/examples" # or: gem contents stationery
115
+ ```
76
116
 
77
117
  ## Elements
78
118
 
@@ -84,9 +124,10 @@ renders them all, or render one with `stationery render examples/report.rb`.
84
124
  | `box(padding:, background:, border:, radius:, width:, height:, min_height:, overflow:, at:, link:, outset:, break_inside:, decoration:, rotate:, shadow:, float:, margin:) { }` | A container. Moves to the next page whole when it fits there and continues across pages when it does not; `break_inside: :auto` splits it at any page break, `:avoid` never splits it. At a cut, `decoration: :slice` (default) drops the padding and border, `:clone` keeps the padding. `overflow: :truncate` or `:shrink_to_fit` for fixed heights; a fixed `height:` never splits. `min_height:` is a floor that still splits: the first fragment keeps as much of it as the page holds, the next carries the rest (not combinable with `height:`). `at: [x, y]` pins it to a page position. `link:` makes the whole box clickable. `outset:` bleeds the background past the box (e.g. into the page margins). `rotate: -3` turns the painted box around its centre (layout box unchanged, never splits; a `link:` keeps its unrotated rectangle). `shadow: true` or `{ offset: [0, 4], blur: 8, color:, opacity: 0.15 }` paints a soft drop shadow under it, taking no space. `overflow: :hidden` clips the content to the rounded outline. `float: :left` or `:right` with a `width:` takes it to that side, `margin:` away from the text that wraps beside it: see [Floats](#floats). |
85
125
  | `row(gap:, align:, break_inside:) { column(width:) { } }` | Columns side by side. `width:` is points, a fraction (`0.5`), `:auto` or `nil` (equal share). Splits across pages like a box, every column at once; a row with a fixed-height column never splits; columns with `min_height:` do. |
86
126
  | `columns(count:, gap:, balance:, rule:) { }` | One flow poured through `count` columns, newspaper style: column 1 top to bottom, then column 2. Breaks where a page would (between lines with `orphans:`/`widows:`, never inside `break_inside: :avoid`, `keep_with_next` honoured). `balance: true` (default) ends the columns at nearly the same height where the content ends and fills them evenly, what cannot be shared going to the earlier columns (ten lines in three columns are 4, 3 and 3); `false` fills each before the next. Continues across pages; `page_break` inside ends the page; `rule: true \| { color:, width: }` draws a line between columns. |
87
- | `table(rows, widths:, width:, header:, split_rows:, cell:) { \|t\| }` | Tables. Cells are strings, layout nodes, procs built with the DSL (`-> { image logo }`) or components. Style with `t.row(0)`, `t.rows(-1)`, `t.column(1)`, `t.columns(1..)`, chained, plus `t.zebra`. Header rows repeat after a page break. A cell may be `{ content:, colspan:, rowspan: }` plus any cell option; rows list only the cells they start, as in HTML, and pages never break through a rowspan. Spans are set in the rows, not through selections. A row taller than the page continues on the next page, cut through its cells, with the header repeated; `split_rows: true` cuts any row that reaches the page bottom instead of moving it whole. |
127
+ | `table(rows, widths:, width:, header:, split_rows:, cell:) { \|t\| }` | Tables. Cells are strings, layout nodes, procs built with the DSL (`-> { image logo }`) or components. Style with `t.row(0)`, `t.rows(-1)`, `t.column(1)`, `t.columns(1..)`, chained, plus `t.zebra`. Header rows repeat after a page break. A cell may be `{ content:, colspan:, rowspan: }` plus any cell option; rows list only the cells they start, as in HTML, and pages never break through a rowspan. Spans are set in the rows, not through selections. A row taller than the page continues on the next page, cut through its cells, with the header repeated; `split_rows: true` cuts any row that reaches the page bottom instead of moving it whole. `rows` is an Array or any Enumerable; an Enumerator (lazy or not) given a width for every column is read as pages reach it, selected from its start only: see [Large documents](#large-documents). |
88
128
  | `image(path_or_io, width:, height:, fit:, align:, radius:, rotate:, max_ppi:, downscale:, float:, margin:)` | JPEG, PNG or lossless WebP, aspect preserved. `fit: [w, h]` scales to fit inside; `fit: :cover` fills `width:` × `height:` and crops around the centre. `radius:` rounds the corners; `rotate:` turns it (degrees, clockwise) without changing the space it takes. Drawn at more than twice `max_ppi:` (300) it is reported as oversized; `downscale: true` resamples a PNG or WebP to that resolution instead. |
89
129
  | `svg(source_or_path, width:, height:, color:, align:)` | Vector icons and drawings; `currentColor` takes `color:` (or the `color` an element sets). Linear and radial gradients (`fill="url(#id)"`, `href` chains, both gradient units); `text`/`tspan` in the document's fonts; `<style>` stylesheets (element, class, id and `*` selectors); `use`, `symbol` sprites and nested `svg` viewports (`viewBox`, `preserveAspectRatio`); `clipPath` (both `clipPathUnits`). |
130
+ | `barcode(data, type:, level:, module_size:, width:, height:, color:, quiet_zone:, native:, align:, alt:)` | A barcode drawn as vector bars, encoded in Ruby: `type: :code128` (the default; printable ASCII, digit runs in code set C), `:ean13` (12 digits and the check digit, or 13 checked), `:qr` (byte mode, `level: :l, :m, :q, :h`, versions 1 to 40, UTF-8 marked with an ECI) or `:datamatrix` (ECC 200 in ASCII encodation, digit pairs in one codeword, squares from 10 × 10 to 144 × 144). `module_size:` points a module (1, or 2 for a square one) or as many as fit `width:`; a linear one is `height:` (36) tall. The quiet zone is part of its size unless `quiet_zone: false`, and it shrinks to fit the space. Monochrome renders put it on the dot grid, a whole number of dots a module. `native: true` has `to_zpl` write the printer's own command for it: see [Label printers](#label-printers-to_zpl). A figure in a tagged PDF, its `alt:` by default the kind and the data. |
90
131
  | `wrap(gap:, row_gap:, align:) { }` | Children side by side at their own widths, wrapping onto new rows (chips, tags). |
91
132
  | `stack(gap:, align:) { }` | A base with layers painted over it: ordinary children set the height, `layer` children float over them and take no space. Moves to the next page whole. |
92
133
  | `layer(top:, right:, bottom:, left:, width:, height:, **box) { }` | Inside `stack`: a box placed by insets from the stack's edges, in points or as a fraction (`0.4`, `1/3r`) of its width/height; negative insets overhang. Takes every box option (`rotate:`, `shadow:`, `radius:`, …). |
@@ -136,17 +177,20 @@ text more # around the pull quote
136
177
  - Text (paragraphs, headings, the text inside a `group` or an `html` block) wraps: every line takes
137
178
  the width left at its own top and is aligned and justified in it. A paragraph whose widest word
138
179
  does not fit beside the floats starts below them.
139
- - A list item and a `box` that paints nothing of its own wrap what they hold: they keep the full
140
- width, and their lines are narrow beside the float and wide below it. That is a box without
141
- `background:`, `border:`, `shadow:` or `link:` and without `width:`, `height:`, `rotate:`,
142
- `overflow:` or `valign:`. Its padding lies under the float, as a block's does in CSS, so beside
143
- the float its lines are the float's `margin:` away from it. A list item keeps its indent from the
144
- float, with the marker beside its first line.
145
- - Anything else (a `box` with a background, a border or a size of its own, a `row`, `columns`, `table`,
146
- `rule`, `image`, `svg`, form field) is a block:
147
- it goes beside the float in the width that is left, and keeps that width all the way down, when
148
- its own width (or the least its content takes) fits there; else it starts below the float. A
149
- `spacer` takes its height beside the float; a `page_break` ends the page and the float with it.
180
+ - A list item and a `box` that takes the width it is given wrap what they hold, as a block does
181
+ in CSS: they keep the full width, and their lines are narrow beside the float and wide below it.
182
+ A box with a `background:`, a `border:`, a `shadow:` or a `link:` paints it across the full
183
+ width, under the float: the float is painted after the boxes beside it, so it sits on top of the
184
+ background, and a float with `opacity:` or a `shadow:` shows the background through it. The
185
+ box's padding lies under the float, as a block's does in CSS, so beside the float its lines are
186
+ the float's `margin:` away from it. A list item keeps its indent from the float, with the marker
187
+ beside its first line, over the background of its body if the body has one.
188
+ - A `box` with a size or a place of its own (`width:`, `height:`, `rotate:`, `overflow:` other than
189
+ `:visible`, `valign:` other than `:top`), a `row`, `columns`, a `table`, `rule`, `image`, `svg` or
190
+ form field is a block: it goes beside the float in the width that is left, and keeps that width
191
+ all the way down, when its own width (or the least its content takes) fits there; else it starts
192
+ below the float. A `spacer` takes its height beside the float; a `page_break` ends the page and
193
+ the float with it.
150
194
  - Several floats: the next one goes beside those already there when it fits, else below them, and
151
195
  never above one written before it. Left and right floats share a line with the text between them.
152
196
  - The flow that holds a float is at least as tall as the float, so what follows a box, a column or a
@@ -155,8 +199,19 @@ text more # around the pull quote
155
199
  after it could not start beside it there, it moves to the next page with that content. A
156
200
  paragraph beside a float splits between lines as always (`orphans:`, `widows:`); the lines carried
157
201
  over are wrapped again at the full width, because the float stayed behind. So are those of a list
158
- item and of a box.
159
- - In a tagged PDF the float is where it was written: an image is a `Figure`, a box has its `role:`.
202
+ item and of a box. The part that stays on the page keeps the place its whole was given, beside
203
+ the floats or below them.
204
+ - Floats written one after the other that are taller than a page together are cut before the
205
+ first that does not fit: it starts the next page, with the floats and the text written after it,
206
+ so the page it left holds the floats above it and nothing beside them. It is the same in a box, a
207
+ list item, a column and a table cell. Only a float taller than a page by itself runs over it, and
208
+ is reported as an `Overflow`. Floats that fit a page together stay together: when one of them
209
+ does not fit what is left of the page, they all go to the next.
210
+ - What does not fit below the floats at the top of a page (a box that stays whole, a line where
211
+ none is left) goes to the next page and leaves the floats behind.
212
+ - In a tagged PDF the float is where it was written, painted after the boxes beside it or not: an
213
+ image is a `Figure`, a box has its `role:`, and a floated box without one beside a box with a
214
+ background is a `Div`, which keeps its content in place.
160
215
 
161
216
  A line beside a float is taken to be as tall as a line of the paragraph's own style when its width
162
217
  is looked up, so one much taller word may reach a little past a float's bottom edge before the text
@@ -340,7 +395,8 @@ signature_field "signature", label: "Signature of the applicant"
340
395
  in the text style around the field, in the document's own fonts: embedded, with the fallbacks
341
396
  applied per character, so a value in any script the fonts cover is drawn (`/V` holds it as
342
397
  Unicode). A character no font has is drawn as `.notdef` inside a `Span` whose `ActualText` is the
343
- character, as in any other text, so the appearance still extracts as written. Check marks and radio dots are paths. `NeedAppearances` is set too, so viewers redraw
398
+ character, as in any other text (or as the font's stand-in under `conformance` with
399
+ `missing_glyphs: :replace`), so the appearance still extracts as written. Check marks and radio dots are paths. `NeedAppearances` is set too, so viewers redraw
344
400
  edited values; ZapfDingbats is listed for the ones that redraw a button's mark, never embedded
345
401
  and never used by the appearances themselves.
346
402
  - A field that can be edited keeps printable ASCII and Latin-1 in its font beyond the value it shows
@@ -381,6 +437,22 @@ render Callout.new(color: "#F3F4F6") { text "Amount due" }
381
437
  ## Pages
382
438
 
383
439
  - `page size: :a4 | :a3 | :a5 | :letter | :legal | :tabloid | [w, h], margin:, layout: :landscape`
440
+ - Sizes are points. `mm(102)`, `cm(2)`, `inch(4)` and `pt(12)` convert to them in every component and
441
+ document (`include Stationery::Units` anywhere else), and `page` reads lengths with their unit:
442
+
443
+ ```ruby
444
+ class Label < Stationery::Document
445
+ page size: ["102mm", "74mm"], margin: "3mm" # or "102 x 74 mm", "4in x 6in", [mm(102), mm(74)]
446
+
447
+ def view_template = box(at: [mm(20), mm(45)], width: mm(60)) { text "Fragile" }
448
+ end
449
+ ```
450
+
451
+ Units are `mm`, `cm`, `in` and `pt`; decimals take a point (`"101.6mm"`). Beside the office sizes there
452
+ are `:a6`, `:a7`, `:b5`, the envelopes `:dl`, `:c5` and `:c6` (short edge first: `layout: :landscape`
453
+ is the address side) and the label stock `:label_4x6`, `:label_4x3`, `:label_4x2`, `:label_100x150`
454
+ and `:label_100x50`. A size or margin that cannot be read raises `ArgumentError` when the class is
455
+ defined.
384
456
  - `page_template { |page| … }` runs on every page after pagination with `page.number`, `page.count`,
385
457
  `page.width`, `page.height`, `page.margin` and `page.content_box`. `page_template(layer: :background)`
386
458
  paints under the content (full-bleed backgrounds).
@@ -412,13 +484,294 @@ render Callout.new(color: "#F3F4F6") { text "Amount due" }
412
484
  that starts there, with `style:` `:decimal`, `:roman`, `:roman_lower`, `:alpha`, `:alpha_lower` or
413
485
  `nil` (prefix only), `start:` and `prefix:`. `to_pdf(page_labels:)` overrides it for one render.
414
486
  - Content taller than a page is placed anyway; `document.warnings` lists every overflow.
487
+ - Content that does not fit a page continues on the next, as a letter or a report should. A label, a
488
+ receipt or a card must not: `max_pages 1` in the class body says so. A render that needs more pages
489
+ lays them all out and reports `Warnings::TooManyPages`, naming what the first page past the limit
490
+ starts with, so `strict` raises:
491
+
492
+ ```ruby
493
+ class ShippingLabel < Stationery::Document
494
+ page size: :label_4x6, margin: mm(4)
495
+ max_pages 1
496
+ end
497
+
498
+ label.warnings.map(&:message)
499
+ # => ["the document may have 1 page and needs 2: page 2 starts with a Code 128 barcode"]
500
+ ```
501
+
502
+ Text is named by its first line, quoted and cut at 40 characters, anything else by its kind (`a
503
+ table`, `an image`, `a QR code`, `a box`). `to_pdf` and `to_png` keep every page, so the one that
504
+ spilled can be looked at; `to_zpl` writes no label at all (see Label printers below). `to_pdf(max_pages:)`,
505
+ `to_png(max_pages:)` and `to_zpl(max_pages:)` replace the limit for one render and `nil` takes it
506
+ away, as `max_pages nil` does in a subclass; a subclass's `page` keeps it. The declaration writes
507
+ nothing: a document that fits is byte for byte what it is without it.
415
508
  - After `to_pdf`, `document.warnings` is an Enumerable of everything the render noticed but did not
416
509
  raise on, each with a `#message`: overflows, SVG elements that were skipped (`UnsupportedSvg`), and
417
510
  the other `Stationery::Warnings::*` kinds (missing glyphs, unknown font families, skipped images,
418
- unresolved links, duplicate anchors). Equal warnings are listed once; warnings from page templates
511
+ unresolved links, duplicate anchors, in a tagged render what is missing for accessibility, and in a
512
+ [monochrome](#monochrome) render the colours and lines a one-bit printer cannot print as they are).
513
+ Equal warnings are listed once; warnings from page templates
419
514
  are included. `to_pdf(strict: true)`, or `strict` at class level, raises `Stationery::WarningsError`
420
515
  (with `#warnings`) instead of writing a PDF that produced any; `to_pdf(strict: false)` opts one
421
516
  render out again.
517
+ - After a render, `document.page_count` is how many pages it laid out: a tagged file's page
518
+ dictionaries are packed into object streams, where a search of its bytes does not find them.
519
+
520
+ ### Printing
521
+
522
+ A PDF can say how it wants to be printed. For a page that is the medium itself (a label, a card, a
523
+ pre-printed form) the defaults of a print dialog are the wrong ones: "fit to page" prints a
524
+ 100 × 60 mm label at 94 %.
525
+
526
+ ```ruby
527
+ class ShelfLabel < Stationery::Document
528
+ page size: :label_100x50, margin: "3mm"
529
+ print scaling: :none, copies: 2, pick_tray_by_size: true, duplex: :simplex
530
+ end
531
+
532
+ ShelfLabel.new.to_pdf(print: { copies: 1 }) # laid over the hints of the class
533
+ ShelfLabel.new.to_pdf(print: { duplex: nil }) # without one of them
534
+ ShelfLabel.new.to_pdf(print: nil) # without any (false does the same)
535
+ ```
536
+
537
+ | Option | Values | Written as |
538
+ | --- | --- | --- |
539
+ | `scaling:` | `:none`, `:default` | `/PrintScaling` `/None`, `/AppDefault` |
540
+ | `copies:` | an Integer of 1 or more | `/NumCopies` |
541
+ | `pick_tray_by_size:` | `true`, `false` | `/PickTrayByPDFSize` |
542
+ | `duplex:` | `:simplex`, `:long_edge`, `:short_edge` | `/Duplex` `/Simplex`, `/DuplexFlipLongEdge`, `/DuplexFlipShortEdge` |
543
+ | `pages:` | a Range of page numbers from 1, or a list of them: `1..3`, `[1..1, 3..4]`, `2..` | `/PrintPageRange` |
544
+ | `dialog:` | `:on_open` | `/OpenAction << /S /Named /N /Print >>` |
545
+
546
+ - The first five are entries of the catalog's `/ViewerPreferences` (ISO 32000-1, 12.2, table 150),
547
+ beside the `/DisplayDocTitle` of a tagged document. `dialog: :on_open` asks the viewer to open its
548
+ print dialog when the file is opened, as `window.print()` does for a page, without JavaScript.
549
+ - `print` at class level adds to what the class inherits, and `to_pdf(print:)` to what the class
550
+ declares. A `nil` takes a hint away (`print copies: nil` in a subclass).
551
+ - Every option is checked where it is written, the class body or the call of `to_pdf`, and raises
552
+ `ArgumentError` naming what it takes: `print scaling: is :none or :default, not :fit`. Ranges that
553
+ overlap are refused.
554
+ - `pages:` counts from 1, as the file does, and is written in page order. How many pages there are is
555
+ known when the document is rendered: a range is cut at the last page (`2..` and `2..99` both end
556
+ there) and one that starts after it is left out, because a viewer drops a range that names a page
557
+ the document does not have.
558
+ - `/ViewerPreferences` is allowed under PDF/A and PDF/UA. The print dialog is not an action PDF/A
559
+ has: `dialog: :on_open` with `conformance :pdf_a2b` or `:pdf_a3b` raises
560
+ `Stationery::ConformanceError` (ISO 19005-2/3, 6.5.1; veraPDF rule 6.5.1-2). PDF/UA-1 alone takes it.
561
+ - The hints are written by every render: `incremental:`, signed and encrypted ones too.
562
+ - `print` in a class body is this declaration. Inside `view_template` and every other method of a
563
+ document it is still `Kernel#print`.
564
+ - `Inspector#print_preferences` reads them back as `print` takes them, `pages` as a list of ranges,
565
+ and `have_print_preference(scaling: :none)` and `assert_print_preference` assert them.
566
+
567
+ They are hints: a viewer follows the ones it knows, and whoever prints can overrule them in the
568
+ dialog. The table says what each viewer does and how that is known. Nothing in it was tried in a
569
+ running viewer: "documented" is what the vendor's PDF reference says, "source" what the viewer's
570
+ source code did when it was read on 2026-09-28, "reported" what a user wrote in the issue named.
571
+
572
+ | Viewer | `scaling:` | `copies:` | `duplex:` | `pages:` | `pick_tray_by_size:` | `dialog:` |
573
+ | --- | --- | --- | --- | --- | --- | --- |
574
+ | Acrobat, Acrobat Reader | sets the dialog (documented) | sets the dialog, 2 to 5 (documented) | sets the dialog (documented) | sets the dialog (documented) | sets the dialog (documented) | opens the dialog (reported, [pdf.js 11442](https://github.com/mozilla/pdf.js/issues/11442)) |
575
+ | Chrome, Edge (PDFium) | preset of the print preview (source) | preset of the print preview (source) | preset of the print preview (source) | not read (source) | not read (source) | starts printing (source) |
576
+ | Firefox (pdf.js) | read, not used by the viewer (source; [bug 1243580](https://bugzilla.mozilla.org/show_bug.cgi?id=1243580)) | read, not used (source) | read, not used (source) | read, not used (source) | read, not used (source) | starts printing (source) |
577
+ | Preview (macOS) | not tested | not tested | not tested | not tested | not tested | not tested |
578
+ | Evince, Okular (poppler) | not tested | not tested | not tested | not tested | not tested | not tested |
579
+
580
+ ### Monochrome
581
+
582
+ A thermal label printer (203 or 300 dpi), a receipt printer or an e-paper display prints black or
583
+ nothing. Whatever else the PDF holds, the driver or the printer turns into dots of its own choosing:
584
+ grey text comes out speckled, a light rule or background vanishes or turns into a dot pattern, and a
585
+ line thinner than one dot prints or not depending on where it lands. `monochrome` makes those
586
+ choices where they can be seen, before anything is printed:
587
+
588
+ ```ruby
589
+ class ShelfLabel < Stationery::Document
590
+ page size: [mm(100), mm(60)], margin: mm(4)
591
+ monochrome dpi: 203 # report what a 203 dpi printer cannot print as it is
592
+ end
593
+
594
+ ShelfLabel.new.to_pdf(monochrome: { snap: true }) # change it instead, for this render
595
+ ShelfLabel.new.to_pdf(monochrome: false) # as it was, byte for byte
596
+ ```
597
+
598
+ | Option | Default | What it does |
599
+ | --- | --- | --- |
600
+ | `dpi:` | `203` | the printer's dots per inch: one dot is 72/dpi pt (0.355 pt at 203 dpi, 0.24 pt at 300) |
601
+ | `snap:` | `false` | change colours and thin lines instead of reporting them |
602
+ | `threshold:` | `0.5` | under `snap:`, a fill whose tone is darker is painted black, a lighter one is not painted |
603
+ | `dither:` | `:floyd_steinberg` | how a bitmap becomes dots: `:floyd_steinberg`, `:ordered` (an 8 × 8 Bayer pattern) or `:threshold` (cut at half) |
604
+
605
+ - **Colours.** Every colour that is not black or white, and anything painted at an `opacity:` below 1,
606
+ is a `Warnings::NotMonochrome` naming the colour and what painted it: `text`, `a rule` (a `rule`,
607
+ an underline, a strikethrough: a filled rectangle 3 pt thick or less), `a background` (any other
608
+ fill), `a border or line` (any stroke), `a gradient` or `an image`, once per colour, kind and page:
609
+ `text in #888888 on page 1 is not black or white`. `strict` raises on them. It sees whatever is
610
+ painted, by an element, an SVG or a `canvas { }` block alike.
611
+ - **`snap: true`.** Text, rules, borders and lines are painted black, except white ones, which stay
612
+ white (white text on a black box). A fill is painted black when its tone is darker than
613
+ `threshold:` and left out when it is lighter; the tone is the colour's luma (ITU-R BT.601), an
614
+ opacity laid over white paper (black at 0.3 is a tone of 0.7). A gradient is black or left out
615
+ by the average tone of its stops. Opacity is taken away. Nothing is reported.
616
+ - **Line widths.** A stroke or a rule thinner than a dot (`width × the scale of a transform`) is a
617
+ `Warnings::ThinLine` and is left as it is; with `snap: true` it is widened to a dot.
618
+ - **The dot grid.** Outside any transform, a filled rectangle (a rule, a background, an underline)
619
+ and a stroke made of horizontal and vertical lines (a border, a table's cell borders, a line) are
620
+ put on the printer's grid, counted from the top left corner of the page: their edges on whole dots
621
+ and their widths whole numbers of dots, so a hairline is as wide on every label. A curve, a rounded
622
+ corner, a slanted line and anything under a transform keeps its geometry.
623
+ - **Images.** A PNG, a lossless WebP or a JPEG is greyed (a transparent pixel is white paper),
624
+ resampled to the dots it covers at `dpi:` and dithered, and embedded as a one-bit DeviceGray image
625
+ (`/BitsPerComponent 1`): what prints is the pattern chosen here, not the driver's. A JPEG is decoded
626
+ for it in Ruby, no larger than the dots need; one of a kind that is not decoded (lossless,
627
+ arithmetic-coded, hierarchical, 12-bit) is embedded as it is and reported (`an image in JPEG 640x480`).
628
+ - `monochrome` at class level is inherited and adds to what the class inherits; `monochrome false`
629
+ takes it away. `to_pdf(monochrome:)` takes `true`, `false` or options laid over the class's.
630
+ - Monochrome renders keep PDF/A and PDF/UA: the one-bit image is DeviceGray, which the sRGB output
631
+ intent covers (checked with veraPDF, PDF/A-3b and PDF/UA-1).
632
+ - `Inspector#colors` lists what a PDF paints with, so a spec can hold it: `expect(pdf).to
633
+ have_pdf_colors("#000000")`.
634
+
635
+ What it does not do: it does not dither a lossless, arithmetic-coded, hierarchical or 12-bit JPEG, does not look at form
636
+ fields (their widget draws them, not the page), does not put dashes, line caps or curves on the grid,
637
+ and does not make small text bolder. It changes nothing without `monochrome`. The rules live in
638
+ `Stationery::Monochrome::Rules` and `Monochrome::Grid`, apart from the PDF canvas, and `to_png` and
639
+ `to_zpl` apply the same ones (see below).
640
+
641
+ ### Pictures of a render: to_png
642
+
643
+ `to_png` paints the pages `to_pdf` lays out on pixels instead, in Ruby, with nothing to install: a
644
+ preview, a picture to look at in a spec or a pull request, or what a one-bit printer will print.
645
+
646
+ ```ruby
647
+ pngs = Invoice.new(invoice).to_png # => ["\x89PNG…", …], one String per page, 96 dpi
648
+ Invoice.new(invoice).to_png("invoice.png") # one page: invoice.png; several: invoice-1.png, invoice-2.png, …
649
+ Invoice.new(invoice).to_png(dpi: 144, pages: 1) # pages: a number, a Range or an Array of them
650
+ ShelfLabel.new.to_png(monochrome: { dpi: 203, snap: true }) # one bit to a dot, 1-bit PNG
651
+ ```
652
+
653
+ - **What is drawn**: text from the outlines of its glyphs (TrueType, CFF, WOFF; synthetic bold and
654
+ oblique, letter spacing, rise, shaped runs), fills and strokes with their caps, joins (miter limit
655
+ 10) and dashes, even-odd and nonzero fills, clips, transforms and rotations, opacity, SVG linear and
656
+ radial gradients, PNG, WebP and JPEG images, headers, footers, page templates (a background layer under
657
+ the page) and page numbers. A colour picture is 24-bit RGB on white, anti-aliased; a text's origin,
658
+ a stroke's horizontal and vertical edges and an upright image's edges are put on the pixel grid as
659
+ poppler puts them, so it is close to `pdftoppm -r <dpi>` of the same PDF: the examples differ in
660
+ 0.0 to 0.5% of their pixels at 72 dpi, in the hinting of a few glyphs and the edges of images.
661
+ - **What is not**: a lossless, arithmetic-coded, hierarchical or 12-bit JPEG, which is not decoded, is drawn as a
662
+ grey box with a cross and reported (`Warnings::SkippedImage`); form fields draw nothing (their
663
+ widget is the PDF viewer's); links, bookmarks and tagging are not visible anyway.
664
+ - **Monochrome.** `monochrome:` takes what `to_pdf` takes, and the class's `monochrome` applies
665
+ unless `monochrome: false` is given. The picture is drawn at the monochrome `dpi:` (a `dpi:` given
666
+ to `to_png` replaces it), a pixel in or out by its centre with no anti-aliasing, and written as a
667
+ 1-bit PNG. The rules are those of the PDF (colours reported or snapped, thin lines, the dot grid,
668
+ images dithered at the dots they cover); a page with grey left on it (a colour reported and kept
669
+ without `snap:`) is dithered as images are. An upright image is drawn dot for dot as the one-bit
670
+ image the PDF embeds, with its corner on the nearest dot: the PDF leaves the image's box where the
671
+ layout put it, and its size in dots is the image's own, where poppler stretches it by a dot when both
672
+ edges of the box round outwards. Black and white match `pdftoppm -mono` in all but 0.1 to 2% of the
673
+ dots; dithered areas differ dot by dot, since poppler halftones where stationery dithers.
674
+ - **Options.** `dpi:` (96, or the monochrome dpi), `pages:`, `monochrome:`, `debug:` (the layout
675
+ rectangles), `strict:`, `shaper:` and `max_pages:` as `to_pdf` has them. What only a PDF has (`sign:`, `encrypt:`,
676
+ `conformance:`, `attachments:`, `print:`, `tagged:`, `page_labels:`, `xmp:`, `factur_x:`,
677
+ `incremental:`, `missing_glyphs:`, `object_streams:`) raises `ArgumentError` when it is passed, and is left alone when
678
+ the class declares it, so a signed or encrypted document still has pictures.
679
+ - **Speed.** A 100 × 150 mm label at 203 dpi (800 × 1200 dots) takes about 30 ms, a one-page invoice
680
+ at 96 dpi about 0.1 s, a page of dithered photographs at 203 dpi about 0.7 s, a label with a
681
+ 12-megapixel JPEG photo about 1 s (Ruby 3.4 with YJIT,
682
+ Apple M-series). Each page is kept as the list of what it was asked to draw until every page is
683
+ painted, then drawn and encoded one at a time, so a long document does not hold a bitmap per page.
684
+
685
+ `Stationery::Raster` has the pieces: `Raster::Canvases` and `Raster::Canvas` record, `Raster::Painter`
686
+ replays onto a `Raster::Surface` through `Raster::Scanner` (coverage), `Raster::Stroker` and
687
+ `Raster::Flattener`, and `Raster::PNG` writes the file.
688
+
689
+ ### Label printers: to_zpl
690
+
691
+ A thermal label printer (Zebra, and the many makes that emulate ZPL II) is driven in its own
692
+ language, not PDF. `to_zpl` writes the document as ZPL: each page is drawn one bit to a dot, as
693
+ `to_png(monochrome: …)` draws it, and sent as one graphic field, so the label is the layout `to_pdf`
694
+ produces, in any font the document uses. One document class serves the preview on screen and the
695
+ printer.
696
+
697
+ ```ruby
698
+ class ShippingLabel < Stationery::Document
699
+ page size: "4in x 6in", margin: mm(4)
700
+ max_pages 1 # a second page raises instead of printing a second label
701
+ monochrome dpi: 203
702
+ end
703
+
704
+ label = ShippingLabel.new(parcel)
705
+ label.to_zpl # => "^XA^PW812^LL1218^LH0,0^FO0,0^GFA,…^XZ\n", one label per page
706
+ label.to_zpl("label.zpl", dpi: 300, copies: 2)
707
+ label.to_zpl(compression: :hex, pages: 1) # plain hex, for printers that do not take Z64
708
+ ```
709
+
710
+ How the ZPL reaches the printer is the application's business: a socket to port 9100, a print
711
+ server, a browser print agent. For example, over the network:
712
+
713
+ ```ruby
714
+ require "socket"
715
+ TCPSocket.open("printer.local", 9100) { |socket| label.to_zpl(socket) }
716
+ ```
717
+
718
+ - **What is written.** Per page `^XA`, `^PW` and `^LL` (the page's width and length in dots,
719
+ `Raster.pixels(points, dpi)`), `^LH0,0`, `^FO0,0`, one `^GFA` graphic field, `^FS`, `^PQ` (copies)
720
+ and `^XZ`, then a newline. The field's dots are 1 for black, each row padded to a byte with white.
721
+ `compression: :z64` (the default) deflates the rows with zlib and writes them in Base64, followed
722
+ by the CRC Zebra's software writes (CRC-16/XMODEM of the Base64 text, checked against a ZebraDesigner
723
+ print file); `:hex` writes them as plain hex, about 20 times larger.
724
+ - **Always monochrome.** A label printer prints one bit, so `to_zpl` renders with the class's
725
+ `monochrome` settings when it declares them and with the defaults when it does not (`snap: false`,
726
+ `dither: :floyd_steinberg`): a colour is reported as `Warnings::NotMonochrome` and dithered, as
727
+ `to_png(monochrome: true)` shows it. `monochrome:` takes options laid over them (`{ snap: true }`);
728
+ `monochrome: false` raises.
729
+ - **`dpi:`** is the printer's: 152, 203, 300 or 600 (6, 8, 12 or 24 dots a millimetre); anything else
730
+ raises `ArgumentError` naming them. It defaults to the class's `monochrome dpi:`, else 203, and
731
+ replaces it when given, so `to_zpl(dpi: 300)` and `to_png(monochrome: { dpi: 300 })` are the same
732
+ dots.
733
+ - **`copies:`** is `^PQ`, by default the class's `print copies:`, else 1. **`pages:`** a page number,
734
+ a Range or an Array, in the order given; `target` a path or anything answering `write` (a socket).
735
+ - **One label.** Content that does not fit the label goes to a next page, and every page is a label.
736
+ Declare `max_pages 1` (see Pages) and a document that needs a second page writes no ZPL:
737
+ `to_zpl` raises `Stationery::WarningsError` with `Warnings::TooManyPages`, strict or not, because
738
+ the harm of a second label is done when it prints, where no one reads a warning. `to_zpl(max_pages: nil)`
739
+ writes them all; `to_pdf` and `to_png` of the same document keep every page and warn, so the page
740
+ that spilled can be looked at.
741
+ - `strict:`, `debug:`, `shaper:` and `max_pages:` are those of `to_pdf`; what only a PDF has raises, as for `to_png`.
742
+ - **Speed.** A 4 × 6 in label takes about 25 ms at 203 dpi and 35 ms at 300 dpi (Ruby 3.4, Apple
743
+ M-series), and is 10 to 20 KB of ZPL.
744
+ - The labels were read back and compared with `to_png` dot for dot, and rendered by
745
+ [Labelary](http://labelary.com/viewer.html), a ZPL viewer, identically. They were not printed on a
746
+ printer by the gem's specs.
747
+
748
+ **Barcodes the printer draws.** A `barcode` is part of the picture unless it asks otherwise:
749
+ `barcode "SX0042771903", native: true`, or `to_zpl(native: true)` for every barcode that does not
750
+ say `native: false`. Such a barcode is left out of the graphic field and written after it as the
751
+ printer's own command, where the picture had it, so the printer puts the bars on its own dot grid:
752
+
753
+ ```
754
+ ^FO146,874^BY4^BCN,124,N,N,N,N^FD>:SX>50042771903^FS Code 128, its code sets named
755
+ ^FO65,197^BY3^BEN,113,N,N^FD400638133393^FS EAN-13, the printer adds the check digit
756
+ ^FO650,625^BQN,2,4^FDMM,B0034https://track.example/SX0042771903^FS QR code, byte mode, level M
757
+ ^FO42,42^BXN,8,200,14,14,6,_,1^FDSX0042771903^FS Data Matrix (ECC 200), 14 × 14
758
+ ```
759
+
760
+ - `^BY` is the module in whole dots, the height in dots, and data ZPL would read as a command is
761
+ escaped with `^FH`. A QR code's `^FO` is 10 dots above it, since `^BQ` draws that far below its
762
+ origin. A Data Matrix is asked for at its own size, so it takes the same square.
763
+ - It stays in the picture when the printer could not draw it as it is: rotated or otherwise
764
+ transformed, clipped, lighter than half grey, a module over 10 dots, a QR code of UTF-8 text
765
+ (its ECI has no field in `^BQ`), or a Data Matrix holding `_` (the escape character `^BX` is given).
766
+ - Rendered by Labelary, the Code 128 is the same dots as `to_png` draws; the EAN-13's guard bars come
767
+ out 13 dots longer, as the printer draws them; a QR code covers the same modules' square, its
768
+ modules chosen by the printer's own encoder (another mask); a Data Matrix of digits and capitals is
769
+ the same dots, one of other text another encodation in the same square. All decode (ZBar,
770
+ dmtxread) to the data.
771
+ - Rules and boxes are not written as `^GB`: a box drawn natively would print over white text on it,
772
+ which the picture keeps.
773
+
774
+ `stationery render label.rb --zpl --dpi 300` writes `label.zpl` (see [CLI](#cli)).
422
775
 
423
776
  ### Encryption
424
777
 
@@ -498,8 +851,11 @@ and across page breaks: a paragraph continued on the next page stays one `P`.
498
851
  `BlockQuote`, and give images their `alt`. In `html`, `<img alt="">` is decoration as `alt: false`
499
852
  is (an `<img>` without the attribute is a description missing). Markdown has no way to say so:
500
853
  `![](photo.png)` is a description missing, and decoration goes through `html` or `image`.
501
- - Headers, footers and page templates are pagination artifacts; backgrounds, borders and rules drawn
502
- outside any element are layout artifacts.
854
+ - Headers, footers and page templates are pagination artifacts, but for their links: a `link:` one
855
+ paints (`text(link:)`, markup, `html`, `markdown`, `box(link:)`) is a `Link` of the `Document`
856
+ holding its text and its annotation, read after the content of its page, in the order header,
857
+ footer, page templates. The rest of the region stays an artifact. Backgrounds, borders and rules
858
+ drawn outside any element are layout artifacts.
503
859
  - `metadata lang:` writes the catalog's `/Lang`; the title is shown instead of the file name.
504
860
  - Every document carries an XMP packet (`/Metadata`, uncompressed) mirroring the Info dictionary:
505
861
  `dc:title`, `dc:creator`, `dc:description`, `dc:subject`, `dc:language`, the `xmp:` dates and
@@ -512,8 +868,22 @@ and across page breaks: a paragraph continued on the next page stays one `P`.
512
868
  (`1`, then `3` skips `2`). Going back up is free (`3`, then `1`). Headings are read in the order of
513
869
  the structure tree, inside sections, lists, table cells, columns and floats; those of headers,
514
870
  footers and page templates are artifacts and do not count.
515
- - A missing `lang` is a `Warnings::MissingLanguage`. All three are warnings, so `strict` catches them,
516
- and `conformance :pdf_ua1` raises on the first two.
871
+ - A link annotation that belongs to no `Link` element is a `Warnings::UntaggedLink` (`target`,
872
+ `place`, `page`): a link in the header row a table repeats on its next pages, which is an artifact
873
+ (`place` is `:artifact`), and `canvas.link` without a `tag:` (`:canvas` in the body, `:header`,
874
+ `:footer` or `:page_template` on the canvas of one). Every `link:` is tagged, in the body and in
875
+ headers, footers and page templates; on a canvas of the body, pass the `Link` element that holds
876
+ what the link draws: `canvas.link(x, y, w, h, url, tag: element)` after
877
+ `canvas.tag(element) { … }`.
878
+ - A missing `lang` is a `Warnings::MissingLanguage`. All four are warnings, so `strict` catches them,
879
+ and `conformance :pdf_ua1` raises on the first three.
880
+ - A tagged render packs its structure elements, and every other object that is not a stream, into
881
+ deflated object streams (`/Type /ObjStm`, 200 objects each) with a cross-reference stream in place
882
+ of the table and trailer (ISO 32000-1, 7.5.7 and 7.5.8). A table of 300 rows is 115 KB instead of
883
+ 501 KB. Streams, the `/Encrypt` dictionary and a signature dictionary stay out; PDF/A-2, PDF/A-3 and
884
+ PDF/UA-1 allow them. `object_streams false` at class level, or `to_pdf(object_streams: false)`,
885
+ writes the classic table for a reader that cannot read them; `to_pdf(object_streams: true)` packs
886
+ an untagged render too.
517
887
  - Untagged documents (the default) are written exactly as before.
518
888
 
519
889
  Check the tree in tests with `have_structure` and `have_tagged_content` (see [Testing](#testing)):
@@ -561,8 +931,10 @@ mislabelled: `ArgumentError` for options that contradict the level, `Stationery:
561
931
  every link annotation a description (`/Contents`: the URL, or the target page). A figure without
562
932
  `alt:` or with a blank one raises (7.3); mark decoration with `alt: false`, or `<img alt="">` in
563
933
  `html`. A heading level that is skipped raises (7.4.2): the first heading is `heading: 1`, and a
564
- heading is at most one level below the heading before it. PDF/A alone asks for neither.
565
- Encryption is allowed.
934
+ heading is at most one level below the heading before it. A link annotation outside the structure
935
+ tree raises (7.18.5): a link in the header row a table repeats, or `canvas.link` without a `tag:`.
936
+ Every other link is tagged, those of headers, footers and page templates too.
937
+ PDF/A alone asks for none of the three. Encryption is allowed.
566
938
  - Combined, the XMP packet also describes the `pdfuaid` schema to PDF/A (`pdfaExtension:schemas`).
567
939
  - Interactive form fields are allowed: their appearances draw with embedded fonts and paths, every
568
940
  field has a `/TU`, and `NeedAppearances` and ZapfDingbats are left out. Only a field made without
@@ -570,16 +942,52 @@ mislabelled: `ArgumentError` for options that contradict the level, `Stationery:
570
942
  standard Helvetica.
571
943
  - A character no font has raises at every level, naming the character and the family: it draws as
572
944
  `.notdef`, which text may not reference under PDF/A (6.2.11.8) or PDF/UA (7.21.8). Add a font or
573
- `font_fallbacks` that covers it. Whitespace a font lacks draws as a blank and is accepted.
945
+ `font_fallbacks` that covers it, or let the render draw a stand-in with `missing_glyphs: :replace`
946
+ (below). Whitespace a font lacks draws as a blank and is accepted.
574
947
  - Without `conformance` nothing changes: the output is byte for byte what it was.
575
948
 
949
+ A name in a script the fonts do not cover (a customer in Tokyo on an invoice archived as PDF/A) is
950
+ where the raise turns up in practice. Applications rescued it and rendered again without the claim:
951
+
952
+ ```ruby
953
+ begin
954
+ InvoicePdf.new(invoice).to_pdf
955
+ rescue Stationery::ConformanceError => e
956
+ logger.warn(e.message)
957
+ InvoicePdf.new(invoice).to_pdf(conformance: nil) # mislabelled no more, but no longer PDF/A
958
+ end
959
+ ```
960
+
961
+ `missing_glyphs: :replace` keeps the claim instead: a character no font has is drawn as the first
962
+ of U+FFFD (�), U+25A1 (□) and `?` that the font drawing it has, inside the `Span` whose `ActualText`
963
+ is the character, so the text still extracts, copies and reads aloud as written and nothing
964
+ references `.notdef`. The stand-in has its own advance, so lines are measured as they are drawn.
965
+
966
+ ```ruby
967
+ class InvoicePdf < Stationery::Document
968
+ conformance :pdf_a3b, missing_glyphs: :replace # :raise is the default
969
+ end
970
+
971
+ InvoicePdf.new(invoice).to_pdf(conformance: :pdf_a3b, missing_glyphs: :replace) # per render
972
+ ```
973
+
974
+ The `Warnings::MissingGlyph` stays (its `stand_in` is the character drawn, and `strict` still
975
+ raises on it), so what was replaced is on record. A font that has none of the three, which a
976
+ symbol font may not, still raises and says so. veraPDF passes 2b, 3b and ua1 with each of the
977
+ three stand-ins, in body text, headers and page templates, form field values and shaped text.
978
+ Without `conformance` the option changes nothing, and with `:raise` neither.
979
+
576
980
  `bundle exec rake verify:conformance` renders `examples/invoice.rb` as PDF/A-3b, and
577
- `examples/report.rb`, `examples/form.rb`, `examples/article.rb` (floats) and `examples/newsletter.rb`
578
- (columns) as PDF/A-3b plus PDF/UA-1, and validates them with
579
- [veraPDF](https://verapdf.org) through Docker (`verapdf/cli`); CI runs it on every push. Validate
580
- your own documents the same way:
981
+ `examples/report.rb`, `examples/form.rb`, `examples/article.rb` (floats), `examples/newsletter.rb`
982
+ (columns) and `examples/accessible_report.rb` (figures, a table with header cells) as PDF/A-3b plus
983
+ PDF/UA-1, the report once more with a link in its footer, and validates
984
+ them with
985
+ [veraPDF](https://verapdf.org): a `verapdf` on the PATH when there is one (`brew install verapdf`),
986
+ else through Docker (`verapdf/cli`); CI runs it on every push. Validate your own documents the same
987
+ way:
581
988
 
582
989
  ```sh
990
+ verapdf --format text -v --flavour 3b invoice.pdf
583
991
  docker run --rm -v "$PWD:/data:ro" verapdf/cli --format text -v --flavour 3b /data/invoice.pdf
584
992
  ```
585
993
 
@@ -756,9 +1164,10 @@ StatementPdf.new(account).to_pdf { |chunk| response.stream.write(chunk) }
756
1164
 
757
1165
  Peak resident memory of one render in a fresh process, and the megabytes still alive when
758
1166
  pagination ends (`bundle exec rake memory`; Apple M2 Max, Ruby 3.4.2 +YJIT, on a busy machine:
759
- two runs of the same render peak up to a fifth apart, what is alive repeats):
1167
+ two runs of the same render peak up to a fifth apart, what is alive repeats; the text documents
1168
+ were measured on 0.11.0, the tables on 0.12.0):
760
1169
 
761
- | Document | Pages | | Before | Now | `incremental: true` |
1170
+ | Document | Pages | | 0.10 | Now | `incremental: true` |
762
1171
  |---|---:|---|---:|---:|---:|
763
1172
  | Headings and paragraphs | 1,000 | peak | 391 MB | 111 MB | 108 MB |
764
1173
  | | | alive | 216 MB | 17 MB | 14 MB |
@@ -768,13 +1177,63 @@ two runs of the same render peak up to a fifth apart, what is alive repeats):
768
1177
  | | | alive | 222 MB | 44 MB | 11 MB |
769
1178
  | | 5,189 | peak | 1,643 MB | 614 MB | 446 MB |
770
1179
  | | | alive | 1,080 MB | 180 MB | 20 MB |
771
- | One table of 33,000 rows | 1,000 | peak | 901 MB | 653 MB | 648 MB |
772
- | | | alive | 494 MB | 28 MB | 25 MB |
773
- | One table of 165,000 rows | 5,000 | peak | 3,792 MB | 2,952 MB | 2,955 MB |
774
- | | | alive | 2,437 MB | 65 MB | 49 MB |
1180
+ | One table of 33,000 rows | 1,000 | peak | 901 MB | 174 MB | 174 MB |
1181
+ | | | alive | 494 MB | 26 MB | 23 MB |
1182
+ | One table of 165,000 rows | 5,000 | peak | 3,792 MB | 584 MB | 586 MB |
1183
+ | | | alive | 2,437 MB | 40 MB | 25 MB |
1184
+
1185
+ What is left is the document as it was built: every node but a table cell's exists before the
1186
+ first page is painted. A table with a column without a width resolves its column widths from every
1187
+ cell, and keeps the widths, not the cell's text: a cell's node is built when a page reaches its row and let go with the page
1188
+ (the table of 165,000 rows holds 105 MB when its columns are resolved, its cells and their
1189
+ text, where it held 913 MB). In a tagged render the `TR`, `TH` and `TD` of a row are built
1190
+ when a page paints it, and the structure tree holds them until the file is written: the
1191
+ same table tagged peaks at 2.2 GB.
1192
+
1193
+ A table whose every column has a width (`widths:` in points or fractions) measures no cell for
1194
+ its columns. Given its rows as an Enumerator, lazy or not, such a table reads them as pages
1195
+ reach them, and holds the rows of the page being filled and the one after them, none before
1196
+ its first page:
775
1197
 
776
- What is left is the document as it was built: every node exists before the first page is
777
- painted, and a table resolves its column widths from every cell.
1198
+ ```ruby
1199
+ rows = [%w[Order Customer Total]].each + Order.find_each.lazy.map { |o| [o.number, o.customer, o.total] }
1200
+ table(rows, header: true, widths: [80, 0.5, 90]) do |t|
1201
+ t.row(0).weight = :bold
1202
+ t.columns(2).align = :right
1203
+ t.zebra(from: 1, color: "#F4F4F4")
1204
+ end
1205
+ ```
1206
+
1207
+ - The header rows, `zebra`, `split_rows`, cells spanning columns and selections by index from
1208
+ the start (`t.row(0)`, `t.rows(1..)`, `t.columns(…)`) work as they do for an Array, applied to
1209
+ each row as it is read. A proc or component cell is built then, in the text style the table
1210
+ was given in.
1211
+ - What needs the end of the table raises `ArgumentError`: a row counted from the end
1212
+ (`t.row(-1)`, `t.rows(1..-2)`) when it is selected, and a cell spanning rows or a row with more
1213
+ columns than widths when it is read.
1214
+ - Anything that needs every row reads them all first: a table in a `row`, in a box with
1215
+ `break_inside: :avoid`, or beside a float is measured whole, as an Array is.
1216
+ - An Enumerator with a column without a width is read when the table is built, as before: a
1217
+ flexible column needs every row's width first. An Array is read as it always was.
1218
+
1219
+ A price list with every width given, a header row and zebra stripes, `incremental: true`
1220
+ (`PAGES=3031 DOCUMENTS=streamed,listed FORMS=incremental bundle exec rake memory`, then
1221
+ `PAGES=30303`, same machine; and the objects the render allocates):
1222
+
1223
+ | Rows | Pages | Rows given as | Peak | Alive when built | Alive when paginated | Objects |
1224
+ |---:|---:|---|---:|---:|---:|---:|
1225
+ | 100,000 | 3,031 | an Enumerator | 81 MB | 9 MB | 18 MB | 70.0 million |
1226
+ | | | an Array | 485 MB | 155 MB | 20 MB | 69.3 million |
1227
+ | 1,000,000 | 30,303 | an Enumerator | 130 MB | 9 MB | 47 MB | 710 million |
1228
+ | | | an Array | 2,758 MB | 1,474 MB | 64 MB | 702 million |
1229
+
1230
+ Streamed, the rows cost the same at any length; what grows is what the file still needs of
1231
+ every page until it is written (47 MB alive after 30,303 pages), and the peak with it. A
1232
+ streamed table allocates a little more (reading an Enumerator one row at a time costs a few
1233
+ objects a row). A tagged render keeps the `TR`,
1234
+ `TH` and `TD` of every painted row in the structure tree until the file is written, streamed or
1235
+ not: streaming leaves out only the rows no page has reached, so the same 100,000 rows tagged
1236
+ peak at 989 MB streamed and 1,230 MB as an Array.
778
1237
 
779
1238
  Controllers gain `render pdf:` and `send_pdf`:
780
1239
 
@@ -839,13 +1298,57 @@ The gem has no Rails dependency; the Railtie loads only inside a Rails app.
839
1298
  stationery render app/pdfs/invoice_pdf.rb # writes app/pdfs/invoice_pdf.pdf
840
1299
  stationery render invoice.rb --out - > invoice.pdf # PDF to stdout
841
1300
  stationery render pdfs.rb --class InvoicePdf --strict # pick one; fail on layout warnings
1301
+ stationery render invoice.rb --png # also invoice-1.png, … beside the PDF
1302
+ stationery render report.rb --png-only --pages 1,3-4 --dpi 144
1303
+ stationery render label.rb --zpl --dpi 300 # writes label.zpl for a label printer
842
1304
  ```
843
1305
 
844
1306
  `render` loads the file and renders the `Stationery::Document` it defines. A
845
1307
  document whose `initialize` needs arguments renders from `def self.preview`,
846
1308
  which returns an instance built with sample data. Layout warnings print to
847
- stderr; `--strict` exits 1 instead of writing. `stationery help` lists the
848
- commands.
1309
+ stderr; `--strict` exits 1 instead of writing. `--zpl` writes ZPL instead of a
1310
+ PDF (see [Label printers](#label-printers-to_zpl)), at `--dpi` 152, 203 (the
1311
+ default), 300 or 600. `stationery help` lists the commands.
1312
+
1313
+ `--png` writes a picture of each page beside the PDF with `to_png` (see
1314
+ [Pictures of a render](#pictures-of-a-render-to_png)), pure Ruby with nothing to
1315
+ install, named after the PDF with the number of the page, and prints one line
1316
+ per file (`wrote invoice-1.png (page 1, 794 x 1123 px)`), so an agent that
1317
+ rendered a document can open what it made. `--dpi` is 96 by default (an A4 page
1318
+ is 794 × 1123 px), `--pages` takes `2` or `1,3-4`, and `--png-only` writes the
1319
+ pictures without the PDF.
1320
+
1321
+ ```sh
1322
+ stationery inspect invoice.pdf # what is on each page, as text
1323
+ stationery inspect invoice.rb # render it first, with its warnings
1324
+ stationery inspect invoice.pdf --json # Inspector#layout as JSON
1325
+ ```
1326
+
1327
+ `inspect` prints what is on each page for an agent that cannot read a picture,
1328
+ and for a diff between two renders: the file's metadata, conformance claims,
1329
+ print hints, attachments and signatures, the outline, then each page with its
1330
+ size, its text lines (x, baseline y, font, size), images, links and form fields
1331
+ with their rectangles in points from the top-left corner, the structure tree of
1332
+ a tagged PDF and the warnings of the render. A section with nothing in it is
1333
+ left out, and the dates are, so two renders of one document print the same.
1334
+ It is `Inspector#layout` (see [Testing](#testing)) and needs the `pdf-reader`
1335
+ gem.
1336
+
1337
+ ```text
1338
+ examples/invoice.rb
1339
+ pages 1
1340
+ title Invoice
1341
+
1342
+ Page 1 595.3 x 841.9 pt
1343
+ Text (x, baseline y, font, size, text)
1344
+ 44.0 65.5 OpenSans-Bold 22 Invoice INV-2026-042
1345
+ 44.0 115.6 OpenSans-Bold 9 Invoice date
1346
+ 134.0 115.6 OpenSans-Regular 9 26 September 2026
1347
+ Images (x, y, width x height, pixels)
1348
+ 437.9 40.0 113.3 x 34 240 x 72 px
1349
+ Links (x, y, width x height, target)
1350
+ 329.2 596.5 65.6 x 11.6 mailto:hello@acme.test
1351
+ ```
849
1352
 
850
1353
  ```sh
851
1354
  stationery fonts list # packs, licenses, what is in vendor/fonts
@@ -860,6 +1363,36 @@ already present are kept unless `--force`. `--from` takes a directory or a
860
1363
  `.tar.gz` holding the same files (still SHA-checked). In Rails,
861
1364
  `bin/rails generate stationery:fonts noto_sans` does the same.
862
1365
 
1366
+ ```sh
1367
+ stationery examples # the examples in the gem, what each shows
1368
+ stationery examples invoice # the path of examples/invoice.rb there
1369
+ stationery examples invoice --source # its code
1370
+ ```
1371
+
1372
+ `examples` lists the documents under `examples/` of the installed gem with the
1373
+ first sentence of their header comment. With a name it prints the path of that
1374
+ file, to read, copy or hand to `stationery render`.
1375
+
1376
+ ```sh
1377
+ stationery skill install # the skill for the agents it finds, else Claude Code
1378
+ stationery skill install --target claude --project # into ./.claude/skills/stationery
1379
+ stationery skill install --dir ~/agent-skills # into any skills directory
1380
+ stationery skill status # current, outdated or missing, per agent
1381
+ stationery skill print # SKILL.md and its files, as one document
1382
+ ```
1383
+
1384
+ `skill install` writes the skill for coding agents that ships with the gem: a
1385
+ `SKILL.md` and the reference and recipe files beside it, written from this
1386
+ README and the examples for the version installed, with that version in its
1387
+ front matter. `--target` is `claude` (`~/.claude/skills`), `codex`
1388
+ (`~/.codex/skills`), `agents` (`~/.agents/skills`, read by OpenCode and others)
1389
+ or `all`; without it the skill goes to each of them whose directory is there,
1390
+ and to Claude Code when none is. `--project` writes under the current directory
1391
+ instead of the home directory. A skill named `stationery` that the gem did not
1392
+ write is kept unless `--force`. `skill status` says for each agent whether the
1393
+ skill there is current, outdated (written for an older version: install it
1394
+ again) or missing.
1395
+
863
1396
  ## Fonts and images
864
1397
 
865
1398
  Fonts are TrueType (`.ttf`) or OpenType/CFF (`.otf`, name-keyed or
@@ -924,7 +1457,9 @@ a figure space, a narrow no-break space, …) is drawn as a blank of the
924
1457
  character's conventional width, never as `.notdef`. Any other glyph no font
925
1458
  has is drawn as the family's `.notdef` and reported as a
926
1459
  `Warnings::MissingGlyph` counting each drawn occurrence (so `strict` raises
927
- on it, and a `conformance` level raises `ConformanceError`). The characters
1460
+ on it, and a `conformance` level raises `ConformanceError` unless it is
1461
+ declared with `missing_glyphs: :replace`, which draws the first of U+FFFD,
1462
+ U+25A1 and `?` the font has instead; see [PDF/A and PDF/UA](#pdfa-and-pdfua)). The characters
928
1463
  themselves travel as the `ActualText` of a `Span` around the glyphs, so the
929
1464
  text still extracts, copies and reads aloud as written. Fallback covers every text element,
930
1465
  table cell, list marker, table of contents entry and page template text;
@@ -987,6 +1522,17 @@ before embedding it, alpha included; a JPEG is embedded byte for byte, so
987
1522
  resize it before you embed it (an ActiveStorage variant per drawn size,
988
1523
  preprocessed, keeps a render to a download).
989
1524
 
1525
+ A JPEG's pixels are decoded in Ruby only where pixels are needed, for
1526
+ `to_png` and a `monochrome` render; a PDF embeds its bytes. Baseline,
1527
+ extended and progressive JPEGs are decoded (every chroma subsampling,
1528
+ restart intervals, grey, RGB, YCbCr, CMYK and YCCK, CMYK turned into RGB)
1529
+ to the pixels libjpeg-turbo gives them, and a photo drawn small at a half, a
1530
+ quarter or an eighth of its size (libjpeg's scaled IDCT). A lossless,
1531
+ arithmetic-coded, hierarchical or 12-bit JPEG, or one of more than 33 megapixels, is not:
1532
+ a picture draws it as a crossed box and a monochrome PDF embeds it as it is,
1533
+ each with a warning. The EXIF orientation is not applied, as the PDF does
1534
+ not apply it either.
1535
+
990
1536
  ### Complex scripts: the shaper hook
991
1537
 
992
1538
  Stationery places glyphs itself: one per character, the font's ligatures and single substitutions,
@@ -1046,7 +1592,8 @@ synthetic oblique, a superscript or letter spacing. Glyphs the shaper placed are
1046
1592
  not a character maps to them. Where the ToUnicode map cannot give the text (glyphs out of logical
1047
1593
  order, several glyphs for one cluster, a glyph that already stands for another text, glyph 0) the
1048
1594
  glyphs are shown in a `Span` whose `ActualText` is the characters in logical order. Glyph 0 is
1049
- reported as a `Warnings::MissingGlyph`, like any other.
1595
+ reported as a `Warnings::MissingGlyph`, like any other, and under `conformance` with
1596
+ `missing_glyphs: :replace` it is drawn as the font's stand-in at the stand-in's advance.
1050
1597
 
1051
1598
  The limits of a hook:
1052
1599
 
@@ -1062,18 +1609,63 @@ The limits of a hook:
1062
1609
  - Hyphenation and the breaking of a word wider than the line cut the text as written; the pieces are
1063
1610
  shaped as separate words.
1064
1611
  - Vertical advances are not read: text runs horizontally.
1065
- - A reader that takes `ActualText` for the text (the gem's `Inspector`) gets it as written. poppler
1066
- (`pdftotext` 26.09) and MuPDF (1.28) run their own reordering over it and return a right-to-left
1067
- stretch reversed. pdf-reader's own `Page#text` drops a `Span` whose first glyph has no advance (a
1068
- mark drawn first); the `Inspector` does not.
1612
+ - Extracted text depends on the reader: the gem's `Inspector` and PDFium get a right-to-left stretch
1613
+ as written, poppler and MuPDF reversed (see the table below). pdf-reader's own `Page#text` drops a
1614
+ `Span` whose first glyph has no advance (a mark drawn first); the `Inspector` does not.
1069
1615
 
1070
1616
  [`examples/shaping/harfbuzz_shaper.rb`](https://github.com/zoolutions/stationery/blob/main/examples/shaping/harfbuzz_shaper.rb)
1071
1617
  is an adapter for HarfBuzz through the [`harfbuzz-ruby`](https://github.com/ydah/harfbuzz) gem
1072
1618
  (`gem "harfbuzz-ruby"`, `require "harfbuzz"`; not the older `harfbuzz` gem, which answers to the same
1073
1619
  `require`), about a hundred lines to copy into an application; stationery does not depend on it. It
1074
- was run with harfbuzz-ruby 1.1.0 and HarfBuzz 14.5.0 against Noto Sans Arabic and draws joined,
1075
- right-to-left Arabic with its marks and with left-to-right digits inside it. It cuts a stretch into
1076
- runs of one direction by its letters alone, not by the Unicode bidirectional algorithm.
1620
+ was run with harfbuzz-ruby 1.1.0 and HarfBuzz 14.5.0 against Noto Sans Arabic, Amiri and Noto Sans
1621
+ Hebrew and draws joined, right-to-left Arabic with its marks and with left-to-right digits inside it.
1622
+ It cuts a stretch into runs of one direction by its letters alone, not by the Unicode bidirectional
1623
+ algorithm.
1624
+ The base direction of a stretch is its first letter's, so a line of an Arabic paragraph that starts
1625
+ with a number stays right to left.
1626
+ [`examples/shaping/rtl_letter.rb`](https://github.com/zoolutions/stationery/blob/main/examples/shaping/rtl_letter.rb)
1627
+ is a letter in Arabic set with it (`ARABIC_FONT=NotoNaskhArabic-Regular.ttf ruby -Ilib
1628
+ examples/shaping/rtl_letter.rb`); its booking number, in Latin letters from Inter, sits in a `row`
1629
+ beside its Arabic label, since stretches of two fonts on one line are placed left to right.
1630
+
1631
+ What comes out of a shaped PDF depends on who reads it, and no way of writing right-to-left text
1632
+ is read as written by every extractor. Seven texts (Arabic, Hebrew, Arabic with digits in it,
1633
+ Arabic with its marks, an Arabic word in a Latin sentence, a justified paragraph, a line that wraps)
1634
+ were rendered three ways and read back: **A**, what the gem writes, a `Span` around the stretch
1635
+ with its text in logical order as `ActualText` (ISO 32000-1, 14.9.4); **B**, a `Span` for every
1636
+ cluster, in visual order; **C**, no `Span` for a glyph the ToUnicode map gives its text, in visual
1637
+ order, and one per cluster for the others. The cells count the texts that came back as written:
1638
+
1639
+ | Arabic font | | `Inspector` | PDFium | PDFKit | pdf.js | poppler | MuPDF |
1640
+ |---|---|---|---|---|---|---|---|
1641
+ | Amiri (a glyph per letter) | **A** | 7 of 7 | 7 of 7 | 6 of 7 | 4 of 7 | 0 of 7 | 0 of 7 |
1642
+ | | B | 0 of 7 | 0 of 7 | 0 of 7 | 0 of 7 | 4 of 7 | 5 of 7 |
1643
+ | | C | 0 of 7 | 1 of 7 | 6 of 7 | 4 of 7 | 4 of 7 | 5 of 7 |
1644
+ | Noto Sans Arabic (a letter is its shape and its dots) | **A** | 7 of 7 | 6 of 7 | 1 of 7 | 1 of 7 | 0 of 7 | 0 of 7 |
1645
+ | | B | 0 of 7 | 0 of 7 | 0 of 7 | 0 of 7 | 1 of 7 | 1 of 7 |
1646
+ | | C | 0 of 7 | 0 of 7 | 1 of 7 | 1 of 7 | 1 of 7 | 1 of 7 |
1647
+
1648
+ - PDFium (153.0.7999, what Chrome reads with, through pypdfium2 5.13) and the gem's `Inspector`
1649
+ return `ActualText` as it is: A reads as written. Without it (C) PDFium returned the words of a
1650
+ right-to-left line in reverse order.
1651
+ - poppler (`pdftotext` 26.09) and MuPDF (`mutool` 1.28.5) reorder what they read, `ActualText`
1652
+ included: A comes back reversed, B and C in Amiri as written, but for Arabic with digits or
1653
+ marks and, in poppler, a justified paragraph.
1654
+ - pdf.js (pdfjs-dist 6.3.289) and PDFKit (macOS 27, what Preview reads with) returned the same for
1655
+ A and C, which differ in nothing but `ActualText`: they read the glyphs through the ToUnicode
1656
+ map and reorder them. A cluster of several glyphs comes back with its text once per glyph, so a
1657
+ letter with a mark is doubled, and so is every dotted letter of the Noto Arabic families (Sans,
1658
+ Naskh and Kufi draw a letter as two glyphs). Only `ActualText` carries the text of such a font.
1659
+ - The Hebrew text (Noto Sans Hebrew) is the same in both halves of the table.
1660
+ - veraPDF 1.30.2 passes all 42 renders as PDF/UA-1, so the rules of the standard do not choose.
1661
+ - Acrobat, Preview itself and screen readers were not tested.
1662
+
1663
+ The gem keeps A: it is what the specification describes, the one PDFium reads as written, and
1664
+ the only one that holds with a font whose glyphs are not letters. None of the three is read as
1665
+ written by poppler or MuPDF and by PDFium.
1666
+ [`examples/shaping/extraction_matrix.rb`](https://github.com/zoolutions/stationery/blob/main/examples/shaping/extraction_matrix.rb)
1667
+ renders the texts, holds B and C as experiments and writes the table again, with what to install
1668
+ at its head.
1077
1669
 
1078
1670
  ## Testing
1079
1671
 
@@ -1106,6 +1698,8 @@ RSpec.describe InvoicePdf do
1106
1698
  it { is_expected.to have_no_warnings }
1107
1699
  it { is_expected.to have_pdf_language("en") } # the catalog /Lang from `metadata lang:`
1108
1700
  it { is_expected.to have_page_labels(%w[i ii 1 2]) } # from `page_labels`
1701
+ it { is_expected.to have_print_preference(scaling: :none, copies: 2) } # from `print`
1702
+ it { is_expected.to have_pdf_colors("#000000") } # every colour it paints with, from `monochrome`
1109
1703
  it { is_expected.to have_attachment("factur-x.xml", mime: "text/xml", relationship: :alternative) }
1110
1704
  it { is_expected.to have_conformance(:pdf_a3b) } # the level claimed in XMP, from `conformance`
1111
1705
  it { is_expected.to have_factur_x(profile: :en16931) } # the e-invoice XML, named in XMP and embedded
@@ -1133,6 +1727,8 @@ class InvoicePdfTest < Minitest::Test
1133
1727
  assert_no_pdf_warnings pdf
1134
1728
  assert_pdf_language pdf, "en"
1135
1729
  assert_page_labels pdf, %w[i ii 1 2]
1730
+ assert_print_preference pdf, scaling: :none, copies: 2
1731
+ assert_pdf_colors pdf, ["#000000"]
1136
1732
  assert_pdf_attachment pdf, "factur-x.xml", mime: "text/xml"
1137
1733
  assert_pdf_conformance pdf, :pdf_a3b
1138
1734
  assert_factur_x pdf, profile: :en16931
@@ -1149,7 +1745,8 @@ titles. The RSpec matchers compose like the built-ins: `.and` / `.or`, and insid
1149
1745
  `all`, `include` or `match`. For anything else, `Stationery::Testing::Inspector.new(subject)`
1150
1746
  exposes `text`, `page_texts`, `page_count`, `links`, `internal_links`,
1151
1747
  `image_count`, `bookmarks`, `metadata`, `xmp` (the packet), `xmp_values` (`{ "dc:title" => …, "dc:creator" => […] }`),
1152
- `lang`, `page_labels`, `attachments`, `conformance` (`[:pdf_a3b, :pdf_ua1]`), `factur_x`
1748
+ `lang`, `page_labels`, `print_preferences` (`{ scaling: :none, pages: [1..3] }`), `colors`
1749
+ (`["#000000", "#FF0000"]`), `attachments`, `conformance` (`[:pdf_a3b, :pdf_ua1]`), `factur_x`
1153
1750
  (`{ profile:, filename:, version:, xml: }`), `signatures` (`[{ field:, name:, reason:, location:,
1154
1751
  signed_at:, subfilter:, byte_range:, signer:, valid:, timestamp: }]`, the timestamp `nil` or
1155
1752
  `{ time:, tsa:, valid: }`), `warnings`, `tagged?`,
@@ -1159,6 +1756,12 @@ read from its marked content: `[type, "text"]`, `[type, [children]]` (its own te
1159
1756
  between the children, as for a `P` holding a `Link`) or `[type]` when empty; a
1160
1757
  `Figure` reads as its alt text.
1161
1758
 
1759
+ `colors` is every colour the page content and the form XObjects it draws set with `g`, `rg` and `k`
1760
+ (and their stroking forms), once each and sorted, as `"#RRGGBB"` (CMYK converted without a profile),
1761
+ with `"shading"` for a gradient and `"image"` for a bitmap that is not one bit of grey; a one-bit grey
1762
+ image, as `monochrome` embeds, paints black and white only and adds nothing. Opacity and form
1763
+ fields are not read. `have_pdf_colors("#000000")` and `assert_pdf_colors` hold the whole list.
1764
+
1162
1765
  `text` and `page_texts` are the text of the page content as pdf-reader lays it out, line by line. A
1163
1766
  `Span` with `ActualText` (a stretch a shaper reordered, characters no font has) reads as that text,
1164
1767
  once, whatever glyphs it shows.
@@ -1173,6 +1776,29 @@ of every widget that is not hidden, in the state the widget is in, and never a b
1173
1776
  text is what it always was, and the failure of `have_pdf_text` over a text that a field shows says
1174
1777
  so.
1175
1778
 
1779
+ `Inspector#layout` is what is on each page and what the file says of itself, as plain data in a
1780
+ stable order: what `stationery inspect` prints (see [CLI](#cli)), for a spec that asks where
1781
+ something landed. Places are in points from the top-left corner of the page, as the gem's API
1782
+ speaks, rounded to a tenth; a text line's `y` is its baseline.
1783
+
1784
+ ```ruby
1785
+ layout = Stationery::Testing::Inspector.new(InvoicePdf.new(invoice)).layout
1786
+ layout[:pages].first[:text].first
1787
+ # => { x: 44.0, y: 65.5, font: "OpenSans-Bold", size: 22.0, text: "Invoice INV-2026-042" }
1788
+ layout[:pages].first.keys # => [:number, :label, :width, :height, :text, :images, :links, :fields]
1789
+ layout.keys # => [:metadata, :conformance, :tagged, :print, :outline, :attachments,
1790
+ # :signatures, :structure, :warnings, :pages]
1791
+ ```
1792
+
1793
+ A text line is a run of one font and size on one baseline, so a word set in bold is a line of its
1794
+ own; lines read top to bottom, then left to right. An image is `{ x:, y:, width:, height:, pixels:
1795
+ [w, h] }` in the order drawn (the rectangle it fills, before any clip); a link has its `uri:`, or the
1796
+ `page:` and `top:` it goes to; a field has its full `name:`, `type:` (`:text`, `:choice`,
1797
+ `:checkbox`, `:radio`, `:button`, `:signature`), `value:` and, for a button, the `state:` that turns
1798
+ it on. `outline` is the bookmarks as `{ title:, page:, top:, children: }`. `metadata` leaves out the
1799
+ dates, which change with every render, so two renders of one document have the same layout.
1800
+ `warnings` are the render's messages when the subject is a document.
1801
+
1176
1802
  ## Why not Prawn, Chrome or Typst?
1177
1803
 
1178
1804
  - **Prawn** is an imperative cursor API: every document does its own layout
@@ -1225,26 +1851,28 @@ events attached is the most useful thing to put in an issue.
1225
1851
  prawn-table, and with [sghtmltopdf](https://github.com/waka/sghtmltopdf) 0.5.1, an
1226
1852
  HTML-to-PDF engine written in Rust, given HTML/CSS twins of the same documents. Every
1227
1853
  engine embeds the same Open Sans TTF files (`benchmark/`, not part of CI). Apple M2 Max,
1228
- Ruby 3.4.2 +YJIT, 28 September 2026, warm caches, the best of three runs:
1854
+ Ruby 3.4.2 +YJIT, 29 September 2026 (0.12.0), warm caches, the best of three runs:
1229
1855
 
1230
1856
  | Document | Engine | Best render | Renders/s | Objects allocated | PDF bytes | Pages |
1231
1857
  |---|---|---:|---:|---:|---:|---:|
1232
- | Invoice (`examples/invoice.rb`) | Stationery | 13.1 ms | 70.1 | 39,562 | 26,599 | 1 |
1233
- | | Prawn | 18.3 ms | 45.2 | 88,558 | 33,034 | 1 |
1234
- | | sghtmltopdf | 3.4 ms | 259.4 | 53 | 36,693 | 1 |
1235
- | Table, 1,500 rows × 5 columns, repeating header | Stationery | 395 ms | 2.45 | 2,596,598 | 133,754 | 46 |
1236
- | | Prawn | 1,373 ms | 0.71 | 6,901,807 | 2,689,487 | 40 |
1237
- | | sghtmltopdf | 96 ms | 9.99 | 53 | 369,338 | 45 |
1238
- | Cover photo and six illustrated sections | Stationery | 9.0 ms | 99.8 | 45,866 | 41,531 | 3 |
1239
- | | Prawn | 29.7 ms | 30.9 | 102,967 | 55,391 | 2 |
1240
- | | sghtmltopdf | 6.7 ms | 136.8 | 53 | 43,031 | 2 |
1241
-
1242
- sghtmltopdf is the fastest: 3.9x Stationery on the invoice, 4.1x on the table and 1.3x on
1243
- the photo document. That is native code against Ruby, and its 53 objects are the binding's:
1244
- the engine allocates outside the Ruby heap. Stationery is 1.4x, 3.5x and 3.3x faster than
1245
- Prawn and writes the smallest files of the three (28%, 64% and 3% smaller than
1246
- sghtmltopdf's). Stationery compresses content streams; Prawn does not by default, hence its
1247
- larger files. Page counts differ where the engines' line heights and table padding do.
1858
+ | Invoice (`examples/invoice.rb`) | Stationery | 11.3 ms | 79.7 | 27,531 | 26,601 | 1 |
1859
+ | | Prawn | 17.6 ms | 49.0 | 88,558 | 33,034 | 1 |
1860
+ | | sghtmltopdf | 3.2 ms | 293.8 | 53 | 36,693 | 1 |
1861
+ | Table, 1,500 rows × 5 columns, repeating header | Stationery | 205 ms | 4.46 | 1,276,408 | 133,756 | 46 |
1862
+ | | Prawn | 1,299 ms | 0.74 | 6,901,819 | 2,689,487 | 40 |
1863
+ | | sghtmltopdf | 94 ms | 10.05 | 53 | 369,338 | 45 |
1864
+ | Cover photo and six illustrated sections | Stationery | 6.8 ms | 136.3 | 25,977 | 41,533 | 3 |
1865
+ | | Prawn | 26.9 ms | 31.0 | 102,961 | 55,391 | 2 |
1866
+ | | sghtmltopdf | 6.6 ms | 140.2 | 53 | 43,031 | 2 |
1867
+
1868
+ sghtmltopdf is the fastest on text and tables: 3.5x Stationery on the invoice and 2.2x on the
1869
+ table, and level with it on the photo document (6.6 against 6.8 ms). That is native code against
1870
+ Ruby, and its 53 objects are the binding's: the engine allocates outside the Ruby heap. Stationery
1871
+ is 1.6x, 6.3x and 4.0x faster than Prawn and writes the smallest files of the three (28%, 64%
1872
+ and 3% smaller than sghtmltopdf's). Stationery compresses content streams; Prawn does not by
1873
+ default, hence its larger files. Against 0.11.0, the same three renders allocate 30% to 51% fewer
1874
+ objects and take 14%, 48% and 24% less time. Page counts differ where the engines' line heights
1875
+ and table padding do.
1248
1876
  "Best render" is the fastest single render, the figure other work on the machine disturbs
1249
1877
  least; renders per second is the average over five seconds.
1250
1878
 
@@ -1261,14 +1889,31 @@ without its gem.
1261
1889
 
1262
1890
  A lossless WebP is decoded in Ruby when it is first loaded, which a JPEG or an opaque PNG
1263
1891
  (both passed through) never is: a 1000 × 1000 px image takes 0.2 to 0.4 s on the machine
1264
- above, by what is in it, and the image cache keeps it for the renders that follow.
1892
+ above, by what is in it, and the image cache keeps it for the renders that follow. A JPEG is
1893
+ decoded only for `to_png` or a monochrome render, once per process and scale: a 1000 × 1000
1894
+ px photo in 0.25 s, a 12-megapixel phone photo in 2.8 s at full size and 0.7 s at the quarter
1895
+ size a label or a preview needs (progressive: 3.4 s and 1.4 s; about four times as long
1896
+ without YJIT).
1265
1897
 
1266
1898
  Time depends on the machine, so CI holds what does not: `bundle exec rake metrics`
1267
- renders six fixed documents and compares the objects each render allocates, its
1899
+ renders fourteen fixed documents and compares the objects each render allocates, its
1268
1900
  page count and its bytes with `benchmark/baseline.json` (allocations may grow 3%,
1269
1901
  bytes 1%, pages not at all). A change that moves them on purpose records a new
1270
1902
  baseline with `bundle exec rake metrics:update` and says why in the commit.
1271
1903
 
1904
+ | Document | What it holds |
1905
+ |---|---|
1906
+ | `invoice`, `flyer`, `form` | The examples of those names: a table with a footer, images and drawings, form fields |
1907
+ | `table` | 1,500 rows × 5 columns with a repeating header, 46 pages |
1908
+ | `text`, `text_hyphenated`, `text_streamed` | Ten pages of headings and paragraphs: as they are, justified and hyphenated, and written to a block |
1909
+ | `article` | Floats: `examples/article.rb` |
1910
+ | `newsletter` | `columns`: `examples/newsletter.rb` |
1911
+ | `webp` | A lossless WebP of 320 × 240 px, decoded in the render that is measured |
1912
+ | `html` | `html` with a stylesheet, inline styles, a floated image, a table, lists and two columns, five pages |
1913
+ | `pdf_ua` | A tagged report under `conformance :pdf_ua1`, six pages |
1914
+ | `text_incremental` | The text document with a footer, rendered with `incremental` to a block |
1915
+ | `text_shaped` | The text document through a `shaper` written in Ruby, which answers the font's own glyphs |
1916
+
1272
1917
  `bundle exec rake memory` reports what long documents hold while they render
1273
1918
  (`PAGES=5000` for more than its 1,000 pages): the peak resident set size of a render in
1274
1919
  a fresh process, and what is alive when building, pagination and writing end. It
@@ -1283,9 +1928,11 @@ Fonts: no variable fonts (including CFF2) and no WOFF2 (it needs Brotli; convert
1283
1928
  nothing and scripts that need contextual shaping (Arabic, Indic, Thai) draw glyph by glyph unless the
1284
1929
  application brings a shaper (`shaper`, see [the shaper hook](#complex-scripts-the-shaper-hook): the gem
1285
1930
  shapes none of them itself), and colour or emoji glyphs no font
1286
- in the chain has are drawn as `.notdef` and reported. Text runs left to right: a shaper orders the glyphs
1931
+ in the chain has are drawn as `.notdef` and reported (under a conformance level they raise, or are drawn
1932
+ as a stand-in with `missing_glyphs: :replace`). Text runs left to right: a shaper orders the glyphs
1287
1933
  within a stretch of one font and style, nothing reorders stretches or lines (CJK text wraps between
1288
- ideographs, but there is no vertical layout); hyphenation
1934
+ ideographs, but there is no vertical layout), and poppler and MuPDF extract shaped right-to-left text
1935
+ reversed (see [the matrix](#complex-scripts-the-shaper-hook)); hyphenation
1289
1936
  patterns are bundled for English, German and Swedish only (a soft hyphen works in any language),
1290
1937
  and justification only widens spaces.
1291
1938
 
@@ -1295,7 +1942,7 @@ and reported, text inside a `clipPath` does not clip, and the shapes of a clip p
1295
1942
  path, so overlapping shapes wound in opposite directions cancel where they overlap.
1296
1943
  Images are JPEG, PNG (non-interlaced) and lossless WebP (not lossy or animated WebP, and no more
1297
1944
  than 33 megapixels) and never fetched from a URL; a JPEG is embedded at its
1298
- source resolution (only PNG and WebP can be downscaled), so an oversized one is reported, not resized. `html` reads a fixed subset of CSS (colours, sizes, weights, alignment, margins, padding, table
1945
+ source resolution (only PNG and WebP can be downscaled), so an oversized one is reported, not resized; a lossless, arithmetic-coded, hierarchical or 12-bit JPEG, or one of more than 33 megapixels, is not decoded for `to_png`, `to_zpl` or `monochrome`. `html` reads a fixed subset of CSS (colours, sizes, weights, alignment, margins, padding, table
1299
1946
  borders and widths, page breaks; see [What CSS is read](#what-css-is-read)), not a layout
1300
1947
  engine's worth: no `display`, positioning, `font-family`, `auto` or negative margins or selectors
1301
1948
  with combinators, and floats for images only, with their `margin` around them.
@@ -1309,20 +1956,33 @@ height balanced for it and the columns after it, so one that ends above a block
1309
1956
  split may stay shorter than the column after it. It has columns of one width, nothing spanning
1310
1957
  them (end the block, write the full-width content, start another) and no column break of its
1311
1958
  own; a spacer that lands at the top of a column keeps its height. Text
1312
- wraps around floated images and boxes, along their rectangles, never along a shape; beside a float
1313
- a table, a row, `columns` and a box with a background, a border or a size of its own are blocks of
1314
- the width that is left, all the way down: a box is painted after the float written before it, so
1315
- one that kept the full width would paint its background over the float (see
1316
- [Floats](#floats)). Link and form-widget rectangles stay in page space inside `rotate`
1959
+ wraps around floated images and boxes, along their rectangles, never along a shape; the text
1960
+ after floats cut by a page break goes to the next page with the float that moved, never beside
1961
+ those that stayed; beside a float
1962
+ a table, a row, `columns` and a box with a size of its own are blocks of the width that is left,
1963
+ all the way down, where a box with a background wraps: a table resolves its column widths from
1964
+ its width, a row shares its width between its columns and `columns` divides it, and none of them
1965
+ can be laid out again at another width from some row on, which widening below the float would
1966
+ take (see [Floats](#floats)). A table reads its rows as pages reach them only from an Enumerator
1967
+ with a width for every column, and then refuses what needs its end (a row counted from the end, a
1968
+ cell spanning rows); in a `row`, in a box with `break_inside: :avoid` or beside a float it is read
1969
+ whole first (see [Large documents](#large-documents)). Link and form-widget rectangles stay in page space inside `rotate`
1317
1970
  and `transform`, and `shadow:` is stacked rectangles, not a blur.
1318
1971
 
1319
- PDF: PDF/A-2b, PDF/A-3b and PDF/UA-1 only (no PDF/A-1, no level A or U, no PDF/UA-2, no PDF/X) and
1320
- no JavaScript. A render carries one signature (`/ETSI.CAdES.detached`, RSA or EC with SHA-256):
1972
+ PDF: PDF/A-2b, PDF/A-3b and PDF/UA-1 only (no PDF/A-1, no level A or U, no PDF/UA-2, no PDF/X),
1973
+ no JavaScript and no action but links and the print dialog (`print dialog: :on_open`). Under PDF/UA-1 a
1974
+ link in the header row a table repeats on its next pages, and `canvas.link` without a `tag:`, raise: they
1975
+ are outside the structure tree. A render carries one signature (`/ETSI.CAdES.detached`, RSA or EC with SHA-256):
1321
1976
  no long-term validation data (PAdES B-LT), no second signature and no signing
1322
1977
  of a file that already exists, all of which need incremental updates.
1323
1978
  Form fields are set in the document's fonts, but text typed into one is drawn by the viewer:
1324
1979
  characters outside the glyphs the field kept (ASCII and Latin-1) use the viewer's own font.
1325
1980
 
1981
+ Pictures and labels: `to_png` and `to_zpl` draw no form fields (their widget is the viewer's), and
1982
+ `to_zpl` sends each page as one graphic field, with only barcodes as the printer's own commands (no
1983
+ `^GB` rules or boxes, no printer fonts). The labels were compared with `to_png` and rendered by a ZPL
1984
+ viewer, not printed by the gem's specs.
1985
+
1326
1986
  ## License
1327
1987
 
1328
1988
  MIT. The PDF writer, TrueType subsetter and image decoders are derived from