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 +4 -4
- data/CHANGELOG.md +36 -4
- data/README.md +53 -10
- data/examples/invoice.rb +2 -2
- data/lib/stationery/document.rb +6 -1
- data/lib/stationery/elements.rb +4 -3
- data/lib/stationery/html/tree_builder/blocks.rb +15 -8
- data/lib/stationery/instrumentation.rb +8 -5
- data/lib/stationery/layout/box.rb +24 -12
- data/lib/stationery/layout/table/cell.rb +8 -0
- data/lib/stationery/layout/table/selection.rb +4 -2
- data/lib/stationery/layout/table/stream.rb +3 -1
- data/lib/stationery/layout/table.rb +13 -11
- data/lib/stationery/parallel.rb +132 -0
- data/lib/stationery/rails/previews_controller.rb +3 -2
- data/lib/stationery/railtie.rb +4 -1
- data/lib/stationery/rich/nodes.rb +3 -2
- data/lib/stationery/rich/renderer.rb +20 -1
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e03d6f50713c0cec7dbd9031d299b3140f9923b314dc5614dad901fb63cde360
|
|
4
|
+
data.tar.gz: d238e07ce4a9f1c5072d9aa76aea0ae94f47258d11dbc7ec2e40ab48fef8fb10
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0f3117480d7eeed816d05f031811d5a721f52b7a3d96456b8e18bd368348b15b006c844593d2584c76a7524cc48461a3bd231532079ffba39ab1e42dbb1b958b
|
|
7
|
+
data.tar.gz: '07832a5fac782595cbfd0e9b6c1f55cfdf6c0e2d275b8623850d8566ebf7a62debb347df9b76d27fb9935c54b98e21ad4257cb548005c3463f6f57ec272e1a25'
|
data/CHANGELOG.md
CHANGED
|
@@ -1,14 +1,46 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
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
|
-
|
|
11
|
-
|
|
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 (`'`, `'`) and HTML 4 named entities once, so escape user data (`ERB::Util.html_escape`) before wrapping it in your own tags: `<b>` 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
|
|
864
|
-
`/
|
|
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
|
`` 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
|
|
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
|
-
-
|
|
1993
|
-
|
|
1994
|
-
|
|
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"
|
data/lib/stationery/document.rb
CHANGED
|
@@ -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.
|
data/lib/stationery/elements.rb
CHANGED
|
@@ -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: {},
|
|
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:,
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
152
|
-
elements(element).
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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.
|
|
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
|
|
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] -
|
|
160
|
+
sides = @border ? @border[:sides] - cut : []
|
|
158
161
|
Geometry::SIDES.keys.zip(@padding).map do |side, padding|
|
|
159
|
-
(
|
|
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
|
|
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 &&
|
|
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] -
|
|
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
|
-
#
|
|
218
|
-
|
|
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
|
|
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
|
|
277
|
+
# sides are square or rounded like the background's.
|
|
266
278
|
def clip_outline(canvas, area, &)
|
|
267
|
-
return yield unless @overflow == :hidden &&
|
|
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}="
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
288
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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.
|
|
8
|
-
#
|
|
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
|
data/lib/stationery/railtie.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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])
|
|
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)
|
data/lib/stationery/version.rb
CHANGED
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.
|
|
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
|