stationery 0.10.1 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/README.md +298 -37
  4. data/lib/stationery/builder.rb +9 -0
  5. data/lib/stationery/canvas/text.rb +8 -2
  6. data/lib/stationery/css/stylesheet.rb +9 -1
  7. data/lib/stationery/document.rb +56 -14
  8. data/lib/stationery/elements.rb +31 -3
  9. data/lib/stationery/errors.rb +5 -1
  10. data/lib/stationery/fonts/font.rb +49 -2
  11. data/lib/stationery/fonts/font_book.rb +14 -5
  12. data/lib/stationery/fonts/shaped_run.rb +160 -0
  13. data/lib/stationery/fonts/shaping.rb +63 -0
  14. data/lib/stationery/forms/typeface.rb +10 -3
  15. data/lib/stationery/html/css.rb +68 -8
  16. data/lib/stationery/html/tree_builder/blocks.rb +14 -4
  17. data/lib/stationery/images/images.rb +6 -6
  18. data/lib/stationery/images/resampled.rb +3 -2
  19. data/lib/stationery/images/webp/bit_reader.rb +78 -0
  20. data/lib/stationery/images/webp/container.rb +62 -0
  21. data/lib/stationery/images/webp/image_stream.rb +168 -0
  22. data/lib/stationery/images/webp/lossless.rb +101 -0
  23. data/lib/stationery/images/webp/predictor.rb +118 -0
  24. data/lib/stationery/images/webp/prefix_code.rb +168 -0
  25. data/lib/stationery/images/webp/transforms.rb +93 -0
  26. data/lib/stationery/images/webp.rb +71 -0
  27. data/lib/stationery/layout/box/wrapping.rb +52 -0
  28. data/lib/stationery/layout/box.rb +38 -12
  29. data/lib/stationery/layout/columns/balancer.rb +66 -0
  30. data/lib/stationery/layout/columns/leveller.rb +65 -0
  31. data/lib/stationery/layout/columns/pour.rb +54 -0
  32. data/lib/stationery/layout/columns.rb +208 -0
  33. data/lib/stationery/layout/floated.rb +50 -0
  34. data/lib/stationery/layout/flow/floating.rb +51 -0
  35. data/lib/stationery/layout/flow/placement.rb +148 -0
  36. data/lib/stationery/layout/flow/splitter.rb +194 -0
  37. data/lib/stationery/layout/flow.rb +20 -117
  38. data/lib/stationery/layout/image.rb +1 -1
  39. data/lib/stationery/layout/list_item.rb +30 -9
  40. data/lib/stationery/layout/mark.rb +3 -1
  41. data/lib/stationery/layout/node.rb +14 -0
  42. data/lib/stationery/layout/paginator.rb +7 -2
  43. data/lib/stationery/layout/table.rb +52 -6
  44. data/lib/stationery/layout/text.rb +31 -12
  45. data/lib/stationery/minitest.rb +6 -3
  46. data/lib/stationery/page.rb +26 -1
  47. data/lib/stationery/page_templates.rb +1 -1
  48. data/lib/stationery/pdf/assembler.rb +20 -4
  49. data/lib/stationery/pdf/conformance.rb +16 -3
  50. data/lib/stationery/pdf/page_sealer.rb +35 -0
  51. data/lib/stationery/rich/nodes.rb +4 -3
  52. data/lib/stationery/rich/renderer/boxes.rb +55 -12
  53. data/lib/stationery/rich/renderer.rb +8 -5
  54. data/lib/stationery/rich/styles.rb +1 -1
  55. data/lib/stationery/shaper.rb +109 -0
  56. data/lib/stationery/tagging/element.rb +6 -0
  57. data/lib/stationery/tagging/tree.rb +53 -2
  58. data/lib/stationery/testing/field_page.rb +89 -0
  59. data/lib/stationery/testing/inspector.rb +12 -2
  60. data/lib/stationery/testing/marked_text.rb +124 -18
  61. data/lib/stationery/testing/matchers.rb +26 -14
  62. data/lib/stationery/text/exclusions.rb +43 -0
  63. data/lib/stationery/text/line.rb +7 -2
  64. data/lib/stationery/text/paragraph.rb +69 -10
  65. data/lib/stationery/text/wrapper/remainder.rb +59 -0
  66. data/lib/stationery/text/wrapper.rb +54 -10
  67. data/lib/stationery/version.rb +1 -1
  68. data/lib/stationery/warnings.rb +10 -1
  69. data/lib/stationery.rb +11 -0
  70. metadata +25 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 94c8fd60adf62c2f1ac3dd92b4a45b5b4254d11284c255c37c86f4d7887d0f14
4
- data.tar.gz: 704273654b825478068079526b21ec8b4f32c9b1b93d4f5c4854a47b22e6f0b7
3
+ metadata.gz: b208319414f7dea1a6af8a89b47aec788e1135ace442031a1d05623240483d7c
4
+ data.tar.gz: f9d15f729afcfd2fa6861b01b365462b4a5d413a4ce9b6fb101c2b7c3b9f68db
5
5
  SHA512:
6
- metadata.gz: 07175d385d770227a5d5af7f542f608d86215ecf3172852a7f75738c426273dfe75fc272db81b929136be496e9b0b7394e1febb5b2a326ec2e06d53b012242e7
7
- data.tar.gz: 864623a98fc783adecd1f8e51a7ae85bbeb1a2bd7c393a6cf72fcf8a548d48221333e29d6ca1336a100bda62576869ccae1fce084ec57b3393758226fd85ac4f
6
+ metadata.gz: fca53eecf78b67b65c7bb9b9582d625f6e693ba9ff29264423f9c1028d3ea8b2477d69af7858edde8169b91b592d43a364d4874703cf719173158c09f187f4b1
7
+ data.tar.gz: 9bd72b967a7e1cd32acf650e5662d009cf792e23f1fef9cec3df579b74fad916f2ed4a0d6d96bdd0b0bbefcecc85f7d277510ff69b486c742d0d00ae169ac8da
data/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.11.1 (2026-09-28)
4
+
5
+ What putting 0.11.0 to work found: PDF/UA claims that hold, boxes that stay on their page, columns that fill evenly, and lists that widen below a float.
6
+
7
+ - PDF/UA-1 refuses heading levels that are skipped (ISO 14289-1, 7.4.2; veraPDF rule 7.4.2-1): the first heading of a document is `heading: 1` and a heading is at most one level below the heading before it, so `heading: 1` followed by `heading: 3`, or a document that starts at `heading: 2`, raises `Stationery::ConformanceError` with an issue per heading (`heading 3 on page 2 skips a level: heading 2 is the deepest that may follow heading 1`) instead of writing a file that claims a level it does not keep. Going back up is free (`3`, then `1`), and a heading that skipped is what the next one is measured against. Headings are read in the order of the structure tree, which is what a validator follows, inside sections, lists, table cells, columns and floats, from `text(heading:)`, `html` and `markdown` alike; the headings of headers, footers and page templates are artifacts and a heading without text is not written, so neither counts. In a tagged render without the claim it is a `Warnings::SkippedHeading` (`level`, `allowed`, `page`), so `strict` catches it.
8
+ - A blank `alt:` is a missing one: `image(source, alt: "")`, `svg(source, alt: " ")` and a `markdown` image without a description (`![](photo.png)`) are reported as `Warnings::MissingAlt` in a tagged render and raise `ConformanceError` under PDF/UA-1 (ISO 14289-1, 7.3; veraPDF rule 7.3-1 fails an empty `/Alt`), where only a missing `alt:` was caught. An `alt:` of whitespace alone is refused as well, although veraPDF accepts it: it describes as little as the empty one. `alt: false` still marks decoration, which is an artifact and needs no description, and the message of `MissingAlt` now names it: `image on page 1 has no alt: text (alt: false marks decoration)`.
9
+ - In `html`, `<img alt="">` is decoration, as HTML means it: an `alt` attribute that is there and empty (or whitespace alone) draws the image as `alt: false` does, an artifact outside the structure tree, floated or not, so it neither warns nor raises. An `<img>` without an `alt` attribute is still a missing description. Markdown has no way to say that an image is decorative, so `![](photo.png)` counts as a description forgotten: use `html` or the `image` element for decoration.
10
+ - A `box` that splits across pages no longer runs over the page by the padding and border that close it. A head is open at the bottom, so its content was cut at the height less the top of the box alone: content that ended on the page by that measure was placed with the bottom padding under it, up to that much over the edge, and reported as an `Overflow`. The last of the content now goes to the next page with what closes the box; content that cannot be cut there moves whole, and first on a fresh page it is kept and reported as before. Boxes inside boxes added their paddings up: 29 of 200 random flows of nested padded boxes overflowed, none do. A box that did not overflow is laid out as it was.
11
+ - Balanced `columns` fill evenly: ten lines in three columns are 4, 3 and 3 (they were 4, 4 and 2, the last column taking what was left over), thirteen in four are 4, 3, 3 and 3. At the balanced height the first column takes what fits, and what it leaves is balanced through the columns after it in the same way, down to the last two, so what cannot be shared goes to the earlier columns. Every column is still cut where a page would cut it, so `orphans:`/`widows:`, `keep_with_next`, `break_inside: :avoid` and blocks that cannot split hold as they did; the columns filled in order are kept where the levelled ones would not place everything, would change the height of the block, or would have a later column taller than an earlier one where there was none. The block is exactly as tall as it was, so what follows it stays where it is, and a block of two columns, `balance: false`, the full pages of a block that continues and documents without `columns` are byte for byte what they were. A block is levelled once, when it is placed, with at most `(count - 2) * 41` pours more than its balancing: 700 paragraphs in two and three columns render in the time they did. `Layout::Columns::Leveller`, `Layout::Columns::Pour#first`.
12
+ - Beside a float, a list item and a `box` that paints nothing of its own wrap what they hold: they keep the full width of their flow, and their lines are narrow beside the float and take the full width below it, in the middle of a paragraph if need be. They were blocks of the width the float left, all the way down, so a long item or box beside a short image stayed narrow below it. That is a box without `background:`, `border:`, `shadow:` or `link:` and without `width:`, `height:`, `rotate:`, `overflow:` or `valign:`; its padding lies under the float, as a block's does in CSS. A list item keeps its indent from the float, with the marker beside its first line. They split across pages as they did (`break_inside:`, `keep_with_next:`, `orphans:` and `widows:` inside them), the lines carried over are wrapped again at the full width, and a tagged PDF reads them in the order they were written. In `html`, the items of a list beside a floated `img` wrap, and so does a block indented by `margin-left` or `margin-right`, whose margin lies under the float. A box with a background, a border, a shadow or a link stays a block of the width that is left: it is painted after the float written before it, and at the full width it would paint over the float. So do a `table`, a `row` and `columns`, whose columns depend on their width. Documents without floats are byte for byte what they were, and allocate what they did. `Layout::Box::Wrapping`, `Text::Exclusions#inset`.
13
+ - `html` reads `margin-left` and `margin-right`, which were reported as `Warnings::UnsupportedCss`: lengths in `px` or `pt`, as `margin-top` takes them. On a floated `img` they are that side of the space kept around it, so `style="float: right; margin-left: 12pt"` keeps 12 pt between the image and the text. On a block (`p`, `div`-like containers, headings, lists, tables, `blockquote`, `pre`) they indent it from that side, its background and padding inside the margin; the block breaks across pages where it did, and a heading or a paragraph keeps its `keep_with_next`. `margin` and its sides are read in the order of the cascade, specificity first and position then: a side wins over a `margin` declared before it, and a `margin` takes back the sides declared before it. `auto`, `em` and `%` are reported as they were, and so is a negative `margin-left` or `margin-right`. HTML without margins renders byte for byte as before. `CSS::Stylesheet#matching`.
14
+ - Testing: `Inspector#text` and `#page_texts`, and so `have_pdf_text`, `have_pdf_text_on_page` and `assert_pdf_text`, read a sequence with `/ActualText` as that text, once, whatever the glyphs inside it advance. A shaped stretch whose first glyph has no advance (a mark drawn before its base, as HarfBuzz answers for Arabic) was missing from the text: pdf-reader gives the `ActualText` to the first glyph shown and drops a glyph without a width. The helpers now read a page through the receiver `Inspector#structure` already read it with: glyphs outside such a sequence are laid out by pdf-reader as before, and the sequence is one run from where its first glyph is shown to where it ends. The text of a page without `ActualText` is what it was. `ActualText` and the structure tree are read with pdf-reader 2.12 and later, where they needed 2.16. For a PDF Stationery did not write, `untagged_text` and `structure` follow a form XObject the page draws, as `text` does. The PDFs written are unchanged.
15
+ - Testing: `fields: true` reads what form fields show. A field's value is drawn by its widget's appearance, a form XObject of the annotation and no part of the page content, so `have_pdf_text("Astrid")` does not find the value of `text_field "name", value: "Astrid"`. `have_pdf_text("Astrid", fields: true)`, `have_pdf_text_on_page(1, "Astrid", fields: true)`, `assert_pdf_text pdf, "Astrid", fields: true`, `refute_pdf_text`, `Inspector#text(fields: true)` and `#page_texts(fields: true)` read the normal appearance of every widget with the page content, each where the widget is on its page: text fields (every line of `multiline:`, every cell of `comb:`), a select's value, a signature field's label, characters no font has from their `ActualText`, a field set in the standard Helvetica, in an encrypted document as in a plain one. A widget that is hidden (`Hidden`, `NoView`) is not read, and of a button's appearances the one its state names, never `Off`. It is an option and not the default, so no text a spec reads today changes: the text of a form with its fields is other text (more words, and other blank lines between those of the page content), which `not_to have_pdf_text` and a comparison of `Inspector#text` would notice. Without the option, the failure of `have_pdf_text` over a text that a field shows says so.
16
+ - **Behaviour changes:** in `html`, the left and right of the `margin` shorthand indent a block, as CSS means them: `margin: 0 20px` drew no indent. Top and bottom draw what they drew. A `margin` declared after `margin-top` or `margin-bottom`, or by a more specific rule, takes them back, where the side always won. A negative margin on a floated `img` is drawn as none: it moved the image out of the flow, off the page when it was large, which nothing in a style may do. Beside a float, a list or a box without a background or a border is laid out at the full width with its lines around the float, where it was laid out at the width left beside it; a document with floats and such content may break its pages elsewhere. A render with `conformance :pdf_ua1` whose heading levels skip, or with a figure whose `alt:` is blank, now raises `ConformanceError` (it wrote a file veraPDF rejected); a tagged render reports both as warnings, so with `strict` it raises `WarningsError`. PDF/A alone asks for neither (level B does not ask for structure) and raises for neither. In a tagged render an `html` image with `alt=""` is an artifact, where it was a `Figure` with an empty `/Alt`. The message of `MissingAlt`, and with it the issue of the `ConformanceError`, ends in `(alt: false marks decoration)`. A `box` that ran over the page by the padding or border that closes it breaks a line or more earlier, and no longer reports an `Overflow`. Documents that are not tagged, and tagged documents that keep both rules and have no `<img alt="">`, are byte for byte what they were.
17
+
18
+ ## 0.11.0 (2026-09-28)
19
+
20
+ Text wrap around images, balanced columns, lossless WebP, a shaper hook for complex scripts, and far less memory for long documents.
21
+
22
+ - Floats: `image(source, float: :left | :right, margin:)` and `box(float:, margin:, width:) { … }` take an image or a box to the edge of the flow it is written in (page body, box, column, cell), and what follows wraps beside it: every line of a paragraph takes the width left at its own top, aligned and justified in it, and the full width again below the float, mid-paragraph. `margin:` is a number (kept on the sides that face the text) or names the sides as `padding:` does; a floated box needs a `width:` (points, a fraction or `:auto`). Tables, boxes, rows, list items and other blocks go beside the float in the width that is left when they fit, else below it. A second float goes beside the first when it fits, else below; left and right floats coexist; a paragraph whose widest word does not fit starts below them. The flow is as tall as its floats. A float never splits: it moves to the next page with the content that wraps around it, and the lines a paragraph carries over a page break are wrapped again at the full width. In a tagged PDF the float stays where it was written, a `Figure` or the box's `role:`. `html` reads `float: left|right` on `img` and `<img align="left|right">`, with the image's CSS `margin` or `styles: { img: { float_margin: 8 } }`; `float` on any other element is still reported as `Warnings::UnsupportedCss`. `examples/article.rb` shows a floated photo and a pull quote. Documents without floats are byte for byte what they were, and allocate what they did.
23
+ - `columns(count: 2, gap: 12, balance: true, rule: nil) { … }`: one flow poured through columns, newspaper style (a `row` is columns side by side with content of their own). The content is laid out at the column width and cut where a page break would cut it: between children, between the lines of a paragraph (`orphans:`/`widows:`), inside boxes and tables by their own rules, never inside `break_inside: :avoid`, and `keep_with_next` keeps a heading in the column of what follows it. `balance: true` ends the columns at nearly the same height where the content ends (the last page of the block, and before a `page_break` inside it) by searching for the shortest column height that holds everything; `balance: false` fills each column before the next. A block that does not fit fills the height left on the page and continues on the next; below other content it starts only when every column takes something, and otherwise moves to the next page. What follows the block starts below its tallest column. `rule: true | { color:, width: }` draws a line between the columns (an artifact in a tagged PDF). Works inside boxes and inside another `columns`; a tagged PDF reads column 1, then column 2, with a paragraph split across columns as one `P`. `html` maps `column-count`, `columns: <n>` and `column-gap` on `div`-like containers, `p`, headings, lists, tables, block quotes and `pre` to it. `examples/newsletter.rb`. `Layout::Columns`, `Layout::Columns::Pour`, `Layout::Columns::Balancer`.
24
+ - Lossless WebP: `image "photo.webp"` embeds a lossless WebP (VP8L, in a plain or an extended `VP8X` container, whose colour profile and metadata chunks are skipped), decoded in Ruby with no dependency: prefix codes, backward references, the colour cache and the predictor, cross-colour, subtract-green and colour-indexing transforms. It is embedded like a PNG, RGB as a Flate stream with the alpha channel as a soft mask when a pixel is not opaque, and takes everything `image` offers, `downscale: true` included. A 1000 × 1000 px image decodes in 0.2 to 0.4 s, once per process. Lossy and animated WebP still raise `UnsupportedImage`, now naming which it is, as do truncated and damaged files and images of more than 33 megapixels (2^25 pixels). `image.stationery` reports `format: "WebP"`; the message for an unknown format reads "only JPEG, PNG and lossless WebP images are supported".
25
+ - Shaper hook: `shaper MyShaper` at class level (inherited; `shaper nil` takes it away) or `to_pdf(shaper:)` hands every text to an object answering `call(text, font, size:, features:, language:)`, so an application with HarfBuzz can set Arabic, Hebrew, Indic or Thai text. It answers `Stationery::Shaper::Glyph`s (`gid`, `advance`, `x_offset`, `y_offset` in font units, `cluster`) in visual order, or `nil` to decline a text; `font` is a `Stationery::Shaper::Face` (`path`, `index`, `data`, `units_per_em`, `postscript_name`, `glyph_count`). Line breaking measures words through the shaper, then every stretch of a line in one font and style is shaped whole, measured and drawn from that one answer: advances as `TJ` adjustments against the font's own widths, offsets as pen movements and text rise. Shaped glyphs are embedded whatever they map to, and glyphs the ToUnicode map cannot speak for (out of logical order, several for one cluster, standing for another text already) are shown in a `Span` with the characters as `ActualText`. Glyph 0 is a `Warnings::MissingGlyph`; an answer that cannot be drawn raises `Stationery::ShaperError`. The gem shapes nothing itself and has no new dependency: there is no reordering across stretches or lines, justification widens spaces only, form fields are not shaped, and poppler and MuPDF return a right-to-left `ActualText` reversed. `examples/shaping/harfbuzz_shaper.rb` is an adapter for the `harfbuzz-ruby` gem. The memo of shaped runs starts over after 64 KB of text, like the font's own. Without a shaper every render is byte for byte what it was.
26
+ - Long documents hold far less while they render, and every form of `to_pdf` writes the bytes it wrote. A page's nodes are let go once it is painted (the root flow, the builder and `FontBook`'s record of split runs no longer keep them), the fonts' shape and metrics memos start over after 64 KB of text instead of keeping every line drawn, a table without rowspans measures a row when a page reaches it (`Node#height_within`), a flow copies the children still to come once a page instead of once a child, and a page nothing paints on again keeps its deflated stream instead of its operators. Peak resident memory of 1,000 pages of text 391 → 111 MB and of 5,001 pages 1,556 → 350 MB; a table of 33,000 rows 901 → 653 MB and one of 165,000 rows 3,792 → 2,952 MB (what is alive when pagination ends: 216 → 17, 1,062 → 41, 494 → 28 and 2,437 → 65 MB). What is left is the document as it was built, which for a table is a node for every cell. `bundle exec rake memory` measures it.
27
+ - `to_pdf(incremental: true)`, or `incremental` at class level, writes each page's content as soon as the page is painted, to a block before the next page is painted. For documents with headers, footers, page templates or a table of contents, which otherwise hold every page's operators until the page count is known: 5,189 pages with a footer peak at 446 MB instead of 614 MB (1,643 MB before), with 20 MB alive at the end of pagination instead of 180 MB. The file has its page contents first and fonts, page tree and catalog last, and what is painted once every page is known is a content stream of its own under or over the body, so it is a little larger (1.5 → 1.7 MB for 1,039 pages). A render with `conformance:` or `sign:`, and a `strict` one that goes to a block, take the usual path, since they must be checked before a byte is written; to a block an error on a late page leaves the start of a file. A document without headers, footers, templates or contents gains nothing from it.
28
+ - A character no font has raises under a conformance level: text may not reference `.notdef` under PDF/UA-1 (ISO 14289-1, 7.21.8) nor under PDF/A-2b and PDF/A-3b (ISO 19005-2/3, 6.2.11.8; veraPDF rules 7.21.8-1 and 6.2.11.8-1), so `conformance` now raises `Stationery::ConformanceError` with an issue per character and family (`"☃" (U+2603) is in no font of Brand: add a font or font_fallbacks that covers it`) instead of writing a file that claims a level it does not keep. Body text, page templates and form field values are all covered. Whitespace a font lacks draws as a blank and is accepted, and without `conformance` a missing glyph stays a `MissingGlyph` warning.
29
+ - Form field appearances keep characters no font has, as body text does since 0.10.1: a text field's value (single line, `multiline:` and `comb:` cells), a select's value and a signature field's label wrap each run of `.notdef` glyphs in a `/Span` with `/ActualText`, inside the text object and inside `/Tx BMC … EMC`, so `text_field "name", value: "日本語"` extracts as `日本語`. The appearances were already written this way; it is now specified and documented. The `MissingGlyph` warning and the bytes of values the fonts cover are unchanged. A field made without a font book (`Forms::Field.new` on a bare canvas) still writes a character outside Windows-1252 as `?` without `ActualText`: that is a real glyph, and `/V` holds the value.
30
+ - **Behaviour changes:** a render with `conformance` that draws a character no font has now raises `ConformanceError` (it wrote a file veraPDF rejected), under PDF/A-2b and PDF/A-3b as well as PDF/UA-1. In `html`, `<img align="left|right">` floats the image, where it drew a block of its own. The lines a paragraph carries over a page break are wrapped again when the width they continue at is another. `image` and `box` raise `ArgumentError` for `margin:` without `float:` and for `float:` with `at:`. The message of `UnsupportedImage` for an unknown format names lossless WebP, and a lossy or animated WebP is named as such. `rake verify:conformance` also validates `examples/article.rb` and `examples/newsletter.rb`.
31
+
3
32
  ## 0.10.1 (2026-09-28)
4
33
 
5
34
  - Extracted text keeps characters no font has: each run of `.notdef` glyphs is wrapped in a `/Span` with `/ActualText` holding the written characters, and ToUnicode maps glyph 0 to U+FFFD instead of the first missing character drawn, so `日本語` set in a font without those glyphs copies out as `日本語`, not `日日日`. The `MissingGlyph` warning and the bytes of text the fonts cover are unchanged.
data/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  Pure-Ruby PDF documents built from Phlex-style components. Describe the page
4
4
  with rows, columns, boxes, tables, text and images; a box-layout engine
5
5
  measures, places and paginates them; a small PDF writer embeds subsetted
6
- TrueType and OpenType fonts, JPEG/PNG images and SVG drawings.
6
+ 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
 
@@ -70,7 +70,7 @@ InvoicePdf.new(invoice).to_pdf # => "%PDF-1.7…" (binary String)
70
70
  InvoicePdf.new(invoice).to_pdf("a.pdf") # also writes a path or an IO
71
71
  ```
72
72
 
73
- `examples/` has a complete, runnable invoice, annual report, letter, packing slip, fillable form, postcard collage, event flyer and Factur-X e-invoice
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
74
74
  ([previews and live PDFs](https://stationery.zoolutions.llc/docs/examples)); `bundle exec rake examples`
75
75
  renders them all, or render one with `stationery render examples/report.rb`.
76
76
 
@@ -81,10 +81,11 @@ renders them all, or render one with `stationery render examples/report.rb`.
81
81
  | `text(string, **style)` | A paragraph. Plain strings are literal. |
82
82
  | `text(string, markup: true)` | Reads `<b> <i> <u> <strikethrough> <sub> <sup> <br> <color rgb=""> <font size="" name=""> <link href="">`; decodes numeric (`&#39;`, `&#x27;`) and HTML 4 named entities once, so escape user data (`ERB::Util.html_escape`) before wrapping it in your own tags: `&lt;b&gt;` stays literal text. |
83
83
  | `text { b "Total"; plain " due" }` | Styled runs in Ruby. Take a block argument (`{ \|t\| t.b @x }`) to keep your own `self`. |
84
- | `box(padding:, background:, border:, radius:, width:, height:, min_height:, overflow:, at:, link:, outset:, break_inside:, decoration:, rotate:, shadow:) { }` | 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. |
84
+ | `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
85
  | `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
+ | `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. |
86
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. |
87
- | `image(path_or_io, width:, height:, fit:, align:, radius:, rotate:, max_ppi:, downscale:)` | JPEG or PNG, 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 to that resolution instead. |
88
+ | `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. |
88
89
  | `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`). |
89
90
  | `wrap(gap:, row_gap:, align:) { }` | Children side by side at their own widths, wrapping onto new rows (chips, tags). |
90
91
  | `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. |
@@ -112,6 +113,55 @@ Text style options: `font`, `size`, `weight` (`:regular`, `:bold`), `style` (`:i
112
113
  ending in a newline and lines without spaces stay left-aligned (tabs are never stretched).
113
114
  Colours are `"#RRGGBB"`, `"RRGGBB"`, `"#RGB"`, `[r, g, b]` (0-255) or `[c, m, y, k]` (0-100).
114
115
 
116
+ ### Floats
117
+
118
+ `float: :left` or `:right` on an `image` or a `box` takes it to that edge of the flow it is written
119
+ in (the page body, a box, a column, a table cell); what follows it in that flow starts at its top
120
+ and wraps beside it, back to the full width below it, in the middle of a paragraph if need be.
121
+
122
+ ```ruby
123
+ image "bay.jpg", float: :left, width: 0.4, margin: 12, alt: "The bay at dawn"
124
+ text story, align: :justify # beside the photo, then below it
125
+
126
+ box(float: :right, width: 170, margin: { left: 16, bottom: 6 }, padding: 10, role: :blockquote) do
127
+ text "The view is everywhere.", size: 13, style: :italic
128
+ end
129
+ text more # around the pull quote
130
+ ```
131
+
132
+ - `margin:` is the space between the float and the text: a number is kept on the sides that face
133
+ the text (the inner side and the bottom); a Hash (`{ left: 16, bottom: 6 }`, `x:`, `y:`) or an
134
+ Array names the sides as `padding:` does. A floated box needs a `width:`: points, a fraction of
135
+ the flow's width or `:auto`. Anything but `:left` and `:right` raises `ArgumentError`.
136
+ - Text (paragraphs, headings, the text inside a `group` or an `html` block) wraps: every line takes
137
+ the width left at its own top and is aligned and justified in it. A paragraph whose widest word
138
+ 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.
150
+ - Several floats: the next one goes beside those already there when it fits, else below them, and
151
+ never above one written before it. Left and right floats share a line with the text between them.
152
+ - The flow that holds a float is at least as tall as the float, so what follows a box, a column or a
153
+ cell starts below the floats inside it.
154
+ - Across pages a float never splits. When it does not fit what is left of the page, or the content
155
+ after it could not start beside it there, it moves to the next page with that content. A
156
+ paragraph beside a float splits between lines as always (`orphans:`, `widows:`); the lines carried
157
+ 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:`.
160
+
161
+ A line beside a float is taken to be as tall as a line of the paragraph's own style when its width
162
+ is looked up, so one much taller word may reach a little past a float's bottom edge before the text
163
+ widens.
164
+
115
165
  ### HTML and Markdown
116
166
 
117
167
  `html` and `markdown` render user content with the elements above; their parsers load on first use.
@@ -133,7 +183,7 @@ end
133
183
  monospace family for inline and block code, otherwise the text font is used), `pre` and
134
184
  `blockquote` (box options), `hr` (rule options), `table` (`cell:` options, `header:` text style),
135
185
  `ul` and `ol` (list options: `gap:`, `indent:`, `marker_gap:`, `marker_color:`, plus `style:` for
136
- `ul` and `format:`/`suffix:` for `ol`), `li` (text style) and `img` (`max_width:`).
186
+ `ul` and `format:`/`suffix:` for `ol`), `li` (text style) and `img` (`max_width:`, and `float_margin:` for a floated image without a CSS margin).
137
187
  - Links are written only for `http`, `https`, `mailto` and `tel` hrefs (and `#anchor`); anything
138
188
  else (`javascript:`, `data:`, a relative path) keeps its text without a link and is reported as a
139
189
  `DroppedLink` warning. `links: %w[http https]` changes the list, `links: :all` keeps every href
@@ -170,14 +220,19 @@ ignored and reported. At-rules are skipped, except that rules inside `@media pri
170
220
  | `text-align` | `left`, `center`, `right`, `justify` | paragraphs, headings, cells, images; inherited from a container |
171
221
  | `background-color` | as `color`, or `transparent` | `p`, `div` and other containers, `blockquote`, `pre`, `table`, `td`, `th` |
172
222
  | `padding`, `padding-top` … `padding-left` | one to four lengths in `px` or `pt` | the same |
173
- | `margin`, `margin-top`, `margin-bottom` | lengths in `px` or `pt`; top and bottom only, added to `gap:` | blocks |
223
+ | `margin`, `margin-top` … `margin-left` | one to four lengths in `px` or `pt`: top and bottom are added to `gap:`, left and right indent the block. A negative margin is drawn as none | blocks, and a floated `img` |
174
224
  | `border` | `1px solid #ccc` in any order, `none` | `table` (every cell), `td`, `th` |
175
225
  | `width` | `px`, `pt`, `%`, `auto` | `img`, `table`, and `td`/`th` (column widths, when every cell of the first row has one) |
226
+ | `float` | `left`, `right`, `none` | `img` only: the text that follows wraps beside it. Its `margin`, with `margin-top` … `margin-left` over it, is kept around it, else `styles: { img: { float_margin: 8 } }` towards the text |
176
227
  | `page-break-before`, `page-break-after`, `break-before`, `break-after` | `always`, `page`, `auto` | blocks |
177
228
  | `page-break-inside`, `break-inside` | `avoid`, `auto` | blocks |
229
+ | `column-count`, `columns` | a number of columns, `auto` (a column width is not read) | `div` and other containers, `p`, headings, lists, tables, `blockquote`, `pre` |
230
+ | `column-gap` | a length in `px` or `pt`, `normal` | the same |
178
231
 
179
- `<font color size>` and `<center>` are read the same way. Everything else (`display`, `float`,
180
- `position`, `font-family`, `line-height`, `em` lengths outside `font-size`, `url()` values,
232
+ `margin` and its sides are read in the order of the cascade: a side wins over a `margin` declared
233
+ before it or by a lesser rule, and a `margin` takes back the sides declared before it.
234
+ `<font color size>`, `<center>` and `<img align="left|right">` (a float) are read the same way.
235
+ Everything else (`display`, `float` on anything but an image, `position`, `font-family`, `line-height`, `em` lengths outside `font-size`, `auto` margins, a negative `margin-left` or `margin-right`, `url()` values,
181
236
  inline backgrounds) is ignored and named in the `UnsupportedCss` warning, so `strict` catches
182
237
  content that expects more than this. No value is ever fetched: `url()` and `@import` are dropped.
183
238
 
@@ -284,7 +339,8 @@ signature_field "signature", label: "Signature of the applicant"
284
339
  - Every widget carries its own appearance, so the form looks the same in every viewer. Text is set
285
340
  in the text style around the field, in the document's own fonts: embedded, with the fallbacks
286
341
  applied per character, so a value in any script the fonts cover is drawn (`/V` holds it as
287
- Unicode). Check marks and radio dots are paths. `NeedAppearances` is set too, so viewers redraw
342
+ 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
288
344
  edited values; ZapfDingbats is listed for the ones that redraw a button's mark, never embedded
289
345
  and never used by the appearances themselves.
290
346
  - A field that can be edited keeps printable ASCII and Latin-1 in its font beyond the value it shows
@@ -301,6 +357,10 @@ signature_field "signature", label: "Signature of the applicant"
301
357
  signer, unless `sign field:` signs it (see [Digital signatures](#digital-signatures)).
302
358
  - `document.fields` returns `{ name => value }` for the last render (a check box's value is `true` or
303
359
  `false`, an unchecked radio group's and a signature field's `nil`). Encrypted documents keep their fields fillable.
360
+ - In tests, `document.fields` is what the form was filled with, and `have_pdf_text("Astrid", fields:
361
+ true)` or `assert_pdf_text pdf, "Astrid", fields: true` finds what a field shows on the page. A
362
+ value is drawn by its widget and is no part of the page content, so the text leaves it out
363
+ without `fields: true` (see [Testing](#testing)).
304
364
 
305
365
  ## Components
306
366
 
@@ -435,7 +495,9 @@ and across page breaks: a paragraph continued on the next page stays one `P`.
435
495
  - `table_of_contents` is `TOC` > `TOCI` > `Link` (the title and its annotation) and `Reference` (the
436
496
  page number).
437
497
  - `html`/`markdown` tag headings `H1`–`H6` (with or without `bookmarks: true`) and block quotes
438
- `BlockQuote`, and give images their `alt`.
498
+ `BlockQuote`, and give images their `alt`. In `html`, `<img alt="">` is decoration as `alt: false`
499
+ is (an `<img>` without the attribute is a description missing). Markdown has no way to say so:
500
+ `![](photo.png)` is a description missing, and decoration goes through `html` or `image`.
439
501
  - Headers, footers and page templates are pagination artifacts; backgrounds, borders and rules drawn
440
502
  outside any element are layout artifacts.
441
503
  - `metadata lang:` writes the catalog's `/Lang`; the title is shown instead of the file name.
@@ -443,8 +505,15 @@ and across page breaks: a paragraph continued on the next page stays one `P`.
443
505
  `dc:title`, `dc:creator`, `dc:description`, `dc:subject`, `dc:language`, the `xmp:` dates and
444
506
  `pdf:Producer`. PDF/A and PDF/UA identification lives there (see
445
507
  [PDF/A and PDF/UA](#pdfa-and-pdfua)); `metadata xmp: false` or `to_pdf(xmp: false)` leaves it out.
446
- - An image or drawing without `alt:` (`Warnings::MissingAlt`) and a missing `lang`
447
- (`Warnings::MissingLanguage`) are warnings, so `strict` catches them.
508
+ - An image or drawing without `alt:`, or with a blank one (`alt: ""`, whitespace alone, which
509
+ veraPDF would accept), is a `Warnings::MissingAlt`; `alt: false` is how decoration is marked.
510
+ - A heading level that is skipped is a `Warnings::SkippedHeading` (`level`, `allowed`, `page`): the
511
+ first heading is `heading: 1` and a heading is at most one level below the heading before it
512
+ (`1`, then `3` skips `2`). Going back up is free (`3`, then `1`). Headings are read in the order of
513
+ the structure tree, inside sections, lists, table cells, columns and floats; those of headers,
514
+ 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.
448
517
  - Untagged documents (the default) are written exactly as before.
449
518
 
450
519
  Check the tree in tests with `have_structure` and `have_tagged_content` (see [Testing](#testing)):
@@ -490,16 +559,23 @@ mislabelled: `ArgumentError` for options that contradict the level, `Stationery:
490
559
  - **PDF/UA-1** (ISO 14289): turns `tagged` on, needs `metadata title:` and `lang:`, writes
491
560
  `pdfuaid:part`, shows the title in the viewer, orders tabs by structure (`/Tabs /S`) and gives
492
561
  every link annotation a description (`/Contents`: the URL, or the target page). A figure without
493
- `alt:` raises; mark decoration with `alt: false`. Encryption is allowed.
562
+ `alt:` or with a blank one raises (7.3); mark decoration with `alt: false`, or `<img alt="">` in
563
+ `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.
494
566
  - Combined, the XMP packet also describes the `pdfuaid` schema to PDF/A (`pdfaExtension:schemas`).
495
567
  - Interactive form fields are allowed: their appearances draw with embedded fonts and paths, every
496
568
  field has a `/TU`, and `NeedAppearances` and ZapfDingbats are left out. Only a field made without
497
569
  a font book (`Forms::Field.new` placed with `canvas.widget`) raises, since it draws with the
498
570
  standard Helvetica.
571
+ - A character no font has raises at every level, naming the character and the family: it draws as
572
+ `.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.
499
574
  - Without `conformance` nothing changes: the output is byte for byte what it was.
500
575
 
501
576
  `bundle exec rake verify:conformance` renders `examples/invoice.rb` as PDF/A-3b, and
502
- `examples/report.rb` and `examples/form.rb` as PDF/A-3b plus PDF/UA-1, and validates them with
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
503
579
  [veraPDF](https://verapdf.org) through Docker (`verapdf/cli`); CI runs it on every push. Validate
504
580
  your own documents the same way:
505
581
 
@@ -634,17 +710,71 @@ gem "stationery", require: "stationery/rails"
634
710
 
635
711
  ### Large documents
636
712
 
713
+ A render holds what it still has to paint. The nodes of a page are let go once the page is
714
+ painted, a table measures its rows as pages reach them, the fonts forget the lines they shaped
715
+ after 64 KB of text, and a page nothing paints on again (no header, footer or page template, no
716
+ contents page number waiting on it) keeps its deflated content stream instead of its operators.
717
+ None of that changes a byte of the file.
718
+
637
719
  `to_pdf { |chunk| … }` streams the file to the block in pieces as it is written and answers the
638
- number of bytes: the first bytes leave sooner and no output buffer is built. Peak memory does
639
- not drop. Layout runs in full before the first byte (pagination has to see every page) and the
640
- laid-out pages are what a render holds: a 938-page document peaks at 187 MB as a String and
641
- 185 MB streamed. The streamed file lists its objects in the order they were written, a page's
642
- before the next page's and fonts, the structure tree and the catalog last; the String keeps them
643
- in numbered order. Both are the same document. A signed document cannot go to a block, since the
644
- signature covers every byte (`ArgumentError`). `to_pdf`, `to_pdf(path)` and `to_pdf(io)` build the
645
- String and return it. In a controller with `ActionController::Live`,
646
- `document.to_pdf { |chunk| response.stream.write(chunk) }` streams a download; `send_pdf` and
647
- `render pdf:` buffer.
720
+ number of bytes: the first bytes leave sooner and no output buffer is built. Every page is laid
721
+ out and painted before the first byte. The streamed file lists its objects in the order they
722
+ were written, a page's before the next page's and fonts, the structure tree and the catalog
723
+ last; the String keeps them in numbered order. Both are the same document. A signed document
724
+ cannot go to a block, since the signature covers every byte (`ArgumentError`). `to_pdf`,
725
+ `to_pdf(path)` and `to_pdf(io)` build the String and return it. In a controller with
726
+ `ActionController::Live`, `document.to_pdf { |chunk| response.stream.write(chunk) }` streams a
727
+ download; `send_pdf` and `render pdf:` buffer.
728
+
729
+ `to_pdf(incremental: true)`, or `incremental` at class level, writes each page's content as soon
730
+ as the page is painted and lets go of it; with a block it is handed over before the next page is
731
+ painted. It is for long documents with headers, footers, page templates or a table of contents:
732
+ those are painted once every page is known, so without it such a document holds the operators
733
+ of every page until then.
734
+
735
+ ```ruby
736
+ class StatementPdf < Stationery::Document
737
+ incremental # or to_pdf(incremental: true) for one render
738
+ footer { |page| text "Page #{page.number} of #{page.count}" }
739
+ end
740
+
741
+ StatementPdf.new(account).to_pdf { |chunk| response.stream.write(chunk) }
742
+ ```
743
+
744
+ - The file is the same document written another way: page contents first, then fonts, the page
745
+ tree, the outline and the catalog. What is painted once every page is known is a content
746
+ stream of its own, under or over the page's body, so a page has up to three and the file is
747
+ larger (1.5 → 1.7 MB for 1,039 pages with a footer).
748
+ - To a block, bytes leave while pages are painted: an error raised on a late page leaves the
749
+ block with the start of a file.
750
+ - A render that has to be checked before anything is written takes the usual path, as if
751
+ `incremental:` were not given: one with `conformance:` (PDF/A, PDF/UA, Factur-X: the audit
752
+ reads the painted pages), one with `sign:` (the signature covers the finished file), and a
753
+ `strict` one that goes to a block (a warning on the last page has to stop the first byte).
754
+ `strict` to a String, a path or an IO is incremental and raises before anything is written.
755
+ - A document with none of them is written the same either way and gains nothing in memory.
756
+
757
+ Peak resident memory of one render in a fresh process, and the megabytes still alive when
758
+ 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):
760
+
761
+ | Document | Pages | | Before | Now | `incremental: true` |
762
+ |---|---:|---|---:|---:|---:|
763
+ | Headings and paragraphs | 1,000 | peak | 391 MB | 111 MB | 108 MB |
764
+ | | | alive | 216 MB | 17 MB | 14 MB |
765
+ | | 5,001 | peak | 1,556 MB | 350 MB | 415 MB |
766
+ | | | alive | 1,062 MB | 41 MB | 25 MB |
767
+ | The same with a footer and a table of contents | 1,039 | peak | 397 MB | 145 MB | 106 MB |
768
+ | | | alive | 222 MB | 44 MB | 11 MB |
769
+ | | 5,189 | peak | 1,643 MB | 614 MB | 446 MB |
770
+ | | | 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 |
775
+
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.
648
778
 
649
779
  Controllers gain `render pdf:` and `send_pdf`:
650
780
 
@@ -794,9 +924,9 @@ a figure space, a narrow no-break space, …) is drawn as a blank of the
794
924
  character's conventional width, never as `.notdef`. Any other glyph no font
795
925
  has is drawn as the family's `.notdef` and reported as a
796
926
  `Warnings::MissingGlyph` counting each drawn occurrence (so `strict` raises
797
- on it). The characters themselves travel as the `ActualText` of a `Span`
798
- around the glyphs, so the text still extracts, copies and reads aloud as
799
- written. Fallback covers every text element,
927
+ on it, and a `conformance` level raises `ConformanceError`). The characters
928
+ themselves travel as the `ActualText` of a `Span` around the glyphs, so the
929
+ text still extracts, copies and reads aloud as written. Fallback covers every text element,
800
930
  table cell, list marker, table of contents entry and page template text;
801
931
  direct `canvas.text` calls draw with the font they are given.
802
932
 
@@ -842,19 +972,109 @@ drawn, and between ideographic characters (CJK ideographs, kana, Hangul), keepin
842
972
  a closing mark such as 。」 on the line before it and an opening bracket with what
843
973
  follows, so Japanese, Chinese and Korean text wraps without spaces.
844
974
 
845
- Images are JPEG (grey, RGB, CMYK) and PNG (every colour type, alpha as a soft
846
- mask). Parsed fonts and images are cached per process.
975
+ Images are JPEG (grey, RGB, CMYK), PNG (every colour type, alpha as a soft
976
+ mask) and lossless WebP (decoded in Ruby and embedded like a PNG, alpha as a
977
+ soft mask; lossy and animated WebP raise `UnsupportedImage`). Parsed fonts and
978
+ images are cached per process.
847
979
 
848
980
  A bitmap keeps its pixels, so a 1600 px photo drawn 160 pt wide ships all
849
981
  1600 px at 720 ppi. An image drawn at more than twice the document's
850
982
  `max_ppi` (300, or `images max_ppi: 220` at class level, `nil` to switch the
851
983
  check off) is reported as a `Warnings::OversizedImage` naming the file, its
852
984
  pixel width and the resolution it lands at. `images downscale: true` (or
853
- `image(..., downscale: true)`) resamples a PNG to `max_ppi` at its drawn size
985
+ `image(..., downscale: true)`) resamples a PNG or a WebP to `max_ppi` at its drawn size
854
986
  before embedding it, alpha included; a JPEG is embedded byte for byte, so
855
987
  resize it before you embed it (an ActiveStorage variant per drawn size,
856
988
  preprocessed, keeps a render to a download).
857
989
 
990
+ ### Complex scripts: the shaper hook
991
+
992
+ Stationery places glyphs itself: one per character, the font's ligatures and single substitutions,
993
+ pair kerning. Arabic, Hebrew, Indic and Thai text need a shaper, and the gem does not have one. An
994
+ application that does (HarfBuzz) plugs it in, for a document class or for one render:
995
+
996
+ ```ruby
997
+ class Invoice < Stationery::Document
998
+ shaper HarfBuzzShaper.new # inherited; `shaper nil` takes an inherited one away
999
+ end
1000
+
1001
+ Invoice.new.to_pdf(shaper: my_shaper) # this render only
1002
+ ```
1003
+
1004
+ A shaper is anything that answers `call`:
1005
+
1006
+ ```ruby
1007
+ def call(text, font, size:, features:, language:, **)
1008
+ # => [Stationery::Shaper::Glyph.new(gid:, advance:, cluster:, x_offset: 0, y_offset: 0), …] or nil
1009
+ end
1010
+ ```
1011
+
1012
+ | Argument | What it is |
1013
+ |---|---|
1014
+ | `text` | One stretch of a line in one font and style |
1015
+ | `font` | A `Stationery::Shaper::Face`: `path` (the font file, nil when there is none), `index` (the face of a collection), `data` (the sfnt bytes; a WOFF is already unpacked), `units_per_em`, `postscript_name`, `glyph_count` |
1016
+ | `size:` | The size drawn at, in points |
1017
+ | `features:` | A frozen Hash of OpenType tags to switches: `"kern"` and `"liga"` always, following `kerning:` and `ligatures:` (letter spacing switches `liga` off), and the style's `features:` as `true` |
1018
+ | `language:` | The document's `metadata lang:`, or nil |
1019
+
1020
+ The answer is the glyphs in **visual order**, left to right. `gid` is the glyph id in the font,
1021
+ `advance` and the offsets are in font units, and `cluster` is the index, in characters, of the first
1022
+ character of `text` the glyph stands for; glyphs of the same cluster stand together for the
1023
+ characters up to the next cluster. A Hash of the same fields does for a `Glyph`. Answering `nil`
1024
+ declines a text, which is then drawn as without a shaper. Anything else (a glyph id the font does
1025
+ not have, a cluster outside the text, an advance that is not a number) raises
1026
+ `Stationery::ShaperError`. Direction and script are not passed, because stationery knows neither:
1027
+ the shaper works them out from the text.
1028
+
1029
+ What is shaped, and when:
1030
+
1031
+ - Line breaking works on the text as written and measures each word and each stretch of spaces on
1032
+ its own. Each line is then cut where the font or the style changes, and every such stretch is
1033
+ shaped whole. That one answer is the stretch's width (alignment, underline, link rectangle) and
1034
+ what is drawn, so the two always agree. The width a break was decided on is the sum of the words',
1035
+ which can differ a little from the shaped line's, as kerning across a space already does.
1036
+ - The shaper is asked once per text, size and set of features in a render, and must answer the
1037
+ same for the same.
1038
+ - Every text a document draws goes through it: `text`, table cells, lists, `html`, `markdown`,
1039
+ headers and footers, the table of contents, text in `svg`. Form fields do not: a viewer that
1040
+ redraws a field after an edit would not shape it either.
1041
+
1042
+ In the PDF the glyph ids are written as the shaper gave them. The font's widths stay its own, so an
1043
+ advance the shaper changed is a `TJ` adjustment, an x offset moves the pen before the glyph and back
1044
+ after it, and a y offset is a text rise (`Ts`) around it, which keeps marks in place under a
1045
+ synthetic oblique, a superscript or letter spacing. Glyphs the shaper placed are embedded whether or
1046
+ not a character maps to them. Where the ToUnicode map cannot give the text (glyphs out of logical
1047
+ order, several glyphs for one cluster, a glyph that already stands for another text, glyph 0) the
1048
+ 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.
1050
+
1051
+ The limits of a hook:
1052
+
1053
+ - The shaper reorders within the stretch it is given and nowhere else. Stretches on a line are placed
1054
+ left to right in the order written and lines break in that order, so there is no bidirectional
1055
+ reordering across a change of font or style; a right-to-left paragraph is set with `align: :right`,
1056
+ and the last line of a justified one is set left.
1057
+ - Fallback fonts are chosen per character from the fonts' cmaps before anything is shaped. Give the
1058
+ text the family that covers its script, or digits and punctuation the first family has are drawn
1059
+ from it, as stretches of their own.
1060
+ - Justification widens U+0020 spaces only (no kashida). Letter spacing is added after each cluster,
1061
+ so a mark stays on its base, but it pulls cursive letters apart.
1062
+ - Hyphenation and the breaking of a word wider than the line cut the text as written; the pieces are
1063
+ shaped as separate words.
1064
+ - 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.
1069
+
1070
+ [`examples/shaping/harfbuzz_shaper.rb`](https://github.com/zoolutions/stationery/blob/main/examples/shaping/harfbuzz_shaper.rb)
1071
+ is an adapter for HarfBuzz through the [`harfbuzz-ruby`](https://github.com/ydah/harfbuzz) gem
1072
+ (`gem "harfbuzz-ruby"`, `require "harfbuzz"`; not the older `harfbuzz` gem, which answers to the same
1073
+ `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.
1077
+
858
1078
  ## Testing
859
1079
 
860
1080
  `stationery/rspec` and `stationery/minitest` read a rendered PDF back for
@@ -879,6 +1099,7 @@ RSpec.describe InvoicePdf do
879
1099
 
880
1100
  it { is_expected.to have_pdf_text("Invoice INV-7") }
881
1101
  it { is_expected.to have_pdf_text_on_page(2, /Total €[\d ,]+/) }
1102
+ it { is_expected.to have_pdf_text("Astrid Lindqvist", fields: true) } # with what the form fields show
882
1103
  it { is_expected.to have_page_count(2) }
883
1104
  it { is_expected.to have_pdf_link("mailto:hello@acme.test") }
884
1105
  it { is_expected.to have_image_count(1) }
@@ -906,6 +1127,7 @@ class InvoicePdfTest < Minitest::Test
906
1127
 
907
1128
  assert_pdf_text pdf, "Invoice INV-7"
908
1129
  refute_pdf_text pdf, "DRAFT"
1130
+ assert_pdf_text pdf, "Astrid Lindqvist", fields: true
909
1131
  assert_page_count pdf, 2
910
1132
  assert_pdf_link pdf, /acme\.test/
911
1133
  assert_no_pdf_warnings pdf
@@ -937,6 +1159,20 @@ read from its marked content: `[type, "text"]`, `[type, [children]]` (its own te
937
1159
  between the children, as for a `P` holding a `Link`) or `[type]` when empty; a
938
1160
  `Figure` reads as its alt text.
939
1161
 
1162
+ `text` and `page_texts` are the text of the page content as pdf-reader lays it out, line by line. A
1163
+ `Span` with `ActualText` (a stretch a shaper reordered, characters no font has) reads as that text,
1164
+ once, whatever glyphs it shows.
1165
+
1166
+ A form field's value is not page content: its widget draws it, so `have_pdf_text("Astrid")` does not
1167
+ find the value of a `text_field`. `fields: true` reads what the form fields show as well, each where
1168
+ it is on its page: `have_pdf_text("Astrid", fields: true)`, `have_pdf_text_on_page(1, "Astrid",
1169
+ fields: true)`, `assert_pdf_text pdf, "Astrid", fields: true`, `refute_pdf_text`, and
1170
+ `Inspector#text(fields: true)` and `#page_texts(fields: true)`. What is read is the normal appearance
1171
+ of every widget that is not hidden, in the state the widget is in, and never a button's `Off` state
1172
+ (the marks of a `checkbox` and a `radio` are paths and read as nothing). Without `fields: true` the
1173
+ text is what it always was, and the failure of `have_pdf_text` over a text that a field shows says
1174
+ so.
1175
+
940
1176
  ## Why not Prawn, Chrome or Typst?
941
1177
 
942
1178
  - **Prawn** is an imperative cursor API: every document does its own layout
@@ -1023,19 +1259,32 @@ table render under StackProf (wall mode; `MODE=cpu` or `MODE=object` for the oth
1023
1259
  `SGHTMLTOPDF=0 bundle exec rake bench` leaves the third engine out, as does a machine
1024
1260
  without its gem.
1025
1261
 
1262
+ A lossless WebP is decoded in Ruby when it is first loaded, which a JPEG or an opaque PNG
1263
+ (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.
1265
+
1026
1266
  Time depends on the machine, so CI holds what does not: `bundle exec rake metrics`
1027
1267
  renders six fixed documents and compares the objects each render allocates, its
1028
1268
  page count and its bytes with `benchmark/baseline.json` (allocations may grow 3%,
1029
1269
  bytes 1%, pages not at all). A change that moves them on purpose records a new
1030
1270
  baseline with `bundle exec rake metrics:update` and says why in the commit.
1031
1271
 
1272
+ `bundle exec rake memory` reports what long documents hold while they render
1273
+ (`PAGES=5000` for more than its 1,000 pages): the peak resident set size of a render in
1274
+ a fresh process, and what is alive when building, pagination and writing end. It
1275
+ moves with the machine and gates nothing; the figures are under
1276
+ [Large documents](#large-documents).
1277
+
1032
1278
  ## Limitations
1033
1279
 
1034
1280
  Fonts: no variable fonts (including CFF2) and no WOFF2 (it needs Brotli; convert to `.ttf` or
1035
1281
  `.woff`); shaping stops at pair kerning and single or ligature substitutions (`liga` by default,
1036
1282
  `smcp`, `onum`, `tnum`, `ss01`… on request), so contextual alternates (`calt`, `clig`, `frac`) do
1037
- nothing and scripts that need contextual shaping (Arabic, Indic, Thai) draw glyph by glyph, and colour or emoji glyphs no font
1038
- in the chain has are drawn as `.notdef` and reported. Text runs left to right (CJK text wraps between
1283
+ nothing and scripts that need contextual shaping (Arabic, Indic, Thai) draw glyph by glyph unless the
1284
+ application brings a shaper (`shaper`, see [the shaper hook](#complex-scripts-the-shaper-hook): the gem
1285
+ 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
1287
+ within a stretch of one font and style, nothing reorders stretches or lines (CJK text wraps between
1039
1288
  ideographs, but there is no vertical layout); hyphenation
1040
1289
  patterns are bundled for English, German and Swedish only (a soft hyphen works in any language),
1041
1290
  and justification only widens spaces.
@@ -1044,15 +1293,27 @@ SVG covers the shapes, gradients, text, stylesheets, `use`/`symbol` sprites and
1044
1293
  sets and exports use, nothing else: `image`, `mask`, `pattern`, `filter` and `textPath` are skipped
1045
1294
  and reported, text inside a `clipPath` does not clip, and the shapes of a clip path join into one
1046
1295
  path, so overlapping shapes wound in opposite directions cancel where they overlap.
1047
- Images are JPEG and PNG (non-interlaced) and never fetched from a URL; a JPEG is embedded at its
1048
- source resolution (only PNGs 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
1296
+ Images are JPEG, PNG (non-interlaced) and lossless WebP (not lossy or animated WebP, and no more
1297
+ 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
1049
1299
  borders and widths, page breaks; see [What CSS is read](#what-css-is-read)), not a layout
1050
- engine's worth: no `display`, floats, positioning, `font-family` or selectors with combinators.
1300
+ engine's worth: no `display`, positioning, `font-family`, `auto` or negative margins or selectors
1301
+ with combinators, and floats for images only, with their `margin` around them.
1051
1302
  `markdown` reads no CSS, and raw HTML inside Markdown stays literal text.
1052
1303
 
1053
1304
  Layout: a box with a fixed `height:` never splits (use `min_height:` for a floor that can); a row
1054
- splits only when every column can; a rotated box and a `stack` move to the next page whole. Text
1055
- does not wrap around images. Link and form-widget rectangles stay in page space inside `rotate`
1305
+ splits only when every column can; a rotated box and a `stack` move to the next page whole.
1306
+ `columns` balances to the shortest height that holds the content and fills the columns evenly
1307
+ from the first one on (ten lines in three columns are 4, 3 and 3): a column takes what fits the
1308
+ height balanced for it and the columns after it, so one that ends above a block that cannot
1309
+ split may stay shorter than the column after it. It has columns of one width, nothing spanning
1310
+ them (end the block, write the full-width content, start another) and no column break of its
1311
+ 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`
1056
1317
  and `transform`, and `shadow:` is stacked rectangles, not a blur.
1057
1318
 
1058
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
@@ -56,6 +56,15 @@ module Stationery
56
56
  node
57
57
  end
58
58
 
59
+ # Hands the finished root over and forgets it: the paginator is then the
60
+ # only holder of the nodes, so those of a painted page can be collected.
61
+ def release
62
+ root = @root
63
+ @root = nil
64
+ @containers = []
65
+ root
66
+ end
67
+
59
68
  def within(container)
60
69
  @containers << container
61
70
  yield