stationery 0.11.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -68
  3. data/README.md +728 -77
  4. data/examples/accessible_report.rb +133 -0
  5. data/examples/article.rb +93 -0
  6. data/examples/assets/bay.png +0 -0
  7. data/examples/assets/dunes.png +0 -0
  8. data/examples/assets/hills.png +0 -0
  9. data/examples/assets/logo.png +0 -0
  10. data/examples/assets/ridge.png +0 -0
  11. data/examples/assets/sea.png +0 -0
  12. data/examples/assets/stone.png +0 -0
  13. data/examples/assets/wall.png +0 -0
  14. data/examples/certificate.rb +104 -0
  15. data/examples/contract.rb +159 -0
  16. data/examples/e_invoice.rb +161 -0
  17. data/examples/flyer.rb +336 -0
  18. data/examples/form.rb +113 -0
  19. data/examples/invoice.rb +163 -0
  20. data/examples/letter.rb +121 -0
  21. data/examples/menu.rb +133 -0
  22. data/examples/multilingual_notice.rb +121 -0
  23. data/examples/newsletter.rb +108 -0
  24. data/examples/packing_slip.rb +120 -0
  25. data/examples/postcard.rb +83 -0
  26. data/examples/price_list.rb +107 -0
  27. data/examples/receipt.rb +176 -0
  28. data/examples/report.rb +294 -0
  29. data/examples/resume.rb +121 -0
  30. data/examples/shaping/extraction_matrix/pdfium.py +8 -0
  31. data/examples/shaping/extraction_matrix/pdfjs.mjs +18 -0
  32. data/examples/shaping/extraction_matrix/pdfkit.swift +6 -0
  33. data/examples/shaping/extraction_matrix.rb +208 -0
  34. data/examples/shaping/harfbuzz_shaper.rb +132 -0
  35. data/examples/shaping/rtl_letter.rb +106 -0
  36. data/examples/shipping_label.rb +93 -0
  37. data/lib/stationery/barcode/code128.rb +96 -0
  38. data/lib/stationery/barcode/data_matrix.rb +111 -0
  39. data/lib/stationery/barcode/data_matrix_placement.rb +118 -0
  40. data/lib/stationery/barcode/ean13.rb +66 -0
  41. data/lib/stationery/barcode/qr.rb +120 -0
  42. data/lib/stationery/barcode/qr_matrix.rb +189 -0
  43. data/lib/stationery/barcode/reed_solomon.rb +61 -0
  44. data/lib/stationery/barcode.rb +33 -0
  45. data/lib/stationery/builder.rb +12 -2
  46. data/lib/stationery/canvas/glyphs.rb +40 -0
  47. data/lib/stationery/canvas/interface.rb +169 -0
  48. data/lib/stationery/canvas/marking.rb +32 -3
  49. data/lib/stationery/canvas/path.rb +32 -70
  50. data/lib/stationery/canvas/surfacing.rb +67 -0
  51. data/lib/stationery/canvas/text.rb +4 -25
  52. data/lib/stationery/canvas.rb +24 -75
  53. data/lib/stationery/cli/examples.rb +66 -0
  54. data/lib/stationery/cli/inspect.rb +65 -0
  55. data/lib/stationery/cli/layout_text.rb +132 -0
  56. data/lib/stationery/cli/pictures.rb +50 -0
  57. data/lib/stationery/cli/render.rb +33 -6
  58. data/lib/stationery/cli/skill.rb +166 -0
  59. data/lib/stationery/cli.rb +5 -0
  60. data/lib/stationery/color.rb +27 -13
  61. data/lib/stationery/component.rb +1 -0
  62. data/lib/stationery/document.rb +135 -27
  63. data/lib/stationery/elements/forms.rb +2 -1
  64. data/lib/stationery/elements/lists.rb +6 -2
  65. data/lib/stationery/elements.rb +43 -7
  66. data/lib/stationery/examples.rb +22 -0
  67. data/lib/stationery/fonts/cff.rb +46 -0
  68. data/lib/stationery/fonts/charstring.rb +297 -0
  69. data/lib/stationery/fonts/fallback.rb +22 -1
  70. data/lib/stationery/fonts/font.rb +58 -3
  71. data/lib/stationery/fonts/font_book.rb +7 -3
  72. data/lib/stationery/fonts/glyf_outline.rb +230 -0
  73. data/lib/stationery/fonts/glyph_run.rb +37 -10
  74. data/lib/stationery/fonts/outline.rb +102 -0
  75. data/lib/stationery/fonts/shaped_run.rb +22 -4
  76. data/lib/stationery/fonts/true_type.rb +7 -0
  77. data/lib/stationery/forms/typeface.rb +2 -4
  78. data/lib/stationery/images/images.rb +10 -0
  79. data/lib/stationery/images/jpeg/bit_reader.rb +92 -0
  80. data/lib/stationery/images/jpeg/colors.rb +133 -0
  81. data/lib/stationery/images/jpeg/decoder.rb +253 -0
  82. data/lib/stationery/images/jpeg/huffman.rb +59 -0
  83. data/lib/stationery/images/jpeg/idct.rb +150 -0
  84. data/lib/stationery/images/jpeg/progressive.rb +128 -0
  85. data/lib/stationery/images/jpeg/reduced_idct.rb +106 -0
  86. data/lib/stationery/images/jpeg/scan.rb +116 -0
  87. data/lib/stationery/images/jpeg/upsampler.rb +81 -0
  88. data/lib/stationery/images/jpeg.rb +59 -1
  89. data/lib/stationery/images/png.rb +10 -7
  90. data/lib/stationery/images/resampled.rb +10 -2
  91. data/lib/stationery/images/webp.rb +6 -5
  92. data/lib/stationery/layout/barcode.rb +75 -0
  93. data/lib/stationery/layout/box/wrapping.rb +17 -13
  94. data/lib/stationery/layout/box.rb +3 -3
  95. data/lib/stationery/layout/field.rb +3 -2
  96. data/lib/stationery/layout/floated.rb +12 -1
  97. data/lib/stationery/layout/flow/floating.rb +23 -5
  98. data/lib/stationery/layout/flow/placement.rb +13 -10
  99. data/lib/stationery/layout/flow/splitter.rb +56 -10
  100. data/lib/stationery/layout/flow.rb +1 -0
  101. data/lib/stationery/layout/image.rb +2 -2
  102. data/lib/stationery/layout/list_item.rb +14 -5
  103. data/lib/stationery/layout/mark.rb +2 -0
  104. data/lib/stationery/layout/node.rb +16 -2
  105. data/lib/stationery/layout/opening.rb +61 -0
  106. data/lib/stationery/layout/paginator.rb +24 -5
  107. data/lib/stationery/layout/svg.rb +1 -1
  108. data/lib/stationery/layout/table/cell.rb +39 -10
  109. data/lib/stationery/layout/table/row_splitter.rb +6 -2
  110. data/lib/stationery/layout/table/selection.rb +9 -3
  111. data/lib/stationery/layout/table/stream.rb +123 -0
  112. data/lib/stationery/layout/table/widths.rb +26 -0
  113. data/lib/stationery/layout/table.rb +151 -35
  114. data/lib/stationery/layout/table_of_contents/entry.rb +3 -3
  115. data/lib/stationery/layout/table_of_contents.rb +1 -1
  116. data/lib/stationery/layout/text.rb +22 -2
  117. data/lib/stationery/minitest.rb +5 -0
  118. data/lib/stationery/monochrome/bitmap.rb +78 -0
  119. data/lib/stationery/monochrome/canvas.rb +19 -0
  120. data/lib/stationery/monochrome/dither.rb +74 -0
  121. data/lib/stationery/monochrome/grid.rb +131 -0
  122. data/lib/stationery/monochrome/painting.rb +140 -0
  123. data/lib/stationery/monochrome/rules.rb +141 -0
  124. data/lib/stationery/monochrome.rb +87 -0
  125. data/lib/stationery/page/format.rb +84 -0
  126. data/lib/stationery/page.rb +11 -7
  127. data/lib/stationery/page_templates.rb +13 -10
  128. data/lib/stationery/path.rb +138 -0
  129. data/lib/stationery/pdf/assembler.rb +14 -3
  130. data/lib/stationery/pdf/canvases.rb +51 -0
  131. data/lib/stationery/pdf/conformance.rb +40 -13
  132. data/lib/stationery/pdf/print_hints.rb +137 -0
  133. data/lib/stationery/pdf/serializer.rb +41 -0
  134. data/lib/stationery/raster/adjust.rb +67 -0
  135. data/lib/stationery/raster/canvas.rb +108 -0
  136. data/lib/stationery/raster/canvases.rb +49 -0
  137. data/lib/stationery/raster/flattener.rb +75 -0
  138. data/lib/stationery/raster/glyphs.rb +81 -0
  139. data/lib/stationery/raster/painter.rb +184 -0
  140. data/lib/stationery/raster/picture.rb +69 -0
  141. data/lib/stationery/raster/png.rb +32 -0
  142. data/lib/stationery/raster/render.rb +134 -0
  143. data/lib/stationery/raster/scanner.rb +164 -0
  144. data/lib/stationery/raster/shading.rb +77 -0
  145. data/lib/stationery/raster/spans.rb +66 -0
  146. data/lib/stationery/raster/stroker.rb +208 -0
  147. data/lib/stationery/raster/surface.rb +70 -0
  148. data/lib/stationery/raster.rb +44 -0
  149. data/lib/stationery/skill/SKILL.md.erb +244 -0
  150. data/lib/stationery/skill/recipes/flyer.md.erb +76 -0
  151. data/lib/stationery/skill/recipes/form.md.erb +78 -0
  152. data/lib/stationery/skill/recipes/invoice.md.erb +142 -0
  153. data/lib/stationery/skill/recipes/label.md.erb +116 -0
  154. data/lib/stationery/skill/recipes/receipt.md.erb +79 -0
  155. data/lib/stationery/skill/recipes/report.md.erb +113 -0
  156. data/lib/stationery/skill/reference/conformance.md.erb +45 -0
  157. data/lib/stationery/skill/reference/elements.md.erb +19 -0
  158. data/lib/stationery/skill/reference/fonts_and_images.md.erb +7 -0
  159. data/lib/stationery/skill/reference/forms.md.erb +5 -0
  160. data/lib/stationery/skill/reference/html_and_markdown.md.erb +5 -0
  161. data/lib/stationery/skill/reference/pages.md.erb +15 -0
  162. data/lib/stationery/skill/reference/printing.md.erb +33 -0
  163. data/lib/stationery/skill/reference/testing.md.erb +33 -0
  164. data/lib/stationery/skill.rb +101 -0
  165. data/lib/stationery/structure.rb +7 -6
  166. data/lib/stationery/svg/gradient.rb +12 -0
  167. data/lib/stationery/svg/painter.rb +1 -1
  168. data/lib/stationery/tagging/element.rb +3 -2
  169. data/lib/stationery/tagging/tree.rb +45 -0
  170. data/lib/stationery/testing/inspector.rb +68 -0
  171. data/lib/stationery/testing/layout.rb +166 -0
  172. data/lib/stationery/testing/matchers.rb +37 -0
  173. data/lib/stationery/testing/page_reader.rb +146 -0
  174. data/lib/stationery/text/paragraph.rb +13 -8
  175. data/lib/stationery/text/wrapper/one_word.rb +33 -0
  176. data/lib/stationery/text/wrapper.rb +62 -27
  177. data/lib/stationery/units.rb +45 -0
  178. data/lib/stationery/version.rb +1 -1
  179. data/lib/stationery/warnings.rb +57 -6
  180. data/lib/stationery/zpl/native.rb +51 -0
  181. data/lib/stationery/zpl/render.rb +56 -0
  182. data/lib/stationery/zpl.rb +88 -0
  183. data/lib/stationery.rb +47 -0
  184. metadata +115 -1
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,14 +484,293 @@ 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.
422
517
 
518
+ ### Printing
519
+
520
+ A PDF can say how it wants to be printed. For a page that is the medium itself (a label, a card, a
521
+ pre-printed form) the defaults of a print dialog are the wrong ones: "fit to page" prints a
522
+ 100 × 60 mm label at 94 %.
523
+
524
+ ```ruby
525
+ class ShelfLabel < Stationery::Document
526
+ page size: :label_100x50, margin: "3mm"
527
+ print scaling: :none, copies: 2, pick_tray_by_size: true, duplex: :simplex
528
+ end
529
+
530
+ ShelfLabel.new.to_pdf(print: { copies: 1 }) # laid over the hints of the class
531
+ ShelfLabel.new.to_pdf(print: { duplex: nil }) # without one of them
532
+ ShelfLabel.new.to_pdf(print: nil) # without any (false does the same)
533
+ ```
534
+
535
+ | Option | Values | Written as |
536
+ | --- | --- | --- |
537
+ | `scaling:` | `:none`, `:default` | `/PrintScaling` `/None`, `/AppDefault` |
538
+ | `copies:` | an Integer of 1 or more | `/NumCopies` |
539
+ | `pick_tray_by_size:` | `true`, `false` | `/PickTrayByPDFSize` |
540
+ | `duplex:` | `:simplex`, `:long_edge`, `:short_edge` | `/Duplex` `/Simplex`, `/DuplexFlipLongEdge`, `/DuplexFlipShortEdge` |
541
+ | `pages:` | a Range of page numbers from 1, or a list of them: `1..3`, `[1..1, 3..4]`, `2..` | `/PrintPageRange` |
542
+ | `dialog:` | `:on_open` | `/OpenAction << /S /Named /N /Print >>` |
543
+
544
+ - The first five are entries of the catalog's `/ViewerPreferences` (ISO 32000-1, 12.2, table 150),
545
+ beside the `/DisplayDocTitle` of a tagged document. `dialog: :on_open` asks the viewer to open its
546
+ print dialog when the file is opened, as `window.print()` does for a page, without JavaScript.
547
+ - `print` at class level adds to what the class inherits, and `to_pdf(print:)` to what the class
548
+ declares. A `nil` takes a hint away (`print copies: nil` in a subclass).
549
+ - Every option is checked where it is written, the class body or the call of `to_pdf`, and raises
550
+ `ArgumentError` naming what it takes: `print scaling: is :none or :default, not :fit`. Ranges that
551
+ overlap are refused.
552
+ - `pages:` counts from 1, as the file does, and is written in page order. How many pages there are is
553
+ known when the document is rendered: a range is cut at the last page (`2..` and `2..99` both end
554
+ there) and one that starts after it is left out, because a viewer drops a range that names a page
555
+ the document does not have.
556
+ - `/ViewerPreferences` is allowed under PDF/A and PDF/UA. The print dialog is not an action PDF/A
557
+ has: `dialog: :on_open` with `conformance :pdf_a2b` or `:pdf_a3b` raises
558
+ `Stationery::ConformanceError` (ISO 19005-2/3, 6.5.1; veraPDF rule 6.5.1-2). PDF/UA-1 alone takes it.
559
+ - The hints are written by every render: `incremental:`, signed and encrypted ones too.
560
+ - `print` in a class body is this declaration. Inside `view_template` and every other method of a
561
+ document it is still `Kernel#print`.
562
+ - `Inspector#print_preferences` reads them back as `print` takes them, `pages` as a list of ranges,
563
+ and `have_print_preference(scaling: :none)` and `assert_print_preference` assert them.
564
+
565
+ They are hints: a viewer follows the ones it knows, and whoever prints can overrule them in the
566
+ dialog. The table says what each viewer does and how that is known. Nothing in it was tried in a
567
+ running viewer: "documented" is what the vendor's PDF reference says, "source" what the viewer's
568
+ source code did when it was read on 2026-09-28, "reported" what a user wrote in the issue named.
569
+
570
+ | Viewer | `scaling:` | `copies:` | `duplex:` | `pages:` | `pick_tray_by_size:` | `dialog:` |
571
+ | --- | --- | --- | --- | --- | --- | --- |
572
+ | 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)) |
573
+ | 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) |
574
+ | 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) |
575
+ | Preview (macOS) | not tested | not tested | not tested | not tested | not tested | not tested |
576
+ | Evince, Okular (poppler) | not tested | not tested | not tested | not tested | not tested | not tested |
577
+
578
+ ### Monochrome
579
+
580
+ A thermal label printer (203 or 300 dpi), a receipt printer or an e-paper display prints black or
581
+ nothing. Whatever else the PDF holds, the driver or the printer turns into dots of its own choosing:
582
+ grey text comes out speckled, a light rule or background vanishes or turns into a dot pattern, and a
583
+ line thinner than one dot prints or not depending on where it lands. `monochrome` makes those
584
+ choices where they can be seen, before anything is printed:
585
+
586
+ ```ruby
587
+ class ShelfLabel < Stationery::Document
588
+ page size: [mm(100), mm(60)], margin: mm(4)
589
+ monochrome dpi: 203 # report what a 203 dpi printer cannot print as it is
590
+ end
591
+
592
+ ShelfLabel.new.to_pdf(monochrome: { snap: true }) # change it instead, for this render
593
+ ShelfLabel.new.to_pdf(monochrome: false) # as it was, byte for byte
594
+ ```
595
+
596
+ | Option | Default | What it does |
597
+ | --- | --- | --- |
598
+ | `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) |
599
+ | `snap:` | `false` | change colours and thin lines instead of reporting them |
600
+ | `threshold:` | `0.5` | under `snap:`, a fill whose tone is darker is painted black, a lighter one is not painted |
601
+ | `dither:` | `:floyd_steinberg` | how a bitmap becomes dots: `:floyd_steinberg`, `:ordered` (an 8 × 8 Bayer pattern) or `:threshold` (cut at half) |
602
+
603
+ - **Colours.** Every colour that is not black or white, and anything painted at an `opacity:` below 1,
604
+ is a `Warnings::NotMonochrome` naming the colour and what painted it: `text`, `a rule` (a `rule`,
605
+ an underline, a strikethrough: a filled rectangle 3 pt thick or less), `a background` (any other
606
+ fill), `a border or line` (any stroke), `a gradient` or `an image`, once per colour, kind and page:
607
+ `text in #888888 on page 1 is not black or white`. `strict` raises on them. It sees whatever is
608
+ painted, by an element, an SVG or a `canvas { }` block alike.
609
+ - **`snap: true`.** Text, rules, borders and lines are painted black, except white ones, which stay
610
+ white (white text on a black box). A fill is painted black when its tone is darker than
611
+ `threshold:` and left out when it is lighter; the tone is the colour's luma (ITU-R BT.601), an
612
+ opacity laid over white paper (black at 0.3 is a tone of 0.7). A gradient is black or left out
613
+ by the average tone of its stops. Opacity is taken away. Nothing is reported.
614
+ - **Line widths.** A stroke or a rule thinner than a dot (`width × the scale of a transform`) is a
615
+ `Warnings::ThinLine` and is left as it is; with `snap: true` it is widened to a dot.
616
+ - **The dot grid.** Outside any transform, a filled rectangle (a rule, a background, an underline)
617
+ and a stroke made of horizontal and vertical lines (a border, a table's cell borders, a line) are
618
+ put on the printer's grid, counted from the top left corner of the page: their edges on whole dots
619
+ and their widths whole numbers of dots, so a hairline is as wide on every label. A curve, a rounded
620
+ corner, a slanted line and anything under a transform keeps its geometry.
621
+ - **Images.** A PNG, a lossless WebP or a JPEG is greyed (a transparent pixel is white paper),
622
+ resampled to the dots it covers at `dpi:` and dithered, and embedded as a one-bit DeviceGray image
623
+ (`/BitsPerComponent 1`): what prints is the pattern chosen here, not the driver's. A JPEG is decoded
624
+ for it in Ruby, no larger than the dots need; one of a kind that is not decoded (lossless,
625
+ arithmetic-coded, hierarchical, 12-bit) is embedded as it is and reported (`an image in JPEG 640x480`).
626
+ - `monochrome` at class level is inherited and adds to what the class inherits; `monochrome false`
627
+ takes it away. `to_pdf(monochrome:)` takes `true`, `false` or options laid over the class's.
628
+ - Monochrome renders keep PDF/A and PDF/UA: the one-bit image is DeviceGray, which the sRGB output
629
+ intent covers (checked with veraPDF, PDF/A-3b and PDF/UA-1).
630
+ - `Inspector#colors` lists what a PDF paints with, so a spec can hold it: `expect(pdf).to
631
+ have_pdf_colors("#000000")`.
632
+
633
+ What it does not do: it does not dither a lossless, arithmetic-coded, hierarchical or 12-bit JPEG, does not look at form
634
+ fields (their widget draws them, not the page), does not put dashes, line caps or curves on the grid,
635
+ and does not make small text bolder. It changes nothing without `monochrome`. The rules live in
636
+ `Stationery::Monochrome::Rules` and `Monochrome::Grid`, apart from the PDF canvas, and `to_png` and
637
+ `to_zpl` apply the same ones (see below).
638
+
639
+ ### Pictures of a render: to_png
640
+
641
+ `to_png` paints the pages `to_pdf` lays out on pixels instead, in Ruby, with nothing to install: a
642
+ preview, a picture to look at in a spec or a pull request, or what a one-bit printer will print.
643
+
644
+ ```ruby
645
+ pngs = Invoice.new(invoice).to_png # => ["\x89PNG…", …], one String per page, 96 dpi
646
+ Invoice.new(invoice).to_png("invoice.png") # one page: invoice.png; several: invoice-1.png, invoice-2.png, …
647
+ Invoice.new(invoice).to_png(dpi: 144, pages: 1) # pages: a number, a Range or an Array of them
648
+ ShelfLabel.new.to_png(monochrome: { dpi: 203, snap: true }) # one bit to a dot, 1-bit PNG
649
+ ```
650
+
651
+ - **What is drawn**: text from the outlines of its glyphs (TrueType, CFF, WOFF; synthetic bold and
652
+ oblique, letter spacing, rise, shaped runs), fills and strokes with their caps, joins (miter limit
653
+ 10) and dashes, even-odd and nonzero fills, clips, transforms and rotations, opacity, SVG linear and
654
+ radial gradients, PNG, WebP and JPEG images, headers, footers, page templates (a background layer under
655
+ the page) and page numbers. A colour picture is 24-bit RGB on white, anti-aliased; a text's origin,
656
+ a stroke's horizontal and vertical edges and an upright image's edges are put on the pixel grid as
657
+ poppler puts them, so it is close to `pdftoppm -r <dpi>` of the same PDF: the examples differ in
658
+ 0.0 to 0.5% of their pixels at 72 dpi, in the hinting of a few glyphs and the edges of images.
659
+ - **What is not**: a lossless, arithmetic-coded, hierarchical or 12-bit JPEG, which is not decoded, is drawn as a
660
+ grey box with a cross and reported (`Warnings::SkippedImage`); form fields draw nothing (their
661
+ widget is the PDF viewer's); links, bookmarks and tagging are not visible anyway.
662
+ - **Monochrome.** `monochrome:` takes what `to_pdf` takes, and the class's `monochrome` applies
663
+ unless `monochrome: false` is given. The picture is drawn at the monochrome `dpi:` (a `dpi:` given
664
+ to `to_png` replaces it), a pixel in or out by its centre with no anti-aliasing, and written as a
665
+ 1-bit PNG. The rules are those of the PDF (colours reported or snapped, thin lines, the dot grid,
666
+ images dithered at the dots they cover); a page with grey left on it (a colour reported and kept
667
+ without `snap:`) is dithered as images are. An upright image is drawn dot for dot as the one-bit
668
+ image the PDF embeds, with its corner on the nearest dot: the PDF leaves the image's box where the
669
+ layout put it, and its size in dots is the image's own, where poppler stretches it by a dot when both
670
+ edges of the box round outwards. Black and white match `pdftoppm -mono` in all but 0.1 to 2% of the
671
+ dots; dithered areas differ dot by dot, since poppler halftones where stationery dithers.
672
+ - **Options.** `dpi:` (96, or the monochrome dpi), `pages:`, `monochrome:`, `debug:` (the layout
673
+ rectangles), `strict:`, `shaper:` and `max_pages:` as `to_pdf` has them. What only a PDF has (`sign:`, `encrypt:`,
674
+ `conformance:`, `attachments:`, `print:`, `tagged:`, `page_labels:`, `xmp:`, `factur_x:`,
675
+ `incremental:`, `missing_glyphs:`) raises `ArgumentError` when it is passed, and is left alone when
676
+ the class declares it, so a signed or encrypted document still has pictures.
677
+ - **Speed.** A 100 × 150 mm label at 203 dpi (800 × 1200 dots) takes about 30 ms, a one-page invoice
678
+ at 96 dpi about 0.1 s, a page of dithered photographs at 203 dpi about 0.7 s, a label with a
679
+ 12-megapixel JPEG photo about 1 s (Ruby 3.4 with YJIT,
680
+ Apple M-series). Each page is kept as the list of what it was asked to draw until every page is
681
+ painted, then drawn and encoded one at a time, so a long document does not hold a bitmap per page.
682
+
683
+ `Stationery::Raster` has the pieces: `Raster::Canvases` and `Raster::Canvas` record, `Raster::Painter`
684
+ replays onto a `Raster::Surface` through `Raster::Scanner` (coverage), `Raster::Stroker` and
685
+ `Raster::Flattener`, and `Raster::PNG` writes the file.
686
+
687
+ ### Label printers: to_zpl
688
+
689
+ A thermal label printer (Zebra, and the many makes that emulate ZPL II) is driven in its own
690
+ language, not PDF. `to_zpl` writes the document as ZPL: each page is drawn one bit to a dot, as
691
+ `to_png(monochrome: …)` draws it, and sent as one graphic field, so the label is the layout `to_pdf`
692
+ produces, in any font the document uses. One document class serves the preview on screen and the
693
+ printer.
694
+
695
+ ```ruby
696
+ class ShippingLabel < Stationery::Document
697
+ page size: "4in x 6in", margin: mm(4)
698
+ max_pages 1 # a second page raises instead of printing a second label
699
+ monochrome dpi: 203
700
+ end
701
+
702
+ label = ShippingLabel.new(parcel)
703
+ label.to_zpl # => "^XA^PW812^LL1218^LH0,0^FO0,0^GFA,…^XZ\n", one label per page
704
+ label.to_zpl("label.zpl", dpi: 300, copies: 2)
705
+ label.to_zpl(compression: :hex, pages: 1) # plain hex, for printers that do not take Z64
706
+ ```
707
+
708
+ How the ZPL reaches the printer is the application's business: a socket to port 9100, a print
709
+ server, a browser print agent. For example, over the network:
710
+
711
+ ```ruby
712
+ require "socket"
713
+ TCPSocket.open("printer.local", 9100) { |socket| label.to_zpl(socket) }
714
+ ```
715
+
716
+ - **What is written.** Per page `^XA`, `^PW` and `^LL` (the page's width and length in dots,
717
+ `Raster.pixels(points, dpi)`), `^LH0,0`, `^FO0,0`, one `^GFA` graphic field, `^FS`, `^PQ` (copies)
718
+ and `^XZ`, then a newline. The field's dots are 1 for black, each row padded to a byte with white.
719
+ `compression: :z64` (the default) deflates the rows with zlib and writes them in Base64, followed
720
+ by the CRC Zebra's software writes (CRC-16/XMODEM of the Base64 text, checked against a ZebraDesigner
721
+ print file); `:hex` writes them as plain hex, about 20 times larger.
722
+ - **Always monochrome.** A label printer prints one bit, so `to_zpl` renders with the class's
723
+ `monochrome` settings when it declares them and with the defaults when it does not (`snap: false`,
724
+ `dither: :floyd_steinberg`): a colour is reported as `Warnings::NotMonochrome` and dithered, as
725
+ `to_png(monochrome: true)` shows it. `monochrome:` takes options laid over them (`{ snap: true }`);
726
+ `monochrome: false` raises.
727
+ - **`dpi:`** is the printer's: 152, 203, 300 or 600 (6, 8, 12 or 24 dots a millimetre); anything else
728
+ raises `ArgumentError` naming them. It defaults to the class's `monochrome dpi:`, else 203, and
729
+ replaces it when given, so `to_zpl(dpi: 300)` and `to_png(monochrome: { dpi: 300 })` are the same
730
+ dots.
731
+ - **`copies:`** is `^PQ`, by default the class's `print copies:`, else 1. **`pages:`** a page number,
732
+ a Range or an Array, in the order given; `target` a path or anything answering `write` (a socket).
733
+ - **One label.** Content that does not fit the label goes to a next page, and every page is a label.
734
+ Declare `max_pages 1` (see Pages) and a document that needs a second page writes no ZPL:
735
+ `to_zpl` raises `Stationery::WarningsError` with `Warnings::TooManyPages`, strict or not, because
736
+ the harm of a second label is done when it prints, where no one reads a warning. `to_zpl(max_pages: nil)`
737
+ writes them all; `to_pdf` and `to_png` of the same document keep every page and warn, so the page
738
+ that spilled can be looked at.
739
+ - `strict:`, `debug:`, `shaper:` and `max_pages:` are those of `to_pdf`; what only a PDF has raises, as for `to_png`.
740
+ - **Speed.** A 4 × 6 in label takes about 25 ms at 203 dpi and 35 ms at 300 dpi (Ruby 3.4, Apple
741
+ M-series), and is 10 to 20 KB of ZPL.
742
+ - The labels were read back and compared with `to_png` dot for dot, and rendered by
743
+ [Labelary](http://labelary.com/viewer.html), a ZPL viewer, identically. They were not printed on a
744
+ printer by the gem's specs.
745
+
746
+ **Barcodes the printer draws.** A `barcode` is part of the picture unless it asks otherwise:
747
+ `barcode "SX0042771903", native: true`, or `to_zpl(native: true)` for every barcode that does not
748
+ say `native: false`. Such a barcode is left out of the graphic field and written after it as the
749
+ printer's own command, where the picture had it, so the printer puts the bars on its own dot grid:
750
+
751
+ ```
752
+ ^FO146,874^BY4^BCN,124,N,N,N,N^FD>:SX>50042771903^FS Code 128, its code sets named
753
+ ^FO65,197^BY3^BEN,113,N,N^FD400638133393^FS EAN-13, the printer adds the check digit
754
+ ^FO650,625^BQN,2,4^FDMM,B0034https://track.example/SX0042771903^FS QR code, byte mode, level M
755
+ ^FO42,42^BXN,8,200,14,14,6,_,1^FDSX0042771903^FS Data Matrix (ECC 200), 14 × 14
756
+ ```
757
+
758
+ - `^BY` is the module in whole dots, the height in dots, and data ZPL would read as a command is
759
+ escaped with `^FH`. A QR code's `^FO` is 10 dots above it, since `^BQ` draws that far below its
760
+ origin. A Data Matrix is asked for at its own size, so it takes the same square.
761
+ - It stays in the picture when the printer could not draw it as it is: rotated or otherwise
762
+ transformed, clipped, lighter than half grey, a module over 10 dots, a QR code of UTF-8 text
763
+ (its ECI has no field in `^BQ`), or a Data Matrix holding `_` (the escape character `^BX` is given).
764
+ - Rendered by Labelary, the Code 128 is the same dots as `to_png` draws; the EAN-13's guard bars come
765
+ out 13 dots longer, as the printer draws them; a QR code covers the same modules' square, its
766
+ modules chosen by the printer's own encoder (another mask); a Data Matrix of digits and capitals is
767
+ the same dots, one of other text another encodation in the same square. All decode (ZBar,
768
+ dmtxread) to the data.
769
+ - Rules and boxes are not written as `^GB`: a box drawn natively would print over white text on it,
770
+ which the picture keeps.
771
+
772
+ `stationery render label.rb --zpl --dpi 300` writes `label.zpl` (see [CLI](#cli)).
773
+
423
774
  ### Encryption
424
775
 
425
776
  ```ruby
@@ -498,8 +849,11 @@ and across page breaks: a paragraph continued on the next page stays one `P`.
498
849
  `BlockQuote`, and give images their `alt`. In `html`, `<img alt="">` is decoration as `alt: false`
499
850
  is (an `<img>` without the attribute is a description missing). Markdown has no way to say so:
500
851
  `![](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.
852
+ - Headers, footers and page templates are pagination artifacts, but for their links: a `link:` one
853
+ paints (`text(link:)`, markup, `html`, `markdown`, `box(link:)`) is a `Link` of the `Document`
854
+ holding its text and its annotation, read after the content of its page, in the order header,
855
+ footer, page templates. The rest of the region stays an artifact. Backgrounds, borders and rules
856
+ drawn outside any element are layout artifacts.
503
857
  - `metadata lang:` writes the catalog's `/Lang`; the title is shown instead of the file name.
504
858
  - Every document carries an XMP packet (`/Metadata`, uncompressed) mirroring the Info dictionary:
505
859
  `dc:title`, `dc:creator`, `dc:description`, `dc:subject`, `dc:language`, the `xmp:` dates and
@@ -512,8 +866,15 @@ and across page breaks: a paragraph continued on the next page stays one `P`.
512
866
  (`1`, then `3` skips `2`). Going back up is free (`3`, then `1`). Headings are read in the order of
513
867
  the structure tree, inside sections, lists, table cells, columns and floats; those of headers,
514
868
  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.
869
+ - A link annotation that belongs to no `Link` element is a `Warnings::UntaggedLink` (`target`,
870
+ `place`, `page`): a link in the header row a table repeats on its next pages, which is an artifact
871
+ (`place` is `:artifact`), and `canvas.link` without a `tag:` (`:canvas` in the body, `:header`,
872
+ `:footer` or `:page_template` on the canvas of one). Every `link:` is tagged, in the body and in
873
+ headers, footers and page templates; on a canvas of the body, pass the `Link` element that holds
874
+ what the link draws: `canvas.link(x, y, w, h, url, tag: element)` after
875
+ `canvas.tag(element) { … }`.
876
+ - A missing `lang` is a `Warnings::MissingLanguage`. All four are warnings, so `strict` catches them,
877
+ and `conformance :pdf_ua1` raises on the first three.
517
878
  - Untagged documents (the default) are written exactly as before.
518
879
 
519
880
  Check the tree in tests with `have_structure` and `have_tagged_content` (see [Testing](#testing)):
@@ -561,8 +922,10 @@ mislabelled: `ArgumentError` for options that contradict the level, `Stationery:
561
922
  every link annotation a description (`/Contents`: the URL, or the target page). A figure without
562
923
  `alt:` or with a blank one raises (7.3); mark decoration with `alt: false`, or `<img alt="">` in
563
924
  `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.
925
+ heading is at most one level below the heading before it. A link annotation outside the structure
926
+ tree raises (7.18.5): a link in the header row a table repeats, or `canvas.link` without a `tag:`.
927
+ Every other link is tagged, those of headers, footers and page templates too.
928
+ PDF/A alone asks for none of the three. Encryption is allowed.
566
929
  - Combined, the XMP packet also describes the `pdfuaid` schema to PDF/A (`pdfaExtension:schemas`).
567
930
  - Interactive form fields are allowed: their appearances draw with embedded fonts and paths, every
568
931
  field has a `/TU`, and `NeedAppearances` and ZapfDingbats are left out. Only a field made without
@@ -570,16 +933,52 @@ mislabelled: `ArgumentError` for options that contradict the level, `Stationery:
570
933
  standard Helvetica.
571
934
  - A character no font has raises at every level, naming the character and the family: it draws as
572
935
  `.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.
936
+ `font_fallbacks` that covers it, or let the render draw a stand-in with `missing_glyphs: :replace`
937
+ (below). Whitespace a font lacks draws as a blank and is accepted.
574
938
  - Without `conformance` nothing changes: the output is byte for byte what it was.
575
939
 
940
+ A name in a script the fonts do not cover (a customer in Tokyo on an invoice archived as PDF/A) is
941
+ where the raise turns up in practice. Applications rescued it and rendered again without the claim:
942
+
943
+ ```ruby
944
+ begin
945
+ InvoicePdf.new(invoice).to_pdf
946
+ rescue Stationery::ConformanceError => e
947
+ logger.warn(e.message)
948
+ InvoicePdf.new(invoice).to_pdf(conformance: nil) # mislabelled no more, but no longer PDF/A
949
+ end
950
+ ```
951
+
952
+ `missing_glyphs: :replace` keeps the claim instead: a character no font has is drawn as the first
953
+ of U+FFFD (�), U+25A1 (□) and `?` that the font drawing it has, inside the `Span` whose `ActualText`
954
+ is the character, so the text still extracts, copies and reads aloud as written and nothing
955
+ references `.notdef`. The stand-in has its own advance, so lines are measured as they are drawn.
956
+
957
+ ```ruby
958
+ class InvoicePdf < Stationery::Document
959
+ conformance :pdf_a3b, missing_glyphs: :replace # :raise is the default
960
+ end
961
+
962
+ InvoicePdf.new(invoice).to_pdf(conformance: :pdf_a3b, missing_glyphs: :replace) # per render
963
+ ```
964
+
965
+ The `Warnings::MissingGlyph` stays (its `stand_in` is the character drawn, and `strict` still
966
+ raises on it), so what was replaced is on record. A font that has none of the three, which a
967
+ symbol font may not, still raises and says so. veraPDF passes 2b, 3b and ua1 with each of the
968
+ three stand-ins, in body text, headers and page templates, form field values and shaped text.
969
+ Without `conformance` the option changes nothing, and with `:raise` neither.
970
+
576
971
  `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:
972
+ `examples/report.rb`, `examples/form.rb`, `examples/article.rb` (floats), `examples/newsletter.rb`
973
+ (columns) and `examples/accessible_report.rb` (figures, a table with header cells) as PDF/A-3b plus
974
+ PDF/UA-1, the report once more with a link in its footer, and validates
975
+ them with
976
+ [veraPDF](https://verapdf.org): a `verapdf` on the PATH when there is one (`brew install verapdf`),
977
+ else through Docker (`verapdf/cli`); CI runs it on every push. Validate your own documents the same
978
+ way:
581
979
 
582
980
  ```sh
981
+ verapdf --format text -v --flavour 3b invoice.pdf
583
982
  docker run --rm -v "$PWD:/data:ro" verapdf/cli --format text -v --flavour 3b /data/invoice.pdf
584
983
  ```
585
984
 
@@ -756,9 +1155,10 @@ StatementPdf.new(account).to_pdf { |chunk| response.stream.write(chunk) }
756
1155
 
757
1156
  Peak resident memory of one render in a fresh process, and the megabytes still alive when
758
1157
  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):
1158
+ two runs of the same render peak up to a fifth apart, what is alive repeats; the text documents
1159
+ were measured on 0.11.0, the tables on 0.12.0):
760
1160
 
761
- | Document | Pages | | Before | Now | `incremental: true` |
1161
+ | Document | Pages | | 0.10 | Now | `incremental: true` |
762
1162
  |---|---:|---|---:|---:|---:|
763
1163
  | Headings and paragraphs | 1,000 | peak | 391 MB | 111 MB | 108 MB |
764
1164
  | | | alive | 216 MB | 17 MB | 14 MB |
@@ -768,13 +1168,63 @@ two runs of the same render peak up to a fifth apart, what is alive repeats):
768
1168
  | | | alive | 222 MB | 44 MB | 11 MB |
769
1169
  | | 5,189 | peak | 1,643 MB | 614 MB | 446 MB |
770
1170
  | | | 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 |
1171
+ | One table of 33,000 rows | 1,000 | peak | 901 MB | 174 MB | 174 MB |
1172
+ | | | alive | 494 MB | 26 MB | 23 MB |
1173
+ | One table of 165,000 rows | 5,000 | peak | 3,792 MB | 584 MB | 586 MB |
1174
+ | | | alive | 2,437 MB | 40 MB | 25 MB |
1175
+
1176
+ What is left is the document as it was built: every node but a table cell's exists before the
1177
+ first page is painted. A table with a column without a width resolves its column widths from every
1178
+ 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
1179
+ (the table of 165,000 rows holds 105 MB when its columns are resolved, its cells and their
1180
+ text, where it held 913 MB). In a tagged render the `TR`, `TH` and `TD` of a row are built
1181
+ when a page paints it, and the structure tree holds them until the file is written: the
1182
+ same table tagged peaks at 2.2 GB.
1183
+
1184
+ A table whose every column has a width (`widths:` in points or fractions) measures no cell for
1185
+ its columns. Given its rows as an Enumerator, lazy or not, such a table reads them as pages
1186
+ reach them, and holds the rows of the page being filled and the one after them, none before
1187
+ its first page:
775
1188
 
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.
1189
+ ```ruby
1190
+ rows = [%w[Order Customer Total]].each + Order.find_each.lazy.map { |o| [o.number, o.customer, o.total] }
1191
+ table(rows, header: true, widths: [80, 0.5, 90]) do |t|
1192
+ t.row(0).weight = :bold
1193
+ t.columns(2).align = :right
1194
+ t.zebra(from: 1, color: "#F4F4F4")
1195
+ end
1196
+ ```
1197
+
1198
+ - The header rows, `zebra`, `split_rows`, cells spanning columns and selections by index from
1199
+ the start (`t.row(0)`, `t.rows(1..)`, `t.columns(…)`) work as they do for an Array, applied to
1200
+ each row as it is read. A proc or component cell is built then, in the text style the table
1201
+ was given in.
1202
+ - What needs the end of the table raises `ArgumentError`: a row counted from the end
1203
+ (`t.row(-1)`, `t.rows(1..-2)`) when it is selected, and a cell spanning rows or a row with more
1204
+ columns than widths when it is read.
1205
+ - Anything that needs every row reads them all first: a table in a `row`, in a box with
1206
+ `break_inside: :avoid`, or beside a float is measured whole, as an Array is.
1207
+ - An Enumerator with a column without a width is read when the table is built, as before: a
1208
+ flexible column needs every row's width first. An Array is read as it always was.
1209
+
1210
+ A price list with every width given, a header row and zebra stripes, `incremental: true`
1211
+ (`PAGES=3031 DOCUMENTS=streamed,listed FORMS=incremental bundle exec rake memory`, then
1212
+ `PAGES=30303`, same machine; and the objects the render allocates):
1213
+
1214
+ | Rows | Pages | Rows given as | Peak | Alive when built | Alive when paginated | Objects |
1215
+ |---:|---:|---|---:|---:|---:|---:|
1216
+ | 100,000 | 3,031 | an Enumerator | 81 MB | 9 MB | 18 MB | 70.0 million |
1217
+ | | | an Array | 485 MB | 155 MB | 20 MB | 69.3 million |
1218
+ | 1,000,000 | 30,303 | an Enumerator | 130 MB | 9 MB | 47 MB | 710 million |
1219
+ | | | an Array | 2,758 MB | 1,474 MB | 64 MB | 702 million |
1220
+
1221
+ Streamed, the rows cost the same at any length; what grows is what the file still needs of
1222
+ every page until it is written (47 MB alive after 30,303 pages), and the peak with it. A
1223
+ streamed table allocates a little more (reading an Enumerator one row at a time costs a few
1224
+ objects a row). A tagged render keeps the `TR`,
1225
+ `TH` and `TD` of every painted row in the structure tree until the file is written, streamed or
1226
+ not: streaming leaves out only the rows no page has reached, so the same 100,000 rows tagged
1227
+ peak at 989 MB streamed and 1,230 MB as an Array.
778
1228
 
779
1229
  Controllers gain `render pdf:` and `send_pdf`:
780
1230
 
@@ -839,13 +1289,57 @@ The gem has no Rails dependency; the Railtie loads only inside a Rails app.
839
1289
  stationery render app/pdfs/invoice_pdf.rb # writes app/pdfs/invoice_pdf.pdf
840
1290
  stationery render invoice.rb --out - > invoice.pdf # PDF to stdout
841
1291
  stationery render pdfs.rb --class InvoicePdf --strict # pick one; fail on layout warnings
1292
+ stationery render invoice.rb --png # also invoice-1.png, … beside the PDF
1293
+ stationery render report.rb --png-only --pages 1,3-4 --dpi 144
1294
+ stationery render label.rb --zpl --dpi 300 # writes label.zpl for a label printer
842
1295
  ```
843
1296
 
844
1297
  `render` loads the file and renders the `Stationery::Document` it defines. A
845
1298
  document whose `initialize` needs arguments renders from `def self.preview`,
846
1299
  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.
1300
+ stderr; `--strict` exits 1 instead of writing. `--zpl` writes ZPL instead of a
1301
+ PDF (see [Label printers](#label-printers-to_zpl)), at `--dpi` 152, 203 (the
1302
+ default), 300 or 600. `stationery help` lists the commands.
1303
+
1304
+ `--png` writes a picture of each page beside the PDF with `to_png` (see
1305
+ [Pictures of a render](#pictures-of-a-render-to_png)), pure Ruby with nothing to
1306
+ install, named after the PDF with the number of the page, and prints one line
1307
+ per file (`wrote invoice-1.png (page 1, 794 x 1123 px)`), so an agent that
1308
+ rendered a document can open what it made. `--dpi` is 96 by default (an A4 page
1309
+ is 794 × 1123 px), `--pages` takes `2` or `1,3-4`, and `--png-only` writes the
1310
+ pictures without the PDF.
1311
+
1312
+ ```sh
1313
+ stationery inspect invoice.pdf # what is on each page, as text
1314
+ stationery inspect invoice.rb # render it first, with its warnings
1315
+ stationery inspect invoice.pdf --json # Inspector#layout as JSON
1316
+ ```
1317
+
1318
+ `inspect` prints what is on each page for an agent that cannot read a picture,
1319
+ and for a diff between two renders: the file's metadata, conformance claims,
1320
+ print hints, attachments and signatures, the outline, then each page with its
1321
+ size, its text lines (x, baseline y, font, size), images, links and form fields
1322
+ with their rectangles in points from the top-left corner, the structure tree of
1323
+ a tagged PDF and the warnings of the render. A section with nothing in it is
1324
+ left out, and the dates are, so two renders of one document print the same.
1325
+ It is `Inspector#layout` (see [Testing](#testing)) and needs the `pdf-reader`
1326
+ gem.
1327
+
1328
+ ```text
1329
+ examples/invoice.rb
1330
+ pages 1
1331
+ title Invoice
1332
+
1333
+ Page 1 595.3 x 841.9 pt
1334
+ Text (x, baseline y, font, size, text)
1335
+ 44.0 65.5 OpenSans-Bold 22 Invoice INV-2026-042
1336
+ 44.0 115.6 OpenSans-Bold 9 Invoice date
1337
+ 134.0 115.6 OpenSans-Regular 9 26 September 2026
1338
+ Images (x, y, width x height, pixels)
1339
+ 437.9 40.0 113.3 x 34 240 x 72 px
1340
+ Links (x, y, width x height, target)
1341
+ 329.2 596.5 65.6 x 11.6 mailto:hello@acme.test
1342
+ ```
849
1343
 
850
1344
  ```sh
851
1345
  stationery fonts list # packs, licenses, what is in vendor/fonts
@@ -860,6 +1354,36 @@ already present are kept unless `--force`. `--from` takes a directory or a
860
1354
  `.tar.gz` holding the same files (still SHA-checked). In Rails,
861
1355
  `bin/rails generate stationery:fonts noto_sans` does the same.
862
1356
 
1357
+ ```sh
1358
+ stationery examples # the examples in the gem, what each shows
1359
+ stationery examples invoice # the path of examples/invoice.rb there
1360
+ stationery examples invoice --source # its code
1361
+ ```
1362
+
1363
+ `examples` lists the documents under `examples/` of the installed gem with the
1364
+ first sentence of their header comment. With a name it prints the path of that
1365
+ file, to read, copy or hand to `stationery render`.
1366
+
1367
+ ```sh
1368
+ stationery skill install # the skill for the agents it finds, else Claude Code
1369
+ stationery skill install --target claude --project # into ./.claude/skills/stationery
1370
+ stationery skill install --dir ~/agent-skills # into any skills directory
1371
+ stationery skill status # current, outdated or missing, per agent
1372
+ stationery skill print # SKILL.md and its files, as one document
1373
+ ```
1374
+
1375
+ `skill install` writes the skill for coding agents that ships with the gem: a
1376
+ `SKILL.md` and the reference and recipe files beside it, written from this
1377
+ README and the examples for the version installed, with that version in its
1378
+ front matter. `--target` is `claude` (`~/.claude/skills`), `codex`
1379
+ (`~/.codex/skills`), `agents` (`~/.agents/skills`, read by OpenCode and others)
1380
+ or `all`; without it the skill goes to each of them whose directory is there,
1381
+ and to Claude Code when none is. `--project` writes under the current directory
1382
+ instead of the home directory. A skill named `stationery` that the gem did not
1383
+ write is kept unless `--force`. `skill status` says for each agent whether the
1384
+ skill there is current, outdated (written for an older version: install it
1385
+ again) or missing.
1386
+
863
1387
  ## Fonts and images
864
1388
 
865
1389
  Fonts are TrueType (`.ttf`) or OpenType/CFF (`.otf`, name-keyed or
@@ -924,7 +1448,9 @@ a figure space, a narrow no-break space, …) is drawn as a blank of the
924
1448
  character's conventional width, never as `.notdef`. Any other glyph no font
925
1449
  has is drawn as the family's `.notdef` and reported as a
926
1450
  `Warnings::MissingGlyph` counting each drawn occurrence (so `strict` raises
927
- on it, and a `conformance` level raises `ConformanceError`). The characters
1451
+ on it, and a `conformance` level raises `ConformanceError` unless it is
1452
+ declared with `missing_glyphs: :replace`, which draws the first of U+FFFD,
1453
+ U+25A1 and `?` the font has instead; see [PDF/A and PDF/UA](#pdfa-and-pdfua)). The characters
928
1454
  themselves travel as the `ActualText` of a `Span` around the glyphs, so the
929
1455
  text still extracts, copies and reads aloud as written. Fallback covers every text element,
930
1456
  table cell, list marker, table of contents entry and page template text;
@@ -987,6 +1513,17 @@ before embedding it, alpha included; a JPEG is embedded byte for byte, so
987
1513
  resize it before you embed it (an ActiveStorage variant per drawn size,
988
1514
  preprocessed, keeps a render to a download).
989
1515
 
1516
+ A JPEG's pixels are decoded in Ruby only where pixels are needed, for
1517
+ `to_png` and a `monochrome` render; a PDF embeds its bytes. Baseline,
1518
+ extended and progressive JPEGs are decoded (every chroma subsampling,
1519
+ restart intervals, grey, RGB, YCbCr, CMYK and YCCK, CMYK turned into RGB)
1520
+ to the pixels libjpeg-turbo gives them, and a photo drawn small at a half, a
1521
+ quarter or an eighth of its size (libjpeg's scaled IDCT). A lossless,
1522
+ arithmetic-coded, hierarchical or 12-bit JPEG, or one of more than 33 megapixels, is not:
1523
+ a picture draws it as a crossed box and a monochrome PDF embeds it as it is,
1524
+ each with a warning. The EXIF orientation is not applied, as the PDF does
1525
+ not apply it either.
1526
+
990
1527
  ### Complex scripts: the shaper hook
991
1528
 
992
1529
  Stationery places glyphs itself: one per character, the font's ligatures and single substitutions,
@@ -1046,7 +1583,8 @@ synthetic oblique, a superscript or letter spacing. Glyphs the shaper placed are
1046
1583
  not a character maps to them. Where the ToUnicode map cannot give the text (glyphs out of logical
1047
1584
  order, several glyphs for one cluster, a glyph that already stands for another text, glyph 0) the
1048
1585
  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.
1586
+ reported as a `Warnings::MissingGlyph`, like any other, and under `conformance` with
1587
+ `missing_glyphs: :replace` it is drawn as the font's stand-in at the stand-in's advance.
1050
1588
 
1051
1589
  The limits of a hook:
1052
1590
 
@@ -1062,18 +1600,63 @@ The limits of a hook:
1062
1600
  - Hyphenation and the breaking of a word wider than the line cut the text as written; the pieces are
1063
1601
  shaped as separate words.
1064
1602
  - 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.
1603
+ - Extracted text depends on the reader: the gem's `Inspector` and PDFium get a right-to-left stretch
1604
+ as written, poppler and MuPDF reversed (see the table below). pdf-reader's own `Page#text` drops a
1605
+ `Span` whose first glyph has no advance (a mark drawn first); the `Inspector` does not.
1069
1606
 
1070
1607
  [`examples/shaping/harfbuzz_shaper.rb`](https://github.com/zoolutions/stationery/blob/main/examples/shaping/harfbuzz_shaper.rb)
1071
1608
  is an adapter for HarfBuzz through the [`harfbuzz-ruby`](https://github.com/ydah/harfbuzz) gem
1072
1609
  (`gem "harfbuzz-ruby"`, `require "harfbuzz"`; not the older `harfbuzz` gem, which answers to the same
1073
1610
  `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.
1611
+ was run with harfbuzz-ruby 1.1.0 and HarfBuzz 14.5.0 against Noto Sans Arabic, Amiri and Noto Sans
1612
+ Hebrew and draws joined, right-to-left Arabic with its marks and with left-to-right digits inside it.
1613
+ It cuts a stretch into runs of one direction by its letters alone, not by the Unicode bidirectional
1614
+ algorithm.
1615
+ The base direction of a stretch is its first letter's, so a line of an Arabic paragraph that starts
1616
+ with a number stays right to left.
1617
+ [`examples/shaping/rtl_letter.rb`](https://github.com/zoolutions/stationery/blob/main/examples/shaping/rtl_letter.rb)
1618
+ is a letter in Arabic set with it (`ARABIC_FONT=NotoNaskhArabic-Regular.ttf ruby -Ilib
1619
+ examples/shaping/rtl_letter.rb`); its booking number, in Latin letters from Inter, sits in a `row`
1620
+ beside its Arabic label, since stretches of two fonts on one line are placed left to right.
1621
+
1622
+ What comes out of a shaped PDF depends on who reads it, and no way of writing right-to-left text
1623
+ is read as written by every extractor. Seven texts (Arabic, Hebrew, Arabic with digits in it,
1624
+ Arabic with its marks, an Arabic word in a Latin sentence, a justified paragraph, a line that wraps)
1625
+ were rendered three ways and read back: **A**, what the gem writes, a `Span` around the stretch
1626
+ with its text in logical order as `ActualText` (ISO 32000-1, 14.9.4); **B**, a `Span` for every
1627
+ cluster, in visual order; **C**, no `Span` for a glyph the ToUnicode map gives its text, in visual
1628
+ order, and one per cluster for the others. The cells count the texts that came back as written:
1629
+
1630
+ | Arabic font | | `Inspector` | PDFium | PDFKit | pdf.js | poppler | MuPDF |
1631
+ |---|---|---|---|---|---|---|---|
1632
+ | Amiri (a glyph per letter) | **A** | 7 of 7 | 7 of 7 | 6 of 7 | 4 of 7 | 0 of 7 | 0 of 7 |
1633
+ | | B | 0 of 7 | 0 of 7 | 0 of 7 | 0 of 7 | 4 of 7 | 5 of 7 |
1634
+ | | C | 0 of 7 | 1 of 7 | 6 of 7 | 4 of 7 | 4 of 7 | 5 of 7 |
1635
+ | 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 |
1636
+ | | B | 0 of 7 | 0 of 7 | 0 of 7 | 0 of 7 | 1 of 7 | 1 of 7 |
1637
+ | | C | 0 of 7 | 0 of 7 | 1 of 7 | 1 of 7 | 1 of 7 | 1 of 7 |
1638
+
1639
+ - PDFium (153.0.7999, what Chrome reads with, through pypdfium2 5.13) and the gem's `Inspector`
1640
+ return `ActualText` as it is: A reads as written. Without it (C) PDFium returned the words of a
1641
+ right-to-left line in reverse order.
1642
+ - poppler (`pdftotext` 26.09) and MuPDF (`mutool` 1.28.5) reorder what they read, `ActualText`
1643
+ included: A comes back reversed, B and C in Amiri as written, but for Arabic with digits or
1644
+ marks and, in poppler, a justified paragraph.
1645
+ - pdf.js (pdfjs-dist 6.3.289) and PDFKit (macOS 27, what Preview reads with) returned the same for
1646
+ A and C, which differ in nothing but `ActualText`: they read the glyphs through the ToUnicode
1647
+ map and reorder them. A cluster of several glyphs comes back with its text once per glyph, so a
1648
+ letter with a mark is doubled, and so is every dotted letter of the Noto Arabic families (Sans,
1649
+ Naskh and Kufi draw a letter as two glyphs). Only `ActualText` carries the text of such a font.
1650
+ - The Hebrew text (Noto Sans Hebrew) is the same in both halves of the table.
1651
+ - veraPDF 1.30.2 passes all 42 renders as PDF/UA-1, so the rules of the standard do not choose.
1652
+ - Acrobat, Preview itself and screen readers were not tested.
1653
+
1654
+ The gem keeps A: it is what the specification describes, the one PDFium reads as written, and
1655
+ the only one that holds with a font whose glyphs are not letters. None of the three is read as
1656
+ written by poppler or MuPDF and by PDFium.
1657
+ [`examples/shaping/extraction_matrix.rb`](https://github.com/zoolutions/stationery/blob/main/examples/shaping/extraction_matrix.rb)
1658
+ renders the texts, holds B and C as experiments and writes the table again, with what to install
1659
+ at its head.
1077
1660
 
1078
1661
  ## Testing
1079
1662
 
@@ -1106,6 +1689,8 @@ RSpec.describe InvoicePdf do
1106
1689
  it { is_expected.to have_no_warnings }
1107
1690
  it { is_expected.to have_pdf_language("en") } # the catalog /Lang from `metadata lang:`
1108
1691
  it { is_expected.to have_page_labels(%w[i ii 1 2]) } # from `page_labels`
1692
+ it { is_expected.to have_print_preference(scaling: :none, copies: 2) } # from `print`
1693
+ it { is_expected.to have_pdf_colors("#000000") } # every colour it paints with, from `monochrome`
1109
1694
  it { is_expected.to have_attachment("factur-x.xml", mime: "text/xml", relationship: :alternative) }
1110
1695
  it { is_expected.to have_conformance(:pdf_a3b) } # the level claimed in XMP, from `conformance`
1111
1696
  it { is_expected.to have_factur_x(profile: :en16931) } # the e-invoice XML, named in XMP and embedded
@@ -1133,6 +1718,8 @@ class InvoicePdfTest < Minitest::Test
1133
1718
  assert_no_pdf_warnings pdf
1134
1719
  assert_pdf_language pdf, "en"
1135
1720
  assert_page_labels pdf, %w[i ii 1 2]
1721
+ assert_print_preference pdf, scaling: :none, copies: 2
1722
+ assert_pdf_colors pdf, ["#000000"]
1136
1723
  assert_pdf_attachment pdf, "factur-x.xml", mime: "text/xml"
1137
1724
  assert_pdf_conformance pdf, :pdf_a3b
1138
1725
  assert_factur_x pdf, profile: :en16931
@@ -1149,7 +1736,8 @@ titles. The RSpec matchers compose like the built-ins: `.and` / `.or`, and insid
1149
1736
  `all`, `include` or `match`. For anything else, `Stationery::Testing::Inspector.new(subject)`
1150
1737
  exposes `text`, `page_texts`, `page_count`, `links`, `internal_links`,
1151
1738
  `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`
1739
+ `lang`, `page_labels`, `print_preferences` (`{ scaling: :none, pages: [1..3] }`), `colors`
1740
+ (`["#000000", "#FF0000"]`), `attachments`, `conformance` (`[:pdf_a3b, :pdf_ua1]`), `factur_x`
1153
1741
  (`{ profile:, filename:, version:, xml: }`), `signatures` (`[{ field:, name:, reason:, location:,
1154
1742
  signed_at:, subfilter:, byte_range:, signer:, valid:, timestamp: }]`, the timestamp `nil` or
1155
1743
  `{ time:, tsa:, valid: }`), `warnings`, `tagged?`,
@@ -1159,6 +1747,12 @@ read from its marked content: `[type, "text"]`, `[type, [children]]` (its own te
1159
1747
  between the children, as for a `P` holding a `Link`) or `[type]` when empty; a
1160
1748
  `Figure` reads as its alt text.
1161
1749
 
1750
+ `colors` is every colour the page content and the form XObjects it draws set with `g`, `rg` and `k`
1751
+ (and their stroking forms), once each and sorted, as `"#RRGGBB"` (CMYK converted without a profile),
1752
+ with `"shading"` for a gradient and `"image"` for a bitmap that is not one bit of grey; a one-bit grey
1753
+ image, as `monochrome` embeds, paints black and white only and adds nothing. Opacity and form
1754
+ fields are not read. `have_pdf_colors("#000000")` and `assert_pdf_colors` hold the whole list.
1755
+
1162
1756
  `text` and `page_texts` are the text of the page content as pdf-reader lays it out, line by line. A
1163
1757
  `Span` with `ActualText` (a stretch a shaper reordered, characters no font has) reads as that text,
1164
1758
  once, whatever glyphs it shows.
@@ -1173,6 +1767,29 @@ of every widget that is not hidden, in the state the widget is in, and never a b
1173
1767
  text is what it always was, and the failure of `have_pdf_text` over a text that a field shows says
1174
1768
  so.
1175
1769
 
1770
+ `Inspector#layout` is what is on each page and what the file says of itself, as plain data in a
1771
+ stable order: what `stationery inspect` prints (see [CLI](#cli)), for a spec that asks where
1772
+ something landed. Places are in points from the top-left corner of the page, as the gem's API
1773
+ speaks, rounded to a tenth; a text line's `y` is its baseline.
1774
+
1775
+ ```ruby
1776
+ layout = Stationery::Testing::Inspector.new(InvoicePdf.new(invoice)).layout
1777
+ layout[:pages].first[:text].first
1778
+ # => { x: 44.0, y: 65.5, font: "OpenSans-Bold", size: 22.0, text: "Invoice INV-2026-042" }
1779
+ layout[:pages].first.keys # => [:number, :label, :width, :height, :text, :images, :links, :fields]
1780
+ layout.keys # => [:metadata, :conformance, :tagged, :print, :outline, :attachments,
1781
+ # :signatures, :structure, :warnings, :pages]
1782
+ ```
1783
+
1784
+ 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
1785
+ own; lines read top to bottom, then left to right. An image is `{ x:, y:, width:, height:, pixels:
1786
+ [w, h] }` in the order drawn (the rectangle it fills, before any clip); a link has its `uri:`, or the
1787
+ `page:` and `top:` it goes to; a field has its full `name:`, `type:` (`:text`, `:choice`,
1788
+ `:checkbox`, `:radio`, `:button`, `:signature`), `value:` and, for a button, the `state:` that turns
1789
+ it on. `outline` is the bookmarks as `{ title:, page:, top:, children: }`. `metadata` leaves out the
1790
+ dates, which change with every render, so two renders of one document have the same layout.
1791
+ `warnings` are the render's messages when the subject is a document.
1792
+
1176
1793
  ## Why not Prawn, Chrome or Typst?
1177
1794
 
1178
1795
  - **Prawn** is an imperative cursor API: every document does its own layout
@@ -1225,26 +1842,28 @@ events attached is the most useful thing to put in an issue.
1225
1842
  prawn-table, and with [sghtmltopdf](https://github.com/waka/sghtmltopdf) 0.5.1, an
1226
1843
  HTML-to-PDF engine written in Rust, given HTML/CSS twins of the same documents. Every
1227
1844
  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:
1845
+ Ruby 3.4.2 +YJIT, 29 September 2026 (0.12.0), warm caches, the best of three runs:
1229
1846
 
1230
1847
  | Document | Engine | Best render | Renders/s | Objects allocated | PDF bytes | Pages |
1231
1848
  |---|---|---:|---:|---:|---:|---:|
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.
1849
+ | Invoice (`examples/invoice.rb`) | Stationery | 11.3 ms | 79.7 | 27,531 | 26,601 | 1 |
1850
+ | | Prawn | 17.6 ms | 49.0 | 88,558 | 33,034 | 1 |
1851
+ | | sghtmltopdf | 3.2 ms | 293.8 | 53 | 36,693 | 1 |
1852
+ | Table, 1,500 rows × 5 columns, repeating header | Stationery | 205 ms | 4.46 | 1,276,408 | 133,756 | 46 |
1853
+ | | Prawn | 1,299 ms | 0.74 | 6,901,819 | 2,689,487 | 40 |
1854
+ | | sghtmltopdf | 94 ms | 10.05 | 53 | 369,338 | 45 |
1855
+ | Cover photo and six illustrated sections | Stationery | 6.8 ms | 136.3 | 25,977 | 41,533 | 3 |
1856
+ | | Prawn | 26.9 ms | 31.0 | 102,961 | 55,391 | 2 |
1857
+ | | sghtmltopdf | 6.6 ms | 140.2 | 53 | 43,031 | 2 |
1858
+
1859
+ sghtmltopdf is the fastest on text and tables: 3.5x Stationery on the invoice and 2.2x on the
1860
+ table, and level with it on the photo document (6.6 against 6.8 ms). That is native code against
1861
+ Ruby, and its 53 objects are the binding's: the engine allocates outside the Ruby heap. Stationery
1862
+ is 1.6x, 6.3x and 4.0x faster than Prawn and writes the smallest files of the three (28%, 64%
1863
+ and 3% smaller than sghtmltopdf's). Stationery compresses content streams; Prawn does not by
1864
+ default, hence its larger files. Against 0.11.0, the same three renders allocate 30% to 51% fewer
1865
+ objects and take 14%, 48% and 24% less time. Page counts differ where the engines' line heights
1866
+ and table padding do.
1248
1867
  "Best render" is the fastest single render, the figure other work on the machine disturbs
1249
1868
  least; renders per second is the average over five seconds.
1250
1869
 
@@ -1261,14 +1880,31 @@ without its gem.
1261
1880
 
1262
1881
  A lossless WebP is decoded in Ruby when it is first loaded, which a JPEG or an opaque PNG
1263
1882
  (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.
1883
+ above, by what is in it, and the image cache keeps it for the renders that follow. A JPEG is
1884
+ decoded only for `to_png` or a monochrome render, once per process and scale: a 1000 × 1000
1885
+ 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
1886
+ size a label or a preview needs (progressive: 3.4 s and 1.4 s; about four times as long
1887
+ without YJIT).
1265
1888
 
1266
1889
  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
1890
+ renders fourteen fixed documents and compares the objects each render allocates, its
1268
1891
  page count and its bytes with `benchmark/baseline.json` (allocations may grow 3%,
1269
1892
  bytes 1%, pages not at all). A change that moves them on purpose records a new
1270
1893
  baseline with `bundle exec rake metrics:update` and says why in the commit.
1271
1894
 
1895
+ | Document | What it holds |
1896
+ |---|---|
1897
+ | `invoice`, `flyer`, `form` | The examples of those names: a table with a footer, images and drawings, form fields |
1898
+ | `table` | 1,500 rows × 5 columns with a repeating header, 46 pages |
1899
+ | `text`, `text_hyphenated`, `text_streamed` | Ten pages of headings and paragraphs: as they are, justified and hyphenated, and written to a block |
1900
+ | `article` | Floats: `examples/article.rb` |
1901
+ | `newsletter` | `columns`: `examples/newsletter.rb` |
1902
+ | `webp` | A lossless WebP of 320 × 240 px, decoded in the render that is measured |
1903
+ | `html` | `html` with a stylesheet, inline styles, a floated image, a table, lists and two columns, five pages |
1904
+ | `pdf_ua` | A tagged report under `conformance :pdf_ua1`, six pages |
1905
+ | `text_incremental` | The text document with a footer, rendered with `incremental` to a block |
1906
+ | `text_shaped` | The text document through a `shaper` written in Ruby, which answers the font's own glyphs |
1907
+
1272
1908
  `bundle exec rake memory` reports what long documents hold while they render
1273
1909
  (`PAGES=5000` for more than its 1,000 pages): the peak resident set size of a render in
1274
1910
  a fresh process, and what is alive when building, pagination and writing end. It
@@ -1283,9 +1919,11 @@ Fonts: no variable fonts (including CFF2) and no WOFF2 (it needs Brotli; convert
1283
1919
  nothing and scripts that need contextual shaping (Arabic, Indic, Thai) draw glyph by glyph unless the
1284
1920
  application brings a shaper (`shaper`, see [the shaper hook](#complex-scripts-the-shaper-hook): the gem
1285
1921
  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
1922
+ in the chain has are drawn as `.notdef` and reported (under a conformance level they raise, or are drawn
1923
+ as a stand-in with `missing_glyphs: :replace`). Text runs left to right: a shaper orders the glyphs
1287
1924
  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
1925
+ ideographs, but there is no vertical layout), and poppler and MuPDF extract shaped right-to-left text
1926
+ reversed (see [the matrix](#complex-scripts-the-shaper-hook)); hyphenation
1289
1927
  patterns are bundled for English, German and Swedish only (a soft hyphen works in any language),
1290
1928
  and justification only widens spaces.
1291
1929
 
@@ -1295,7 +1933,7 @@ and reported, text inside a `clipPath` does not clip, and the shapes of a clip p
1295
1933
  path, so overlapping shapes wound in opposite directions cancel where they overlap.
1296
1934
  Images are JPEG, PNG (non-interlaced) and lossless WebP (not lossy or animated WebP, and no more
1297
1935
  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
1936
+ 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
1937
  borders and widths, page breaks; see [What CSS is read](#what-css-is-read)), not a layout
1300
1938
  engine's worth: no `display`, positioning, `font-family`, `auto` or negative margins or selectors
1301
1939
  with combinators, and floats for images only, with their `margin` around them.
@@ -1309,20 +1947,33 @@ height balanced for it and the columns after it, so one that ends above a block
1309
1947
  split may stay shorter than the column after it. It has columns of one width, nothing spanning
1310
1948
  them (end the block, write the full-width content, start another) and no column break of its
1311
1949
  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`
1950
+ wraps around floated images and boxes, along their rectangles, never along a shape; the text
1951
+ after floats cut by a page break goes to the next page with the float that moved, never beside
1952
+ those that stayed; beside a float
1953
+ a table, a row, `columns` and a box with a size of its own are blocks of the width that is left,
1954
+ all the way down, where a box with a background wraps: a table resolves its column widths from
1955
+ its width, a row shares its width between its columns and `columns` divides it, and none of them
1956
+ can be laid out again at another width from some row on, which widening below the float would
1957
+ take (see [Floats](#floats)). A table reads its rows as pages reach them only from an Enumerator
1958
+ with a width for every column, and then refuses what needs its end (a row counted from the end, a
1959
+ cell spanning rows); in a `row`, in a box with `break_inside: :avoid` or beside a float it is read
1960
+ whole first (see [Large documents](#large-documents)). Link and form-widget rectangles stay in page space inside `rotate`
1317
1961
  and `transform`, and `shadow:` is stacked rectangles, not a blur.
1318
1962
 
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):
1963
+ 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),
1964
+ no JavaScript and no action but links and the print dialog (`print dialog: :on_open`). Under PDF/UA-1 a
1965
+ link in the header row a table repeats on its next pages, and `canvas.link` without a `tag:`, raise: they
1966
+ are outside the structure tree. A render carries one signature (`/ETSI.CAdES.detached`, RSA or EC with SHA-256):
1321
1967
  no long-term validation data (PAdES B-LT), no second signature and no signing
1322
1968
  of a file that already exists, all of which need incremental updates.
1323
1969
  Form fields are set in the document's fonts, but text typed into one is drawn by the viewer:
1324
1970
  characters outside the glyphs the field kept (ASCII and Latin-1) use the viewer's own font.
1325
1971
 
1972
+ Pictures and labels: `to_png` and `to_zpl` draw no form fields (their widget is the viewer's), and
1973
+ `to_zpl` sends each page as one graphic field, with only barcodes as the printer's own commands (no
1974
+ `^GB` rules or boxes, no printer fonts). The labels were compared with `to_png` and rendered by a ZPL
1975
+ viewer, not printed by the gem's specs.
1976
+
1326
1977
  ## License
1327
1978
 
1328
1979
  MIT. The PDF writer, TrueType subsetter and image decoders are derived from