stationery 0.13.0 → 0.13.2

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 20fc9e96d7c91ce004e7e9a25cbd9a2ae9d38b169707358c37e0f2cf0e48c711
4
- data.tar.gz: c3a382d8a5e44aec3432f5e16576764ca87fe8225f2e5046b866480192ab36bc
3
+ metadata.gz: e03d6f50713c0cec7dbd9031d299b3140f9923b314dc5614dad901fb63cde360
4
+ data.tar.gz: d238e07ce4a9f1c5072d9aa76aea0ae94f47258d11dbc7ec2e40ab48fef8fb10
5
5
  SHA512:
6
- metadata.gz: 68a186240528e3394151ae13f71f56c0b6ab6c37f5ebbd82e15f3f2d2ff4e8ae7aecd7bf6c3262de2303778273b474e17c12e885ec65262a63b359503e4d2065
7
- data.tar.gz: de90f228f712439d0ef21933892f58f29954099892c4a9178da93a4c81d16811ff449ec3f755c82a8f2f10305ea07d6eb8bd60f6c9bd668a1071324d77d80b05
6
+ metadata.gz: 0f3117480d7eeed816d05f031811d5a721f52b7a3d96456b8e18bd368348b15b006c844593d2584c76a7524cc48461a3bd231532079ffba39ab1e42dbb1b958b
7
+ data.tar.gz: '07832a5fac782595cbfd0e9b6c1f55cfdf6c0e2d275b8623850d8566ebf7a62debb347df9b76d27fb9935c54b98e21ad4257cb548005c3463f6f57ec272e1a25'
data/CHANGELOG.md CHANGED
@@ -1,14 +1,46 @@
1
1
  # Changelog
2
2
 
3
- ## 0.13.0
3
+ ## Unreleased
4
+
5
+ - Tables take `header_columns:` (an Integer, or `true` for one), the row headers of a tagged PDF: the
6
+ first cells of every row are `TH` with `/Scope /Row`, and a cell in both a header row and a header
7
+ column is `/Scope /Both`, so a screen reader names the row with every value it reads. A row header
8
+ is found by the column it starts in, under spans as well. It is tagging only: nothing drawn changes,
9
+ header columns do not repeat, and a table without it is tagged as before (`Layout::Table`).
10
+ - `html` tables tag the `<th>` cells that start every body row as row headers (`TH`, `/Scope /Row`),
11
+ as many columns as the shortest run of them: `<tr><th>Born</th><td>1815</td></tr>` reads as a label and
12
+ its value. A body row that starts with a `<td>` leaves the table without row headers, and the header
13
+ row is found as before. A `<tfoot>` row is not a body row and does not set the count, so the usual
14
+ `<tfoot><tr><td>Total</td>…` keeps the body's row headers; it takes the count as every row of the
15
+ table does, so its first cells are tagged `TH` too, a `<td>Total</td>` among them. A table of a
16
+ header row alone, or of header and footer rows, has none (`Rich::Renderer`).
17
+ - `html` tables draw the rows of a `<tfoot>` after the body's wherever it is written, as browsers do,
18
+ and the rich-text table knows them (`Rich::Table#footer`, how many of its last rows are a footer).
19
+ - A box split across pages with `decoration: :clone` draws every fragment as a whole box, as CSS `box-decoration-break: clone` does: the border is drawn on the cut sides too, with room kept for it, and a rounded box keeps its rounded corners at the break, its background and an `overflow: :hidden` clip included. A table inside such a box gets a frame that closes under the last row on every page: `box(border: { sides: %i[left right bottom] }, break_inside: :auto, decoration: :clone) { table(rows, header: true, cell: { borders: [] }) }`. The shadow is still painted only under a box that is not split, and `outset:` still bleeds only on closed sides. `decoration: :slice`, the default, is unchanged, and so is every document that does not use `:clone`. `Layout::Box`.
20
+ - `Stationery.render_all(documents, ractors: n)` renders many documents at once across Ractors on Ruby 4.0 (#227): the documents are dealt in turn to `n` Ractors (one per processor by default), each renders its share with caches of its own, and the PDFs come back in the documents' order, byte for byte what `to_pdf` writes; options are passed to every `to_pdf` (`Stationery.render_all(invoices, tagged: true)`). Each Ractor renders a copy of its documents, so `warnings` and `page_count` stay on the copy. A document a Ractor cannot render is rendered on the main Ractor once the others are done, the same bytes: its class's settings are not shareable, it cannot be copied, its view reads what a Ractor cannot, or its render raised, which then raises there what `to_pdf` raises. Below Ruby 4.0 (no `Ractor#value`), with `ractors: 1` or with options that cannot be copied into a Ractor, every document is rendered on the main Ractor. A `render_all.stationery` event, reported from the main Ractor, says how many Ractors were started and which documents the main Ractor rendered (`main_ractor:`); the renders inside the Ractors report none. A batch of 48 invoices rendered 2.5× as fast across 4 Ractors and 3.0× across 8 as one render after another, where 4 or 8 threads rendered it 1.05× (`benchmark/ractors.rb`, the best of three whole batches, Apple M2 Max at about 1.3× load per core, Ruby 4.0.9). A table styled through a selection (`t.row(0).weight = :bold`, `t.zebra`, `set`), streamed or not, now renders inside a non-main Ractor too, where it raised. The example invoice renders inside a Ractor. A rendered document no longer keeps its layout, only its `warnings`, `page_count` and `fields`, so it is as light to keep and to copy as one never rendered. Documents that do not use it render the same bytes as before. README: "Threads, fibers and Ractors". `Stationery::Parallel`, `Stationery::Instrumentation`, `Layout::Table::Selection`.
21
+ - A box with per-corner radii (`radius: [6, 0, 6, 0]`) draws a four-sided border, an `overflow: :hidden` clip and a split across pages; each of them raised `NoMethodError` on the Array before. A box with one radius is drawn as it was. `Layout::Box`.
22
+ - With `config.stationery.show_previews` on, a Rails app boots when a gem or an initializer loads `ActionController::Base` during boot (#223): the previews controller was defined by an `action_controller_base` load hook, at whatever moment `ActionController::Base` loaded, and its `helper :all` could raise `NameError: uninitialized constant ApplicationHelper` before the app's autoloader was set up. `Stationery::PreviewsController` is now autoloaded on the first request to `/rails/stationery/previews`, after boot, and gets the app's helpers like any controller. Its routes, its pages and `show_previews = false` are unchanged. `Stationery::Railtie`.
23
+ - **Behaviour changes:** a split box with `decoration: :clone` and a `border:` draws the border on its cut sides, and its content there moves in by the border's width; with a `radius:`, the cut corners are rounded instead of square. Without a border or a radius, a `:clone` box renders as before. A render inside a Ractor other than the main one reports no instrumentation events: the instrumenter there is the null one (`Stationery.instrumenter` answers `Stationery::Instrumentation::NULL`). Under Rails, where it is `ActiveSupport::Notifications`, such a render raised `Ractor::IsolationError` (its notifier is not shareable) and now renders; a frozen instrumenter of the app's own no longer hears from other Ractors. Renders on the main Ractor report as before. In `html`, a `<tfoot>` written before `<tbody>`, the order HTML 4 asked for, drew first and now draws last.
24
+
25
+ ## 0.13.0 (2026-10-16)
26
+
27
+ - Renders are safe from many threads at once, from the fibers of one Async reactor, and inside a non-main Ractor on Ruby 4.0 (#222). What renders share is kept once per Ractor, each cache behind a lock of its own (`Stationery::Store`, `Stationery::LRU`, `Stationery::Memo`): parsed fonts, decoded images, colours parsed from Strings, hyphenation patterns, the paddings of table cells and the Reed–Solomon divisors of barcodes; no lock is kept in a constant or a class instance variable any more. A parsed font reads its kerning, ligatures and CFF table with the rest of the parse, so it never changes once built and can be made shareable and handed to a Ractor as it is; a decoded image builds its resamples and JPEG scales under a lock of its own. Every constant of the gem is deep-frozen. Boot ends with `Stationery.finalize!`, which the Railtie runs once the app has initialized (a Rails-less app calls it once its setup is done): it freezes `Stationery.font_paths`, keeps the instrumenter in use and finalizes every document class loaded by then, with their subclasses. `Document.finalize!` freezes a class's settings and makes them Ractor-shareable as far as what they hold lets them be, so the class renders inside any Ractor when all of them are; it never raises: a block that uses an unshareable local from around it, a `shaper` or a `sign` key that is not shareable, is kept as it is and the class renders on the main Ractor only, as before (`Ractor.shareable?(InvoicePdf.config)` says whether its settings are; the instrumenter in use must be shareable too). A String the app gave is copied, not frozen in place, and an object the gem did not build is never frozen (`Stationery.settled`). A font family is frozen as it is built. A document class's settings are built when it is defined, not when they are first read. conformance-kit holds the gem to it: its thread-safety cops in RuboCop, a render of text, shapes, a JPEG, a table, a QR code and a footer inside a Ractor compared byte for byte with the main Ractor's, the thread and fiber examples over the same render, and an audit of every constant and class instance variable (`bundle exec conformance-kit check`). Documents render the same bytes as before. README: "Threads, fibers and Ractors". `Stationery.finalize!`, `Stationery::Document.finalize!`, `Stationery::Store`.
28
+ - **Behaviour changes:** in a Rails app, `Stationery.font_paths` is frozen once the app has initialized (the Railtie runs `Stationery.finalize!`), so a path appended later, in an `after_initialize` of the app's own, raises `FrozenError`. So does a call that changes the settings of a document class the app had loaded by then: with `config.eager_load` (production, and the CI test runs of a Rails 7.1+ app) that is every class under `app/`, without it (development) only those its boot touched, so a class reconfigured after boot fails in production and not in development. `Stationery::Fonts::Family` objects are frozen. The gem's constants are deep-frozen (`Stationery::Page::SIZES`, `Stationery::Text::Markup::STYLES`, …), and `Stationery::Hyphenation::LOCK`, `Stationery::Preview::REGISTRY_LOCK` and `Stationery::Layout::Table::Cell::BOXES` are gone. Until `Stationery.finalize!` keeps it, the default instrumenter is looked up on every render, so `ActiveSupport::Notifications` loaded after the gem is used. A document class copies its parent's settings when it is defined, not when they are first read, so a setting a parent class gains after a subclass is defined (a reopened `ApplicationPdf`) no longer reaches that subclass.
29
+
30
+ ## 0.12.3 (2026-10-01)
4
31
 
5
32
  - Text fields and selects take `font_size: :fit` (#218): the size `font_size: :auto` draws (one line, a `comb:` field's cells or a `multiline:` value, within `min_font_size:` and `max_font_size:`), written in `/DA` as that number instead of `0`, so a viewer that redraws the field keeps the size; Firefox's editable input, which showed `:auto` at 9 px, now shows the number, capped at the field's height ÷ 1.35 and set in the browser's own face. A longer value typed afterwards does not shrink, and an empty field is written at its largest size, so a field left empty to be filled in wants `:auto`. A field placed in several places writes its first widget's size. `Inspector#layout` and `stationery inspect` report the number. `text_field "price", value: "4 990 kr", font_size: :fit, max_font_size: 96, height: 110`. The price card example uses it. A field that does not is written as it was. `Forms::Field`, `Forms::Appearance`.
33
+
34
+ ## 0.12.2 (2026-10-01)
35
+
6
36
  - Text fields and selects take `align:`, `color:` and `font_size: :auto` (#216): `align:` (`:left`, `:center`, `:right`) is written as `/Q`, `color:` (any colour the gem takes, black by default) as the colour operator of `/DA`, and `font_size: :auto` as `0 Tf`, within `min_font_size:` (4 by default) and `max_font_size:` (the field's height by default). The gem's own appearance aligns, colours and sizes the value the same way, so a field looks the same before and after a viewer redraws it: one line at the largest tenth of a point that fits the field's height and width, a `comb:` field's widest character in its cell, a `multiline:` value wrapped to the width and fitted to the height. `comb:` ignores `align:`. `text_field "price", value: "4 990 kr", align: :center, color: "#DC2626", font_size: :auto, height: 110`. A field that uses none of them is written as it was. Under `monochrome` a `color:` is checked like any other colour (`Warnings::NotMonochrome`, or black with `snap: true`); `Inspector#layout` and `stationery inspect` report a text field's or choice's `align:`, `font_size:` and `color:`. A price card example (`examples/price_card.rb`). `Forms::Field`, `Forms::Appearance`, `Forms::AutoSize`, `Testing::Layout`. **Behaviour change:** under PDF/A, a CMYK colour in a form field's appearance or `/DA` is reported as `Warnings::ConformanceIssue` (`CMYK colour in form field "price"`), its `border:` and `background:` included, which were not checked before.
7
37
  - The skill (`stationery skill install`) and the README it is written from are corrected (#209): the `max_pages` example renders before it reads `warnings`; a preview reads `params.fetch("id") { … }`, so its default is not looked up when `id` is given; the streamed-table example preloads what its rows read; the invoice recipe computes VAT with `Rational`, not Float, and says when a document may derive its totals; the report recipe escapes the chart labels it writes into SVG (`ERB::Util.html_escape`); the form recipe gives its fields a `tooltip:` and says a field without one is announced by its field name; the label recipe sizes a native QR code by the printer's dpi (a module of at most 720 / dpi points) and says what a barcode left in the picture loses. Statements that overstated are qualified: `encrypt` under a conformance level raises `ArgumentError`, not `ConformanceError`; `NeedAppearances` is left out under a conformance level or a signature; what the untrusted-input guarantees cover; and what a B-T timestamp proves once the certificate has expired. Run `stationery skill install` again to update an installed copy.
8
- - A tagged render is written with object streams (#206): its structure elements, and every other object that is not a stream, are packed 200 to a deflated `/Type /ObjStm`, with a cross-reference stream in place of the table and trailer (ISO 32000-1, 7.5.7 and 7.5.8). A table of 300 rows, 8 pages, is 115 KB tagged where it was 501 KB, 36 objects where it was 4,544; the tagged examples are 9 to 65% smaller. It is on by default for a render that is tagged (`tagged`, `to_pdf(tagged: true)`, `conformance :pdf_ua1`), and `object_streams false` at class level or `to_pdf(object_streams: false)` writes the classic table for a reader that cannot read object streams; `to_pdf(object_streams: true)` packs an untagged render too. Streams, the `/Encrypt` dictionary and a signature's dictionary stay out, so an encrypted, a signed and an incremental render are packed too, and PDF/A-2b, PDF/A-3b and PDF/UA-1 pass veraPDF packed. `to_png` and `to_zpl` refuse `object_streams:` as a PDF option. `Document#page_count` is how many pages the last render laid out, which `stationery render` now reports. In `PDF::Writer` (`object_streams:`, `add_unpacked`) and `PDF::ObjectStreams`.
9
38
  - `stationery verify` checks PDFs in the engines viewers are built on (#210): qpdf (the file's structure), Poppler (Evince, Okular), MuPDF, PDFium (Chrome, Edge), pdf.js (Firefox) and PDFKit (Preview, Safari). Each engine reads the file and what it reads is held against what pdf-reader reads of it: the page count, each page's text lines (found, not compared: NFKC, no whitespace, no soft hyphens, a list item's marker may be read apart, as a column of markers), that a page with text or an image paints something, links and their targets, outline titles, form fields with their values and appearances, attachments, signatures (and `pdfsig`'s verdict), a tagged file's structure tree, and every font embedded with a ToUnicode map. Any error or warning an engine gives fails the check, but for the three messages `Stationery::Verify::Allowlist` names with why each is the check's environment and not the file. `stationery verify invoice.pdf report.rb --engines qpdf,pdfjs --password P --json` renders a Ruby file first, as `inspect` does, runs every engine installed without `--engines`, and exits 0, 1 or 2 (usage); it prints one line per file and engine, the problems under it, and the engines not installed with the line that installs each. The engines are tools you install (`brew install qpdf poppler mupdf`, `pip install pypdfium2`, `npm i pdfjs-dist`; PDFKit on macOS), or run in the image `ghcr.io/zoolutions/stationery-verify`, published with each release, which has every engine but PDFKit: `docker run --rm -v "$PWD:/data:ro" ghcr.io/zoolutions/stationery-verify /data/invoice.pdf`. The gem gains no dependency: `stationery verify` needs `pdf-reader`, as `inspect` does, and `require "stationery"` does not load it, so a render allocates and writes what it did. `bundle exec rake verify:readers` renders every example, the conformance renders, an encrypted invoice, a packed invoice and an unpacked tagged report to `tmp/readers/` and checks them; CI runs it through the image on Linux and with qpdf, Poppler, MuPDF and PDFKit on macOS. `Testing::Inspector.new(subject, password:)` opens an encrypted file. `Stationery::Verify`, `Stationery::CLI::Verify`.
10
- - Renders are safe from many threads at once, from the fibers of one Async reactor, and inside a non-main Ractor on Ruby 4.0 (#222). What renders share is kept once per Ractor, each cache behind a lock of its own (`Stationery::Store`, `Stationery::LRU`, `Stationery::Memo`): parsed fonts, decoded images, colours parsed from Strings, hyphenation patterns, the paddings of table cells and the Reed–Solomon divisors of barcodes; no lock is kept in a constant or a class instance variable any more. A parsed font reads its kerning, ligatures and CFF table with the rest of the parse, so it never changes once built and can be made shareable and handed to a Ractor as it is; a decoded image builds its resamples and JPEG scales under a lock of its own. Every constant of the gem is deep-frozen. Boot ends with `Stationery.finalize!`, which the Railtie runs once the app has initialized (a Rails-less app calls it once its setup is done): it freezes `Stationery.font_paths`, keeps the instrumenter in use and finalizes every document class loaded by then, with their subclasses. `Document.finalize!` freezes a class's settings and makes them Ractor-shareable as far as what they hold lets them be, so the class renders inside any Ractor when all of them are; it never raises: a block that uses an unshareable local from around it, a `shaper` or a `sign` key that is not shareable, is kept as it is and the class renders on the main Ractor only, as before (`Ractor.shareable?(InvoicePdf.config)` says whether its settings are; the instrumenter in use must be shareable too). A String the app gave is copied, not frozen in place, and an object the gem did not build is never frozen (`Stationery.settled`). A font family is frozen as it is built. A document class's settings are built when it is defined, not when they are first read. conformance-kit holds the gem to it: its thread-safety cops in RuboCop, a render of text, shapes, a JPEG, a table, a QR code and a footer inside a Ractor compared byte for byte with the main Ractor's, the thread and fiber examples over the same render, and an audit of every constant and class instance variable (`bundle exec conformance-kit check`). Documents render the same bytes as before. README: "Threads, fibers and Ractors". `Stationery.finalize!`, `Stationery::Document.finalize!`, `Stationery::Store`.
11
- - **Behaviour changes:** a tagged render is packed: its catalog, pages, annotations and structure elements are inside deflated object streams, so a test or tool that searches the file's bytes for them (`/StructParent`, `/Type /Page`) finds nothing (`document.page_count` has the pages), and the file ends in a cross-reference stream, not `xref` and `trailer`. A reader of PDF 1.4 and earlier cannot open it; `object_streams: false` writes it as 0.12.0 did, byte for byte. A tagged render streamed to a block is a few bytes longer than the String, as each flush closes an object stream. Untagged documents are byte for byte what they were in 0.12.0. In a Rails app, `Stationery.font_paths` is frozen once the app has initialized (the Railtie runs `Stationery.finalize!`), so a path appended later, in an `after_initialize` of the app's own, raises `FrozenError`. So does a call that changes the settings of a document class the app had loaded by then: with `config.eager_load` (production, and the CI test runs of a Rails 7.1+ app) that is every class under `app/`, without it (development) only those its boot touched, so a class reconfigured after boot fails in production and not in development. `Stationery::Fonts::Family` objects are frozen. The gem's constants are deep-frozen (`Stationery::Page::SIZES`, `Stationery::Text::Markup::STYLES`, …), and `Stationery::Hyphenation::LOCK`, `Stationery::Preview::REGISTRY_LOCK` and `Stationery::Layout::Table::Cell::BOXES` are gone. Until `Stationery.finalize!` keeps it, the default instrumenter is looked up on every render, so `ActiveSupport::Notifications` loaded after the gem is used. A document class copies its parent's settings when it is defined, not when they are first read, so a setting a parent class gains after a subclass is defined (a reopened `ApplicationPdf`) no longer reaches that subclass.
39
+
40
+ ## 0.12.1 (2026-09-29)
41
+
42
+ - A tagged render is written with object streams (#206): its structure elements, and every other object that is not a stream, are packed 200 to a deflated `/Type /ObjStm`, with a cross-reference stream in place of the table and trailer (ISO 32000-1, 7.5.7 and 7.5.8). A table of 300 rows, 8 pages, is 115 KB tagged where it was 501 KB, 36 objects where it was 4,544; the tagged examples are 9 to 65% smaller. It is on by default for a render that is tagged (`tagged`, `to_pdf(tagged: true)`, `conformance :pdf_ua1`), and `object_streams false` at class level or `to_pdf(object_streams: false)` writes the classic table for a reader that cannot read object streams; `to_pdf(object_streams: true)` packs an untagged render too. Streams, the `/Encrypt` dictionary and a signature's dictionary stay out, so an encrypted, a signed and an incremental render are packed too, and PDF/A-2b, PDF/A-3b and PDF/UA-1 pass veraPDF packed. `to_png` and `to_zpl` refuse `object_streams:` as a PDF option. `Document#page_count` is how many pages the last render laid out, which `stationery render` now reports. In `PDF::Writer` (`object_streams:`, `add_unpacked`) and `PDF::ObjectStreams`.
43
+ - **Behaviour changes:** a tagged render is packed: its catalog, pages, annotations and structure elements are inside deflated object streams, so a test or tool that searches the file's bytes for them (`/StructParent`, `/Type /Page`) finds nothing (`document.page_count` has the pages), and the file ends in a cross-reference stream, not `xref` and `trailer`. A reader of PDF 1.4 and earlier cannot open it; `object_streams: false` writes it as 0.12.0 did, byte for byte. A tagged render streamed to a block is a few bytes longer than the String, as each flush closes an object stream. Untagged documents are byte for byte what they were in 0.12.0.
12
44
 
13
45
  ## 0.12.0 (2026-09-29)
14
46
 
data/README.md CHANGED
@@ -121,10 +121,10 @@ ls "$(bundle show stationery)/examples" # or: gem contents stationery
121
121
  | `text(string, **style)` | A paragraph. Plain strings are literal. |
122
122
  | `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. |
123
123
  | `text { b "Total"; plain " due" }` | Styled runs in Ruby. Take a block argument (`{ \|t\| t.b @x }`) to keep your own `self`. |
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). |
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 and squares the corners, `:clone` keeps the padding, the border and the rounded corners on every fragment. `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). |
125
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. |
126
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. |
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). |
127
+ | `table(rows, widths:, width:, header:, header_columns:, 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. `header_columns: 1` (or `true`) makes the first cell of every row a row header in a tagged PDF, as a label and value table needs; it changes nothing that is drawn. 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). |
128
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. |
129
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
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. |
@@ -860,8 +860,9 @@ and across page breaks: a paragraph continued on the next page stays one `P`.
860
860
  `:article` (`Art`), `:part`. Boxes without a role, rows, columns, groups and wraps add no element.
861
861
  - Lists are `L` (with `/ListNumbering` from the bullet shape or number format) > `LI` > `Lbl` (a text
862
862
  marker; drawn bullets are artifacts) and `LBody`.
863
- - Tables are `Table` > `TR` > `TH` (header rows, `/Scope /Column`) or `TD`, with `/ColSpan` and
864
- `/RowSpan`. A table split across pages stays one `Table`; its repeated header rows are artifacts.
863
+ - Tables are `Table` > `TR` > `TH` (header rows, `/Scope /Column`; the first `header_columns:` cells of
864
+ a row, `/Scope /Row`; a cell that is both, `/Scope /Both`) or `TD`, with `/ColSpan` and `/RowSpan`. A
865
+ table split across pages stays one `Table`; its repeated header rows are artifacts.
865
866
  - Linked text is a `Link` inside its paragraph holding the link annotation (`/OBJR`, `/StructParent`);
866
867
  `box(link:)` groups its content in a `Link`.
867
868
  - `table_of_contents` is `TOC` > `TOCI` > `Link` (the title and its annotation) and `Reference` (the
@@ -870,6 +871,10 @@ and across page breaks: a paragraph continued on the next page stays one `P`.
870
871
  `BlockQuote`, and give images their `alt`. In `html`, `<img alt="">` is decoration as `alt: false`
871
872
  is (an `<img>` without the attribute is a description missing). Markdown has no way to say so:
872
873
  `![](photo.png)` is a description missing, and decoration goes through `html` or `image`.
874
+ - In `html` tables, a first row of `<th>` cells is the header row, and the `<th>` cells every body row
875
+ starts with are row headers (`header_columns:`, as many as the shortest run of them); a body row that
876
+ starts with a `<td>` leaves the table without row headers. A `<tfoot>` row is drawn last and does not
877
+ count: its first cells are tagged with the body's, so `<td>Total</td>` is a row header there.
873
878
  - Headers, footers and page templates are pagination artifacts, but for their links: a `link:` one
874
879
  paints (`text(link:)`, markup, `html`, `markdown`, `box(link:)`) is a `Link` of the `Document`
875
880
  holding its text and its annotation, read after the content of its page, in the order header,
@@ -1912,7 +1917,9 @@ Every render reports its phases as events named `<phase>.stationery`, so an
1912
1917
  APM that subscribes to `ActiveSupport::Notifications` (AppSignal, Skylight,
1913
1918
  Rails' own log subscribers) shows where a slow PDF spends its time with no
1914
1919
  setup: AppSignal groups them under "stationery" in the event tree. Outside
1915
- Rails the events go to a null instrumenter that costs one method call.
1920
+ Rails the events go to a null instrumenter that costs one method call. Renders
1921
+ inside a Ractor other than the main one report none (see
1922
+ [Threads, fibers and Ractors](#threads-fibers-and-ractors)).
1916
1923
 
1917
1924
  | Event | Around | Payload |
1918
1925
  |---|---|---|
@@ -1923,6 +1930,7 @@ Rails the events go to a null instrumenter that costs one method call.
1923
1930
  | `image.stationery` | decoding one image (cache misses only) | `format:`, `width:`, `height:`, `bytes:` |
1924
1931
  | `font.stationery` | parsing a font file (`action: :parse`, cache misses only) or subsetting one for a document (`action: :subset`, `glyphs:`) | `path:` or `font:`, `action:` |
1925
1932
  | `parse.stationery` | parsing an `html` or `markdown` source | `format:`, `bytes:` |
1933
+ | `render_all.stationery` | the whole `Stationery.render_all` | `documents:` (count), `ractors:` (started), `main_ractor:` (indexes of the documents rendered on the main Ractor) |
1926
1934
 
1927
1935
  Payload values known only afterwards (`pages:`, `bytes:`) are filled in
1928
1936
  before the event finishes, so subscribers always see them. Log every phase
@@ -1984,17 +1992,52 @@ pdfs = Array.new(4) { |i| Ractor.new(["Invoice #{i}"]) { |lines| InvoicePdf.new(
1984
1992
  that is not shareable, is kept as it is, so the class renders on the main
1985
1993
  Ractor as before and not inside another one.
1986
1994
  `Ractor.shareable?(InvoicePdf.config)` says whether its settings are
1987
- shareable; a render inside a Ractor also needs a shareable instrumenter
1988
- (below), and the class's own methods (`view_template`, the methods its
1995
+ shareable, and the class's own methods (`view_template`, the methods its
1989
1996
  blocks call) may reach only what a Ractor can.
1990
1997
  A String the app gave is copied, not frozen in place, and an object the gem
1991
1998
  did not build is never frozen.
1992
- - An instrumenter for renders inside a Ractor must be shareable: a module
1993
- (`ActiveSupport::Notifications`) or a frozen object. Another one is kept,
1994
- for the main Ractor.
1999
+ - Only renders on the main Ractor report events: inside another Ractor the
2000
+ instrumenter is the null one, as subscribers live on the main Ractor and
2001
+ `ActiveSupport::Notifications` cannot be called from another one.
1995
2002
  - What a Ractor is handed (`Ractor.new(lines)`) is copied unless it is
1996
2003
  shareable.
1997
2004
 
2005
+ `Stationery.render_all` renders many documents at once across Ractors and
2006
+ answers their PDFs in the documents' order, the bytes `to_pdf` writes for
2007
+ each:
2008
+
2009
+ ```ruby
2010
+ invoices = orders.map { InvoicePdf.new(it.lines) }
2011
+ pdfs = Stationery.render_all(invoices, ractors: 4) # four Ractors
2012
+ pdfs = Stationery.render_all(invoices, tagged: true) # one Ractor per processor; options go to every to_pdf
2013
+ ```
2014
+
2015
+ - The documents are dealt in turn to the Ractors, each of which renders its
2016
+ share with caches of its own: it parses the fonts and decodes the images
2017
+ again, so a batch pays off when each Ractor has several documents to
2018
+ render. `benchmark/ractors.rb` renders a batch of 48 invoices: on an
2019
+ Apple M2 Max (12 cores, shared, about 1.3× load per core), Ruby 4.0.9
2020
+ +YJIT, 5 November 2026, the best of three whole batches, 4 Ractors
2021
+ rendered it 2.5× as fast as one render after another and 8 Ractors 3.0×,
2022
+ where 4 or 8 threads rendered it 1.05×.
2023
+ - Each Ractor renders a copy of its documents, so what a render leaves on a
2024
+ document (`warnings`, `page_count`) stays on the copy; render a document
2025
+ whose warnings you need with `to_pdf`.
2026
+ - A document a Ractor cannot render is rendered on the main Ractor once the
2027
+ others are done, the same bytes: its class's settings are not shareable,
2028
+ it cannot be copied (it holds a Proc, a Method, a Thread), its view reads
2029
+ what a Ractor cannot (a constant holding an unfrozen String), or its
2030
+ render raised, in which case it raises there what `to_pdf` raises (an
2031
+ error that is not a `StandardError`, such as a `SystemStackError`, also
2032
+ prints the Ractor's backtrace). The `render_all.stationery` event,
2033
+ reported from the main Ractor, names them (`main_ractor:`).
2034
+ - Below Ruby 4.0 (no `Ractor#value`), with `ractors: 1` or with options
2035
+ that cannot be copied into a Ractor (a `shaper:` that is a Proc, unless
2036
+ `Ractor.make_shareable` was run on it), every document is rendered on the
2037
+ main Ractor, one after another.
2038
+ - Ruby warns that the Ractor API is experimental the first time a Ractor
2039
+ starts; `Warning[:experimental] = false` silences it.
2040
+
1998
2041
  The gem is held to this by [conformance-kit](https://github.com/zoolutions/conformance-kit):
1999
2042
  `bundle exec conformance-kit check` (on Ruby 4.0) renders a document of
2000
2043
  text, shapes, a JPEG, a table, a QR code and a footer inside a Ractor and
data/examples/invoice.rb CHANGED
@@ -9,8 +9,8 @@
9
9
  require "stationery"
10
10
 
11
11
  class ExampleInvoice < Stationery::Document
12
- FONTS = File.expand_path("../spec/fixtures/fonts", __dir__)
13
- LOGO = File.expand_path("assets/logo.png", __dir__)
12
+ FONTS = File.expand_path("../spec/fixtures/fonts", __dir__).freeze
13
+ LOGO = File.expand_path("assets/logo.png", __dir__).freeze
14
14
 
15
15
  ACCENT = "#4F46E5"
16
16
  INK = "#1F2937"
@@ -326,7 +326,10 @@ module Stationery
326
326
  # `canvases` makes (see PDF::Canvases and Canvas::Interface): what a
327
327
  # render does before its output is written, whatever the output is.
328
328
  # Answers the pages, hands each to `each_page` as soon as its content is
329
- # painted, and yields the bookmarks whose anchors were painted.
329
+ # painted, and yields the bookmarks whose anchors were painted. The
330
+ # document keeps its warnings and page count, not the layout: a rendered
331
+ # document is as light to keep, and to copy into a Ractor
332
+ # (Stationery.render_all), as one never rendered.
330
333
  def paint_on(canvases, warnings: Warnings.new, shaper: self.class.config[:shaping][:shaper], each_page: nil,
331
334
  stand_ins: false, max_pages: self.class.config[:max_pages])
332
335
  book = book_for(warnings, shaper, stand_ins)
@@ -338,6 +341,8 @@ module Stationery
338
341
  @warnings = warnings
339
342
  @page_count = pages.size
340
343
  pages
344
+ ensure
345
+ @_builder = nil
341
346
  end
342
347
 
343
348
  # Used by page templates to build nodes into their own root.
@@ -136,14 +136,15 @@ module Stationery
136
136
  # Cells are strings, layout nodes, procs built with the DSL (`-> { image … }`)
137
137
  # or components. Rows given as an Enumerator to a table whose every
138
138
  # column has a width are read as pages reach them (Layout::Table.streams?).
139
- def table(rows, widths: nil, width: :auto, header: false, split_rows: false, cell: {}, anchor: nil, bookmark: nil,
140
- &)
139
+ def table(rows, widths: nil, width: :auto, header: false, header_columns: false, split_rows: false, cell: {},
140
+ anchor: nil, bookmark: nil, &)
141
141
  rows = if Layout::Table.streams?(rows, widths)
142
142
  streamed_rows(rows)
143
143
  else
144
144
  rows.map { |row| row.map { |content| cell_content(content) } }
145
145
  end
146
- node = Layout::Table.new(rows, context: @_builder.context, widths:, width:, header:, split_rows:, cell:, &)
146
+ node = Layout::Table.new(rows, context: @_builder.context, widths:, width:, header:, header_columns:, split_rows:,
147
+ cell:, &)
147
148
  @_builder.add(mark(node, anchor, bookmark))
148
149
  end
149
150
 
@@ -140,19 +140,26 @@ module Stationery
140
140
 
141
141
  def dimension(value) = value&.[](/\A\s*(\d+)/, 1)&.to_i
142
142
 
143
+ # The rows of a `<tfoot>` come after the body's, wherever it is written, as a browser draws them.
143
144
  def table(element, marks, inherited, style)
144
- rows = rows_of(element).filter_map do |row|
145
+ rows = []
146
+ footer = nil
147
+ each_row(element) do |row, in_footer|
145
148
  cells = elements(row).select { |cell| %w[td th].include?(cell.name) }
146
- cells.map { |cell| cell(cell, marks, inherited) } unless cells.empty?
149
+ next if cells.empty?
150
+
151
+ cells.map! { |cell| cell(cell, marks, inherited) }
152
+ in_footer ? (footer ||= []) << cells : rows << cells
147
153
  end
148
- Rich::Table.new(rows:, style:) unless rows.empty?
154
+ rows.concat(footer) if footer
155
+ Rich::Table.new(rows:, footer: footer ? footer.size : 0, style:) unless rows.empty?
149
156
  end
150
157
 
151
- def rows_of(element)
152
- elements(element).flat_map do |child|
153
- next [child] if child.name == "tr"
154
-
155
- TreeBuilder::SECTIONS.include?(child.name) ? rows_of(child) : []
158
+ def each_row(element, in_footer: false, &)
159
+ elements(element).each do |child|
160
+ if child.name == "tr" then yield child, in_footer
161
+ elsif TreeBuilder::SECTIONS.include?(child.name) then each_row(child, in_footer: child.name == "tfoot", &)
162
+ end
156
163
  end
157
164
  end
158
165
 
@@ -22,19 +22,22 @@ module Stationery
22
22
 
23
23
  class << self
24
24
  # Any object answering `instrument(name, payload) { |payload| }`; nil
25
- # gives the default back. A render inside a non-main Ractor can reach
26
- # only a shareable one: a module (ActiveSupport::Notifications) or a
27
- # frozen object.
25
+ # gives the default back. Renders on the main Ractor report to it.
28
26
  # Set at boot, on the main Ractor, so a plain module attribute is enough.
29
27
  attr_writer :instrumenter # rubocop:disable ThreadSafety/ClassAndModuleAttributes
30
28
 
31
29
  # The one set, else ActiveSupport::Notifications when it is loaded,
32
- # else NULL.
30
+ # else NULL; NULL inside any other Ractor than the main one, whose
31
+ # renders report nothing: subscribers live on the main Ractor, and
32
+ # ActiveSupport::Notifications cannot be called from another one (its
33
+ # notifier is not shareable).
33
34
  # rubocop:disable-next ThreadSafety/ClassInstanceVariable -- only read here; written at boot on the main Ractor
34
- def instrumenter = @instrumenter || default
35
+ def instrumenter = main_ractor? ? (@instrumenter || default) : NULL
35
36
 
36
37
  def default = defined?(::ActiveSupport::Notifications) ? ::ActiveSupport::Notifications : NULL
37
38
 
39
+ def main_ractor? = !defined?(::Ractor) || ::Ractor.main?
40
+
38
41
  # Keeps the instrumenter in use, so one loaded after boot is not picked
39
42
  # up (Stationery.finalize!).
40
43
  def finalize!
@@ -8,7 +8,9 @@ module Stationery
8
8
  # moves to the next page whole when it fits there and continues across
9
9
  # pages when it does not (`break_inside: :auto` splits at any break,
10
10
  # `:avoid` never). A fragment's cut sides are `open`: `decoration: :slice`
11
- # drops their padding and border, `:clone` keeps the padding. A
11
+ # drops their padding and border and cuts rounded corners square there,
12
+ # `:clone` draws each fragment as a whole box, its padding, border and
13
+ # rounded corners on the cut sides too (CSS `box-decoration-break`). A
12
14
  # fixed-height box can truncate or shrink text that does not fit
13
15
  # (`overflow:`) and never splits. `min_height:` is a floor that does split:
14
16
  # the first fragment keeps as much of it as the page holds and the next
@@ -153,13 +155,19 @@ module Stationery
153
155
  end
154
156
 
155
157
  def insets(open = @open)
158
+ cut = sliced(open)
156
159
  border = @border ? @border[:width] : 0
157
- sides = @border ? @border[:sides] - open : []
160
+ sides = @border ? @border[:sides] - cut : []
158
161
  Geometry::SIDES.keys.zip(@padding).map do |side, padding|
159
- (open.include?(side) && @decoration == :slice ? 0 : padding) + (sides.include?(side) ? border : 0)
162
+ (cut.include?(side) ? 0 : padding) + (sides.include?(side) ? border : 0)
160
163
  end
161
164
  end
162
165
 
166
+ # The open sides that lose their padding, border and rounded corners:
167
+ # all of them under `decoration: :slice`, none under `:clone`, which
168
+ # decorates every fragment as a whole box.
169
+ def sliced(open = @open) = @decoration == :slice ? open : []
170
+
163
171
  def horizontal = insets[1] + insets[3]
164
172
  def vertical(open = @open) = insets(open).values_at(0, 2).sum
165
173
  def inner_width(width) = [width - horizontal, 0].max
@@ -167,7 +175,7 @@ module Stationery
167
175
  # Stacked rounded rectangles under the box, the outermost grown by the
168
176
  # blur and each fainter, so the centre sums to the opacity and the edge
169
177
  # fades. An artifact: decoration, not content. Skipped on a fragment
170
- # with cut sides, whose corners are square anyway.
178
+ # with cut sides.
171
179
  def paint_shadow(canvas, x, y, width, height)
172
180
  return unless @shadow && @open.empty?
173
181
 
@@ -202,24 +210,28 @@ module Stationery
202
210
 
203
211
  stroke = @border[:width]
204
212
  area = Rect.new(x, y, width, height)
205
- if @border[:sides].size == 4 && @radius.positive?
213
+ if @border[:sides].size == 4 && rounded?
206
214
  half = stroke / 2.0
207
215
  cut(canvas, area, area.inset(half, half, half, half)) do |shape|
208
216
  canvas.rounded_rect(*shape.to_h.values, radius: @radius, stroke: @border[:color], line_width: stroke)
209
217
  end
210
218
  else
211
- (@border[:sides] - @open).each { |side| border_side(canvas, side, area, stroke) }
219
+ (@border[:sides] - sliced).each { |side| border_side(canvas, side, area, stroke) }
212
220
  end
213
221
  end
214
222
 
215
223
  def open?(index) = @open.include?(Geometry::SIDES.key(index))
216
224
 
217
- # Cut edges are square: the rounded shape runs past them by its radius
218
- # and stroke, clipped to the fragment.
225
+ # One radius for every corner or `[top_left, top_right, bottom_right, bottom_left]`.
226
+ def rounded? = @radius.is_a?(Numeric) ? @radius.positive? : Array(@radius).any?(&:positive?)
227
+
228
+ # Under `decoration: :slice` cut edges are square: the rounded shape runs
229
+ # past them by its radius and stroke, clipped to the fragment. Under
230
+ # `:clone` every fragment keeps its own rounded shape.
219
231
  def cut(canvas, area, shape)
220
- return yield(shape) if @open.empty? || !@radius.positive?
232
+ return yield(shape) if sliced.empty? || !rounded?
221
233
 
222
- reach = @radius + (@border ? @border[:width] : 0)
234
+ reach = (@radius.is_a?(Array) ? @radius.max : @radius) + (@border ? @border[:width] : 0)
223
235
  top = @open.include?(:top) ? reach : 0
224
236
  bottom = @open.include?(:bottom) ? reach : 0
225
237
  canvas.clip(area.x, area.y, area.width, area.height) do
@@ -262,9 +274,9 @@ module Stationery
262
274
  end
263
275
 
264
276
  # `overflow: :hidden` keeps the content inside the rounded outline; cut
265
- # sides stay square like the background's.
277
+ # sides are square or rounded like the background's.
266
278
  def clip_outline(canvas, area, &)
267
- return yield unless @overflow == :hidden && @radius.positive?
279
+ return yield unless @overflow == :hidden && rounded?
268
280
 
269
281
  cut(canvas, area, area) do |shape|
270
282
  canvas.clip(shape.x, shape.y, shape.width, shape.height, radius: @radius, &)
@@ -27,6 +27,14 @@ module Stationery
27
27
  # The same cell holding another node, as a row split into two parts needs.
28
28
  def with_content(node) = Cell.new(node, @options, colspan:, rowspan:).tagged(@tag, @row_tag)
29
29
 
30
+ # A TH with that /Scope when `scope` is :Column, :Row or :Both, else a TD.
31
+ def element(scope)
32
+ return Tagging::Element.new(:TD) unless scope || colspan > 1 || rowspan > 1
33
+
34
+ attributes = { Scope: scope, ColSpan: (colspan if colspan > 1), RowSpan: (rowspan if rowspan > 1) }.compact
35
+ Tagging::Element.new(scope ? :TH : :TD, attributes: attributes.empty? ? {} : { Table: attributes })
36
+ end
37
+
30
38
  # Its TH or TD and its row's TR in a tagged PDF; set once, by the
31
39
  # table that first paints or cuts its row.
32
40
  def tagged(tag, row_tag)
@@ -31,11 +31,13 @@ module Stationery
31
31
  self
32
32
  end
33
33
 
34
+ # Shareable, so a selection is styled inside any Ractor: a method
35
+ # defined with a block that is not runs on the main Ractor only.
34
36
  ATTRIBUTES.each do |name|
35
- define_method(:"#{name}=") do |value|
37
+ define_method(:"#{name}=", &Stationery.shareable(proc do |value|
36
38
  cells.each { |cell| cell[name] = value }
37
39
  @table.invalidate!
38
- end
40
+ end))
39
41
  end
40
42
 
41
43
  def self.indexes(spec, count)
@@ -113,8 +113,10 @@ module Stationery
113
113
  self
114
114
  end
115
115
 
116
+ # Shareable, as Table::Selection's are.
116
117
  Table::Selection::ATTRIBUTES.each do |name|
117
- define_method(:"#{name}=") { |value| @stream.style(@rows, @columns, name, value) }
118
+ setter = proc { |value| @stream.style(@rows, @columns, name, value) }
119
+ define_method(:"#{name}=", &Stationery.shareable(setter))
118
120
  end
119
121
  end
120
122
  end
@@ -27,9 +27,11 @@ module Stationery
27
27
  border_color: "#000000" })
28
28
 
29
29
  # `tag` is the Table element its fragments share; a `continued` fragment
30
- # repeats header rows already read on an earlier page.
31
- def initialize(rows, context:, widths: nil, width: :auto, header: false, split_rows: false, cell: {},
32
- tag: context.element(:Table), continued: false)
30
+ # repeats header rows already read on an earlier page. `header_columns`
31
+ # are the first cells of every row, tagged as row headers; unlike header
32
+ # rows they are not repeated and are styled only through selections.
33
+ def initialize(rows, context:, widths: nil, width: :auto, header: false, header_columns: false, split_rows: false,
34
+ cell: {}, tag: context.element(:Table), continued: false)
33
35
  super()
34
36
  @tag = tag
35
37
  @continued = continued
@@ -38,6 +40,7 @@ module Stationery
38
40
  @widths = widths
39
41
  @width = width
40
42
  @header = header == true ? 1 : (header || 0).to_i
43
+ @header_columns = header_columns == true ? 1 : (header_columns || 0).to_i
41
44
  defaults = DEFAULT_CELL.merge(cell)
42
45
  if Table.streams?(rows, widths)
43
46
  stream(rows, defaults)
@@ -284,15 +287,14 @@ module Stationery
284
287
  return if cells.all?(&:tag)
285
288
 
286
289
  row_tag = Tagging::Element.new(:TR)
287
- cells.each { |cell| cell.tagged(cell_tag(cell, row < @header), row_tag) }
288
- end
289
-
290
- def cell_tag(cell, header)
291
- return Tagging::Element.new(:TD) unless header || cell.colspan > 1 || cell.rowspan > 1
290
+ column_scope = (:Column if row < @header)
291
+ return cells.each { |cell| cell.tagged(cell.element(column_scope), row_tag) } if @header_columns.zero?
292
292
 
293
- attributes = { Scope: (:Column if header), ColSpan: (cell.colspan if cell.colspan > 1),
294
- RowSpan: (cell.rowspan if cell.rowspan > 1) }.compact
295
- Tagging::Element.new(header ? :TH : :TD, attributes: attributes.empty? ? {} : { Table: attributes })
293
+ row_scope = column_scope ? :Both : :Row
294
+ placements_in(row).each do |placement|
295
+ scope = placement.column < @header_columns ? row_scope : column_scope
296
+ placement.cell.tagged(placement.cell.element(scope), row_tag)
297
+ end
296
298
  end
297
299
 
298
300
  def split_before(count, width)
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "etc"
4
+
5
+ module Stationery
6
+ # Renders many documents at once across Ractors (Stationery.render_all),
7
+ # on Ruby 4.0 and later (Ractor#value). The documents are dealt in turn to
8
+ # `ractors` Ractors, each renders its share with caches of its own (fonts
9
+ # parsed, images decoded, hyphenation loaded once per Ractor), and the PDFs
10
+ # come back in the documents' order, the bytes #to_pdf writes for each.
11
+ #
12
+ # A Ractor takes a copy of each document, so what a render leaves on the
13
+ # document (#warnings, #page_count) stays on the copy. A document a Ractor
14
+ # cannot render is rendered on the main Ractor once the others are done:
15
+ # one whose class's settings are not shareable (see Document.finalize!),
16
+ # one that cannot be copied (it holds a Proc, a Method, a Thread); one
17
+ # whose render reaches what a Ractor cannot; or one whose render raised,
18
+ # which raises there what #to_pdf raises. Options that cannot be copied
19
+ # send every document to the main Ractor. Below Ruby 4.0, or with one Ractor, every document is
20
+ # rendered on the main Ractor, in order. The Ractors' renders report no
21
+ # events (see Instrumentation.instrumenter); the main Ractor reports the
22
+ # batch.
23
+ module Parallel
24
+ class << self
25
+ # One `render_all.stationery` event: `documents:` (how many),
26
+ # `ractors:` (how many were started) and `main_ractor:` (the indexes
27
+ # of the documents rendered on the main Ractor).
28
+ def render(documents, ractors:, **options)
29
+ check(ractors)
30
+ documents = documents.to_a
31
+ Stationery.instrument("render_all.stationery", documents: documents.size) do |event|
32
+ pdfs = Array.new(documents.size)
33
+ event[:ractors] = parallel?(ractors, documents) ? across(documents, ractors, options, pdfs) : 0
34
+ event[:main_ractor] = pdfs.each_index.select { pdfs[it].nil? }
35
+ event[:main_ractor].each { pdfs[it] = documents[it].to_pdf(**options) }
36
+ pdfs
37
+ end
38
+ end
39
+
40
+ def available? = defined?(::Ractor) && ::Ractor.method_defined?(:value)
41
+
42
+ def default_ractors = Etc.nprocessors
43
+
44
+ private
45
+
46
+ def check(ractors)
47
+ return if ractors.is_a?(Integer) && ractors.positive?
48
+
49
+ raise ArgumentError, "ractors: must be a positive Integer, not #{ractors.inspect}"
50
+ end
51
+
52
+ def parallel?(ractors, documents)
53
+ ractors > 1 && documents.size > 1 && available?
54
+ end
55
+
56
+ # Fills `pdfs` with what the Ractors rendered, dealing the documents
57
+ # they may take to at most `ractors` of them in turn, and answers how
58
+ # many it started; a share a Ractor could not take or render stays
59
+ # nil, for the main Ractor.
60
+ def across(documents, ractors, options, pdfs)
61
+ indexes = documents.each_index.select { shareable?(documents[it]) }
62
+ count = [ractors, indexes.size].min
63
+ shares = Array.new(count) { |share| indexes.select.with_index { |_, position| position % count == share } }
64
+ workers = shares.map { |share| start(share, documents, options) }
65
+ workers.each do |share, ractor|
66
+ rendered = rendered(ractor)
67
+ share.each_with_index { |index, position| pdfs[index] = rendered[position] } if rendered
68
+ end
69
+ workers.count { |_, ractor| ractor }
70
+ end
71
+
72
+ # What a Ractor rendered, nil when none was started or it ended on an
73
+ # error that is not a StandardError (a SystemStackError): the main
74
+ # Ractor renders its share and raises that error itself.
75
+ def rendered(ractor)
76
+ ractor&.value
77
+ rescue ::Ractor::RemoteError
78
+ nil
79
+ end
80
+
81
+ def shareable?(document) = document.is_a?(Document) && ::Ractor.shareable?(document.class.config)
82
+
83
+ # The indexes of `share` a Ractor renders, and that Ractor. A document
84
+ # that cannot be copied into one is left out of the share, for the
85
+ # main Ractor, and the Ractor is started with the rest; when the
86
+ # options cannot be copied, no Ractor is (nil), and the main Ractor
87
+ # renders the whole share.
88
+ def start(share, documents, options)
89
+ [share, launch(share.map { documents[it] }, options)]
90
+ rescue TypeError, ::Ractor::Error
91
+ copyable = share.select { copyable?(documents[it]) }
92
+ copyable.empty? || copyable.size == share.size ? [share, nil] : start(copyable, documents, options)
93
+ end
94
+
95
+ # A Ractor rendering `documents`. It answers a nil for a render that
96
+ # failed: the main Ractor renders that document again and raises what
97
+ # to_pdf raises.
98
+ def launch(documents, options)
99
+ ::Ractor.new(documents, options) do |share, settings|
100
+ share.map do |document|
101
+ document.to_pdf(**settings)
102
+ rescue StandardError
103
+ nil
104
+ end
105
+ end
106
+ end
107
+
108
+ # Whether `document` can be copied into another Ractor: sent through
109
+ # a port, which copies it as Ractor.new does.
110
+ def copyable?(document)
111
+ port = ::Ractor::Port.new
112
+ begin
113
+ port.send(document)
114
+ port.receive
115
+ true
116
+ rescue TypeError, ::Ractor::Error
117
+ false
118
+ ensure
119
+ port.close
120
+ end
121
+ end
122
+ end
123
+ end
124
+
125
+ # The PDFs of `documents` (Stationery::Document instances), in their order,
126
+ # rendered across `ractors` Ractors (one per processor by default) on Ruby
127
+ # 4.0 and later, and on the main Ractor below it; `options` are passed to
128
+ # every #to_pdf. See Parallel.
129
+ def self.render_all(documents, ractors: Parallel.default_ractors, **)
130
+ Parallel.render(documents, ractors:, **)
131
+ end
132
+ end
@@ -4,8 +4,9 @@ require "erb"
4
4
  require "stationery/preview"
5
5
 
6
6
  module Stationery
7
- # Serves Stationery::Preview classes at /rails/stationery/previews. Loaded
8
- # by the Railtie when config.stationery.show_previews is on.
7
+ # Serves Stationery::Preview classes at /rails/stationery/previews.
8
+ # Autoloaded, on the first request to that route, when the Railtie has
9
+ # config.stationery.show_previews on.
9
10
  class PreviewsController < ActionController::Base
10
11
  before_action :require_previews
11
12
  before_action :load_previews
@@ -44,8 +44,11 @@ module Stationery
44
44
 
45
45
  def self.preview_paths(root, dirs) = dirs.map { root.join(it).to_s }.select { File.directory?(it) }
46
46
 
47
+ # The controller is autoloaded when the router first asks for it, after
48
+ # boot: defined while the app boots, its `helper :all` would constantize
49
+ # the app's helpers before the autoloader can find them.
47
50
  def self.add_previews(routes)
48
- ActiveSupport.on_load(:action_controller_base) { require "stationery/rails/previews_controller" }
51
+ Stationery.autoload :PreviewsController, "stationery/rails/previews_controller"
49
52
  routes.prepend do
50
53
  get "/rails/stationery/previews" => "stationery/previews#index", :as => :stationery_previews
51
54
  get "/rails/stationery/previews/*path" => "stationery/previews#show"
@@ -35,8 +35,9 @@ module Stationery
35
35
  def initialize(text:, language:, style: {}) = super
36
36
  end
37
37
  Rule = Data.define
38
- Table = Data.define(:rows, :style) do
39
- def initialize(rows:, style: {}) = super
38
+ # `footer` is how many of the last rows are a footer (`<tfoot>` in HTML).
39
+ Table = Data.define(:rows, :style, :footer) do
40
+ def initialize(rows:, style: {}, footer: 0) = super
40
41
  end
41
42
  Cell = Data.define(:header, :align, :blocks, :style) do
42
43
  def initialize(header:, align:, blocks:, style: {}) = super
@@ -103,9 +103,28 @@ module Stationery
103
103
 
104
104
  def table(table)
105
105
  header = table.rows.first&.all?(&:header) ? 1 : 0
106
+ header_columns = header_columns(table.rows, header, table.rows.size - table.footer)
106
107
  rows = table.rows.map { |row| row.map { |cell| cell_content(cell) } }
107
108
  cell = @styles[:table][:cell].merge(border_options(table.style[:border]))
108
- sized(table.style[:width]) { |width| @component.table(rows, header:, cell:, **width, **column_widths(table)) }
109
+ sized(table.style[:width]) do |width|
110
+ @component.table(rows, header:, header_columns:, cell:, **width, **column_widths(table))
111
+ end
112
+ end
113
+
114
+ # The th cells every body row starts with, as many as the shortest run
115
+ # of them; none when a body row starts with a td, and none without a
116
+ # body row (a table of a header row, or of header and footer rows). A
117
+ # footer row is not a body row: it takes the count, it does not set it.
118
+ def header_columns(rows, header, footer_start)
119
+ count = nil
120
+ header.upto(footer_start - 1) do |index|
121
+ row = rows[index]
122
+ run = row.index { |cell| !cell.header } || row.size
123
+ return 0 if run.zero?
124
+
125
+ count = run if count.nil? || run < count
126
+ end
127
+ count || 0
109
128
  end
110
129
 
111
130
  def cell_content(cell)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Stationery
4
- VERSION = "0.13.0"
4
+ VERSION = "0.13.2"
5
5
  end
data/lib/stationery.rb CHANGED
@@ -203,6 +203,7 @@ require_relative "stationery/regions"
203
203
  require_relative "stationery/page_templates"
204
204
  require_relative "stationery/structure"
205
205
  require_relative "stationery/document"
206
+ require_relative "stationery/parallel"
206
207
 
207
208
  # Pure-Ruby PDF documents built from Phlex-style components.
208
209
  module Stationery
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: stationery
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.13.0
4
+ version: 0.13.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -246,6 +246,7 @@ files:
246
246
  - lib/stationery/page.rb
247
247
  - lib/stationery/page/format.rb
248
248
  - lib/stationery/page_templates.rb
249
+ - lib/stationery/parallel.rb
249
250
  - lib/stationery/path.rb
250
251
  - lib/stationery/pdf/assembler.rb
251
252
  - lib/stationery/pdf/attachments.rb