stationery 0.12.3 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +3 -2
  3. data/README.md +66 -2
  4. data/lib/stationery/barcode/data_matrix.rb +2 -2
  5. data/lib/stationery/barcode/data_matrix_placement.rb +1 -1
  6. data/lib/stationery/barcode/ean13.rb +2 -2
  7. data/lib/stationery/barcode/qr.rb +4 -4
  8. data/lib/stationery/barcode/qr_matrix.rb +1 -1
  9. data/lib/stationery/barcode/reed_solomon.rb +7 -6
  10. data/lib/stationery/canvas/debug.rb +1 -1
  11. data/lib/stationery/canvas/interface.rb +1 -1
  12. data/lib/stationery/canvas/marking.rb +1 -1
  13. data/lib/stationery/cli.rb +7 -2
  14. data/lib/stationery/color.rb +5 -15
  15. data/lib/stationery/css/stylesheet.rb +1 -1
  16. data/lib/stationery/document.rb +32 -8
  17. data/lib/stationery/examples.rb +1 -1
  18. data/lib/stationery/fonts/bundled.rb +3 -3
  19. data/lib/stationery/fonts/catalog.rb +4 -3
  20. data/lib/stationery/fonts/cff.rb +6 -7
  21. data/lib/stationery/fonts/cff_subset.rb +1 -1
  22. data/lib/stationery/fonts/family.rb +5 -2
  23. data/lib/stationery/fonts/font.rb +4 -1
  24. data/lib/stationery/fonts/gsub.rb +12 -8
  25. data/lib/stationery/fonts/installer.rb +2 -2
  26. data/lib/stationery/fonts/ligatures.rb +5 -3
  27. data/lib/stationery/fonts/registry.rb +9 -19
  28. data/lib/stationery/fonts/true_type.rb +14 -17
  29. data/lib/stationery/forms/appearance.rb +1 -1
  30. data/lib/stationery/forms/field.rb +2 -2
  31. data/lib/stationery/html/tree_builder/blocks.rb +2 -2
  32. data/lib/stationery/html/tree_builder.rb +2 -2
  33. data/lib/stationery/hyphenation.rb +4 -6
  34. data/lib/stationery/images/cache.rb +10 -15
  35. data/lib/stationery/images/images.rb +2 -2
  36. data/lib/stationery/images/jpeg/bit_reader.rb +1 -1
  37. data/lib/stationery/images/jpeg/scan.rb +1 -1
  38. data/lib/stationery/images/jpeg.rb +7 -4
  39. data/lib/stationery/images/png.rb +4 -3
  40. data/lib/stationery/images/webp/prefix_code.rb +1 -1
  41. data/lib/stationery/images/webp.rb +4 -3
  42. data/lib/stationery/instrumentation.rb +24 -4
  43. data/lib/stationery/layout/box.rb +2 -2
  44. data/lib/stationery/layout/table/cell.rb +4 -5
  45. data/lib/stationery/layout/table.rb +2 -2
  46. data/lib/stationery/layout/table_of_contents/entry.rb +1 -1
  47. data/lib/stationery/lru.rb +31 -0
  48. data/lib/stationery/memo.rb +33 -0
  49. data/lib/stationery/monochrome.rb +2 -2
  50. data/lib/stationery/page.rb +2 -2
  51. data/lib/stationery/pdf/conformance.rb +10 -6
  52. data/lib/stationery/pdf/encryption/aes.rb +1 -1
  53. data/lib/stationery/pdf/encryption/standard_security.rb +2 -2
  54. data/lib/stationery/pdf/factur_x.rb +2 -2
  55. data/lib/stationery/pdf/serializer.rb +1 -1
  56. data/lib/stationery/pdf/writer.rb +1 -1
  57. data/lib/stationery/pdf/xmp.rb +1 -1
  58. data/lib/stationery/preview.rb +15 -5
  59. data/lib/stationery/railtie.rb +3 -0
  60. data/lib/stationery/raster/png.rb +2 -2
  61. data/lib/stationery/raster/render.rb +3 -3
  62. data/lib/stationery/rich/renderer/inlines.rb +4 -4
  63. data/lib/stationery/rich/styles.rb +2 -2
  64. data/lib/stationery/shareable.rb +41 -0
  65. data/lib/stationery/skill.rb +2 -2
  66. data/lib/stationery/store.rb +42 -0
  67. data/lib/stationery/svg/region.rb +1 -1
  68. data/lib/stationery/testing/inspector.rb +1 -1
  69. data/lib/stationery/testing/marked_text.rb +1 -1
  70. data/lib/stationery/testing/page_reader.rb +1 -1
  71. data/lib/stationery/text/markup.rb +2 -2
  72. data/lib/stationery/verify/allowlist.rb +1 -1
  73. data/lib/stationery/verify/engines/pdfium.rb +1 -1
  74. data/lib/stationery/verify/engines/pdfjs.rb +1 -1
  75. data/lib/stationery/verify/engines/pdfkit.rb +1 -1
  76. data/lib/stationery/version.rb +1 -1
  77. data/lib/stationery.rb +35 -1
  78. metadata +5 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 43646ec58820f24924dfbcaaf0bdd8170d5251699f8a0e052560b03dde2d343a
4
- data.tar.gz: 651b72e03ce4cd1d31cf713e729b58024bc9145fe7d3c31ea3732e87436771ae
3
+ metadata.gz: 20fc9e96d7c91ce004e7e9a25cbd9a2ae9d38b169707358c37e0f2cf0e48c711
4
+ data.tar.gz: c3a382d8a5e44aec3432f5e16576764ca87fe8225f2e5046b866480192ab36bc
5
5
  SHA512:
6
- metadata.gz: daf443749aecd4818744fc05248a138b65b14226e318bf5877ff14618b34255b45f5ce1126562aee70722386f83544502bd8769edc5125809d3add8904003146
7
- data.tar.gz: 72fc71f0732f1e9b202eb5a48cfedf47cf499808bb150dffb67a88f425e200f4bbd6b038aab2e89ab280e0cf9594b2d6e2045de85b27ef3e8c02eee332d4d15b
6
+ metadata.gz: 68a186240528e3394151ae13f71f56c0b6ab6c37f5ebbd82e15f3f2d2ff4e8ae7aecd7bf6c3262de2303778273b474e17c12e885ec65262a63b359503e4d2065
7
+ data.tar.gz: de90f228f712439d0ef21933892f58f29954099892c4a9178da93a4c81d16811ff449ec3f755c82a8f2f10305ea07d6eb8bd60f6c9bd668a1071324d77d80b05
data/CHANGELOG.md CHANGED
@@ -1,13 +1,14 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.13.0
4
4
 
5
5
  - 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`.
6
6
  - 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
7
  - 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
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
9
  - `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
- - **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.
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.
11
12
 
12
13
  ## 0.12.0 (2026-09-29)
13
14
 
data/README.md CHANGED
@@ -1522,7 +1522,11 @@ font_family "Noto Sans" # found i
1522
1522
  font_family "Noto Sans", **Stationery::Fonts.paths(:noto_sans, dir: "vendor/fonts") # outside Rails
1523
1523
  ```
1524
1524
 
1525
- or append it yourself: `Stationery.font_paths << File.expand_path("vendor/fonts")`.
1525
+ or append it yourself: `Stationery.font_paths << File.expand_path("vendor/fonts")`,
1526
+ before `Stationery.finalize!` freezes the list (see
1527
+ [Threads, fibers and Ractors](#threads-fibers-and-ractors); the Railtie runs it
1528
+ once the app has initialized, so add paths in `config/application.rb` or an
1529
+ initializer).
1526
1530
  From Ruby: `Stationery::Fonts.install(:noto_sans, into: "vendor/fonts")`
1527
1531
  returns `{ regular: path, bold: path, … }`; `Stationery::Fonts.catalog` lists
1528
1532
  the packs. `rake fonts:verify` (development only) downloads every pack and
@@ -1937,6 +1941,66 @@ events instead (`Stationery.instrumenter = MyInstrumenter.new`), and
1937
1941
  spans inside a document. When you hit a slow render, a trace with these
1938
1942
  events attached is the most useful thing to put in an issue.
1939
1943
 
1944
+ ## Threads, fibers and Ractors
1945
+
1946
+ A render keeps its state to itself: a document, its fonts' glyph usage and its
1947
+ pages belong to the render. What renders share is cached once per Ractor,
1948
+ behind the cache's own lock: parsed fonts (16 files), decoded images (16),
1949
+ colours parsed from Strings, hyphenation patterns. So any number of threads,
1950
+ or fibers of one Async reactor, may render at once, and a render inside a
1951
+ non-main Ractor (Ruby 4.0) fills caches of its own. A parsed font never
1952
+ changes once it is read, so it can be made shareable and handed to a Ractor
1953
+ as it is.
1954
+
1955
+ A Ractor can read only what is shareable, so boot ends with
1956
+ `Stationery.finalize!`, which freezes what renders read. The Railtie runs it
1957
+ once the app has initialized; a Rails-less app calls it once its setup is
1958
+ done:
1959
+
1960
+ ```ruby
1961
+ class InvoicePdf < Stationery::Document
1962
+ page size: :a4
1963
+ footer { |page| text "#{page.number} / #{page.count}" }
1964
+
1965
+ def initialize(lines) = @lines = lines
1966
+ def view_template = @lines.each { |line| text line }
1967
+ end
1968
+ Stationery.finalize! # outside Rails: font_paths and every document class loaded so far
1969
+
1970
+ pdfs = Array.new(4) { |i| Ractor.new(["Invoice #{i}"]) { |lines| InvoicePdf.new(lines).to_pdf } }.map(&:value)
1971
+ ```
1972
+
1973
+ - `Stationery.finalize!` freezes `Stationery.font_paths`, keeps the
1974
+ instrumenter in use and finalizes every document class loaded by then,
1975
+ with their subclasses (`Document.finalize!`). In a Rails app with
1976
+ `config.eager_load` (production, and the CI test runs of a Rails 7.1+ app)
1977
+ that is every class under `app/`. A class loaded later (autoloaded in
1978
+ development) or defined after boot is not finalized: it renders on the
1979
+ main Ractor as before, and `InvoicePdf.finalize!` finalizes it.
1980
+ - A finalized class's settings cannot change (a `FrozenError`); a subclass
1981
+ has its own. They are made Ractor-shareable as far as what they hold lets
1982
+ them be, and nothing raises: a block of a page template, header or footer
1983
+ that uses an unshareable local from around it, a `shaper` or a `sign` key
1984
+ that is not shareable, is kept as it is, so the class renders on the main
1985
+ Ractor as before and not inside another one.
1986
+ `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
1989
+ blocks call) may reach only what a Ractor can.
1990
+ A String the app gave is copied, not frozen in place, and an object the gem
1991
+ 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.
1995
+ - What a Ractor is handed (`Ractor.new(lines)`) is copied unless it is
1996
+ shareable.
1997
+
1998
+ The gem is held to this by [conformance-kit](https://github.com/zoolutions/conformance-kit):
1999
+ `bundle exec conformance-kit check` (on Ruby 4.0) renders a document of
2000
+ text, shapes, a JPEG, a table, a QR code and a footer inside a Ractor and
2001
+ compares its bytes with the main Ractor's, and audits every constant and
2002
+ class instance variable of the gem for state a Ractor cannot read.
2003
+
1940
2004
  ## Performance
1941
2005
 
1942
2006
  `bundle exec rake bench` renders three documents with Stationery, with Prawn 2.5 +
@@ -1982,7 +2046,7 @@ without its gem.
1982
2046
  A lossless WebP is decoded in Ruby when it is first loaded, which a JPEG or an opaque PNG
1983
2047
  (both passed through) never is: a 1000 × 1000 px image takes 0.2 to 0.4 s on the machine
1984
2048
  above, by what is in it, and the image cache keeps it for the renders that follow. A JPEG is
1985
- decoded only for `to_png` or a monochrome render, once per process and scale: a 1000 × 1000
2049
+ decoded only for `to_png` or a monochrome render, once per Ractor and scale: a 1000 × 1000
1986
2050
  px photo in 0.25 s, a 12-megapixel phone photo in 2.8 s at full size and 0.7 s at the quarter
1987
2051
  size a label or a preview needs (progressive: 3.4 s and 1.4 s; about four times as long
1988
2052
  without YJIT).
@@ -12,7 +12,7 @@ module Stationery
12
12
  class DataMatrix
13
13
  # Symbol size => [data codewords, error correction codewords, data
14
14
  # regions across, modules across a region, interleaved blocks].
15
- SIZES = {
15
+ SIZES = Stationery.shareable(
16
16
  10 => [3, 5, 1, 8, 1], 12 => [5, 7, 1, 10, 1], 14 => [8, 10, 1, 12, 1], 16 => [12, 12, 1, 14, 1],
17
17
  18 => [18, 14, 1, 16, 1], 20 => [22, 18, 1, 18, 1], 22 => [30, 20, 1, 20, 1], 24 => [36, 24, 1, 22, 1],
18
18
  26 => [44, 28, 1, 24, 1], 32 => [62, 36, 2, 14, 1], 36 => [86, 42, 2, 16, 1], 40 => [114, 48, 2, 18, 1],
@@ -20,7 +20,7 @@ module Stationery
20
20
  72 => [368, 144, 4, 16, 4], 80 => [456, 192, 4, 18, 4], 88 => [576, 224, 4, 20, 4],
21
21
  96 => [696, 272, 4, 22, 4], 104 => [816, 336, 4, 24, 6], 120 => [1050, 408, 6, 18, 6],
22
22
  132 => [1304, 496, 6, 20, 8], 144 => [1558, 620, 6, 22, 10]
23
- }.freeze
23
+ )
24
24
  PAD = 129
25
25
  UPPER_SHIFT = 235
26
26
  QUIET = 1
@@ -9,7 +9,7 @@ module Stationery
9
9
  # where a stroke meets the edge, and the fixed pattern in the corner
10
10
  # left over.
11
11
  class Placement
12
- UTAH = [[-2, -2], [-2, -1], [-1, -2], [-1, -1], [-1, 0], [0, -2], [0, -1], [0, 0]].freeze
12
+ UTAH = Stationery.shareable([[-2, -2], [-2, -1], [-1, -2], [-1, -1], [-1, 0], [0, -2], [0, -1], [0, 0]])
13
13
 
14
14
  attr_reader :grid
15
15
 
@@ -9,8 +9,8 @@ module Stationery
9
9
  # them with `text` when they are wanted).
10
10
  class EAN13
11
11
  L = %w[0001101 0011001 0010011 0111101 0100011 0110001 0101111 0111011 0110111 0001011].freeze
12
- R = L.map { |code| code.tr("01", "10") }.freeze
13
- G = R.map(&:reverse).freeze
12
+ R = Stationery.shareable(L.map { |code| code.tr("01", "10") })
13
+ G = Stationery.shareable(R.map(&:reverse))
14
14
  # Which of L and G the six digits on the left use, by the first digit.
15
15
  PARITY = %w[LLLLLL LLGLGG LLGGLG LLGGGL LGLLGG LGGLLG LGGGLL LGLGLG LGLGGL LGGLGL].freeze
16
16
  QUIET = 11
@@ -11,7 +11,7 @@ module Stationery
11
11
  class QR
12
12
  LEVELS = %i[l m q h].freeze
13
13
  # Error correction codewords a block, and blocks, by level and version.
14
- ECC_PER_BLOCK = {
14
+ ECC_PER_BLOCK = Stationery.shareable(
15
15
  l: [7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30,
16
16
  30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
17
17
  m: [10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28,
@@ -20,8 +20,8 @@ module Stationery
20
20
  30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
21
21
  h: [17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30,
22
22
  30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30]
23
- }.freeze
24
- BLOCKS = {
23
+ )
24
+ BLOCKS = Stationery.shareable(
25
25
  l: [1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18,
26
26
  19, 19, 20, 21, 22, 24, 25],
27
27
  m: [1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29,
@@ -30,7 +30,7 @@ module Stationery
30
30
  40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68],
31
31
  h: [1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, 37, 40, 42, 45,
32
32
  48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81]
33
- }.freeze
33
+ )
34
34
  QUIET = 4
35
35
  PADS = [0xEC, 0x11].freeze
36
36
  # An ECI segment naming UTF-8 (assignment 26), before text that is not ASCII.
@@ -18,7 +18,7 @@ module Stationery
18
18
  ->(x, y) { (((x * y) % 2) + ((x * y) % 3)).zero? },
19
19
  ->(x, y) { (((x * y) % 2) + ((x * y) % 3)).even? },
20
20
  ->(x, y) { (((x + y) % 2) + ((x * y) % 3)).even? }
21
- ].freeze
21
+ ].map { Stationery.shareable(it) }.freeze
22
22
 
23
23
  attr_reader :size, :modules, :mask
24
24
 
@@ -19,15 +19,16 @@ module Stationery
19
19
  end
20
20
  (255...512).each { |power| @exp[power] = @exp[power - 255] }
21
21
  @first_root = first_root
22
- @divisors = {}
22
+ @divisors = :"stationery_reed_solomon_#{polynomial}_#{first_root}"
23
23
  end
24
24
 
25
25
  def multiply(a, b) = a.zero? || b.zero? ? 0 : @exp[@log[a] + @log[b]]
26
26
 
27
27
  # The generator polynomial of `degree`, highest power first and its
28
- # leading 1 left out.
28
+ # leading 1 left out; made once per degree and Ractor (see Store), so
29
+ # the coder itself never changes and every Ractor can share it.
29
30
  def divisor(degree)
30
- @divisors[degree] ||= begin
31
+ Store.fetch(@divisors) { Memo.new }.fetch(degree) do
31
32
  result = Array.new(degree, 0)
32
33
  result[-1] = 1
33
34
  root = @exp[@first_root]
@@ -38,7 +39,7 @@ module Stationery
38
39
  end
39
40
  root = multiply(root, 2)
40
41
  end
41
- result
42
+ result.freeze
42
43
  end
43
44
  end
44
45
 
@@ -54,8 +55,8 @@ module Stationery
54
55
  result
55
56
  end
56
57
 
57
- QR = new(0x11D, 0)
58
- DATA_MATRIX = new(0x12D, 1)
58
+ QR = Stationery.shareable(new(0x11D, 0))
59
+ DATA_MATRIX = Stationery.shareable(new(0x12D, 1))
59
60
  end
60
61
  end
61
62
  end
@@ -11,7 +11,7 @@ module Stationery
11
11
  flow: "#9CA3AF", positioned: "#DB2777", image: "#0D9488", page: "#06B6D4", region: "#0EA5E9",
12
12
  field: "#7C3AED", stack: "#F59E0B"
13
13
  }.freeze
14
- DEBUG_DASHES = { padding: [2, 2], cell_padding: [2, 2], flow: [1, 2] }.freeze
14
+ DEBUG_DASHES = Stationery.shareable({ padding: [2, 2], cell_padding: [2, 2], flow: [1, 2] })
15
15
 
16
16
  def debug? = @debug == true || (@debug.is_a?(Array) && @debug.any?)
17
17
 
@@ -29,7 +29,7 @@ module Stationery
29
29
  include Debug
30
30
 
31
31
  # The callable #tag_runs hands its block where nothing is marked.
32
- PASS = ->(_element = nil, &block) { block.call }
32
+ PASS = Stationery.shareable(->(_element = nil, &block) { block.call })
33
33
 
34
34
  # The render's collector (nil on a bare canvas), for what a node
35
35
  # notices only while painting, such as an oversized image.
@@ -91,7 +91,7 @@ module Stationery
91
91
  end
92
92
  end
93
93
 
94
- PASS = ->(_element = nil, &block) { block.call }
94
+ PASS = Stationery.shareable(->(_element = nil, &block) { block.call })
95
95
 
96
96
  private
97
97
 
@@ -18,11 +18,16 @@ module Stationery
18
18
  # Raised by a command to fail with a message and exit code 1.
19
19
  class Error < StandardError; end
20
20
 
21
+ # Filled as the commands' files load, on the main Ractor; frozen, so a
22
+ # registration publishes a new Hash.
23
+ @commands = {}.freeze
24
+
21
25
  class << self
22
- def commands = @commands ||= {}
26
+ attr_reader :commands
23
27
 
24
28
  def register(name, command)
25
- commands[name.to_s] = command
29
+ # rubocop:disable-next ThreadSafety/ClassInstanceVariable -- written while the commands' files load, on the main Ractor; always a frozen Hash
30
+ @commands = @commands.merge(name.to_s => command).freeze
26
31
  end
27
32
 
28
33
  def start(argv, out: $stdout, err: $stderr) = new(out:, err:).run(argv.dup)
@@ -8,33 +8,23 @@ module Stationery
8
8
  # The colours parsed from Strings that are remembered, by their String:
9
9
  # every fill, stroke and run of text names its colour ("#000000"), and
10
10
  # parsing it again made eleven objects. Past this many the memo starts
11
- # over, as the fonts' memos do.
11
+ # over, as the fonts' memos do. Each Ractor keeps its own (see Store).
12
12
  MEMO = 256
13
13
 
14
- @parsed = {}
15
- @lock = Mutex.new
16
-
17
14
  attr_reader :space, :components, :fill, :stroke
18
15
 
19
16
  def self.parse(value)
20
17
  case value
21
18
  when Color then value
22
- when String then @parsed[value] || remember(value)
19
+ when String then memo.fetch(value) { from_hex(value) }
23
20
  when Array then from_array(value)
24
21
  else invalid(value)
25
22
  end
26
23
  end
27
24
 
28
- # A Hash keeps a frozen copy of a String key, so a String changed after
29
- # it was parsed does not change what the memo holds.
30
- def self.remember(value)
31
- color = from_hex(value)
32
- @lock.synchronize do
33
- @parsed.clear if @parsed.size >= MEMO
34
- @parsed[value] ||= color
35
- end
36
- end
37
- private_class_method :remember
25
+ # This Ractor's colours parsed from Strings (a Memo).
26
+ def self.memo = Store.fetch(:stationery_colors) { Memo.new(limit: MEMO) }
27
+ private_class_method :memo
38
28
 
39
29
  def self.from_hex(value)
40
30
  hex = value[HEX, 1] || invalid(value)
@@ -47,7 +47,7 @@ module Stationery
47
47
  @unsupported = unsupported
48
48
  end
49
49
 
50
- EMPTY = new([], []).freeze
50
+ EMPTY = Stationery.shareable(new([], []))
51
51
 
52
52
  def empty? = @rules.empty?
53
53
 
@@ -18,15 +18,39 @@ module Stationery
18
18
  INFO_KEYS = { title: :Title, author: :Author, subject: :Subject, keywords: :Keywords, creator: :Creator,
19
19
  producer: :Producer }.freeze
20
20
 
21
+ # The settings every document starts from, shareable as they are.
22
+ @config = Stationery.shareable(
23
+ { page: { size: :letter, margin: 36 }, families: {}, fallbacks: [], text: {}, metadata: {},
24
+ templates: [], regions: [], strict: false, tagged: false, incremental: false, images: {},
25
+ attachments: [], shaping: {}, print: {}, missing_glyphs: :raise }
26
+ )
27
+
21
28
  class << self
22
- def config
23
- @config ||= if superclass.respond_to?(:config)
24
- superclass.config.transform_values(&:dup)
25
- else
26
- { page: { size: :letter, margin: 36 }, families: {}, fallbacks: [], text: {}, metadata: {},
27
- templates: [], regions: [], strict: false, tagged: false, incremental: false, images: {},
28
- attachments: [], shaping: {}, print: {}, missing_glyphs: :raise }
29
- end
29
+ # The class's settings, built when the class is defined: each of its
30
+ # parent's copied, so a subclass's never reaches the parent, then
31
+ # changed by the calls of its class body, on the main Ractor. A render
32
+ # only reads them, and a non-main Ractor can once finalize! has run.
33
+ attr_reader :config
34
+
35
+ def inherited(subclass)
36
+ super
37
+ subclass.instance_variable_set(:@config, config.transform_values(&:dup))
38
+ end
39
+
40
+ # Ends the class's boot: its settings are frozen, and made
41
+ # Ractor-shareable as far as they can be (Stationery.settled), so the
42
+ # class renders inside any Ractor when all of them are. A block of its
43
+ # page templates, headers or footers that uses an unshareable local
44
+ # from around it, or a shaper that is not shareable itself, is kept as
45
+ # it is: the class then renders on the main Ractor only, as before.
46
+ # Never raises. Stationery.finalize! runs it on every class loaded by
47
+ # then; run it yourself on a class loaded later. After it the settings
48
+ # cannot change (FrozenError), and a subclass has its own. Answers the
49
+ # class; running it again changes nothing.
50
+ def finalize!
51
+ # rubocop:disable-next ThreadSafety/ClassInstanceVariable -- written at boot on the main Ractor, before any render
52
+ @config = Stationery.settled(config) unless config.frozen?
53
+ self
30
54
  end
31
55
 
32
56
  # `size:` is a name of Page::SIZES, [width, height] in points or with
@@ -5,7 +5,7 @@ module Stationery
5
5
  # each is and what each shows. `stationery examples` lists them and the
6
6
  # skill (see Skill) names them.
7
7
  module Examples
8
- DIR = File.expand_path("../../examples", __dir__)
8
+ DIR = File.expand_path("../../examples", __dir__).freeze
9
9
 
10
10
  module_function
11
11
 
@@ -6,12 +6,12 @@ module Stationery
6
6
  # read the first time a document draws with it, so requiring the gem
7
7
  # never touches the files.
8
8
  module Bundled
9
- DIR = File.expand_path("data", __dir__)
9
+ DIR = File.expand_path("data", __dir__).freeze
10
10
  DEFAULT = "Inter"
11
- FAMILIES = {
11
+ FAMILIES = Stationery.shareable(
12
12
  "Inter" => { regular: "Inter-Regular.ttf", bold: "Inter-Bold.ttf",
13
13
  italic: "Inter-Italic.ttf", bold_italic: "Inter-BoldItalic.ttf" }
14
- }.freeze
14
+ )
15
15
 
16
16
  def self.names = FAMILIES.keys
17
17
 
@@ -15,8 +15,9 @@ module Stationery
15
15
  "cee9892f9f0cc8fe882c9e9537ee6a89621d86ee7ceaf70b02e2b2b1c25c061a", "OFL.txt"].freeze
16
16
  LIBERATION = "https://github.com/liberationfonts/liberation-fonts/files/7261482/" \
17
17
  "liberation-fonts-ttf-2.1.5.tar.gz#liberation-fonts-ttf-2.1.5"
18
- LIBERATION_OFL = ["#{LIBERATION}/LICENSE",
19
- "93fed46019c38bbe566b479d22148e2e8a1e85ada614accb0211c37b2c61c19b", "OFL.txt"].freeze
18
+ LIBERATION_OFL = Stationery.shareable(["#{LIBERATION}/LICENSE",
19
+ "93fed46019c38bbe566b479d22148e2e8a1e85ada614accb0211c37b2c61c19b",
20
+ "OFL.txt"])
20
21
  LIBERATION_LICENSE = 'OFL-1.1, Reserved Font Name "Liberation"'
21
22
 
22
23
  # { style => [url, sha, "Family-Style.ttf"] } with the URL built from the file name.
@@ -90,7 +91,7 @@ module Stationery
90
91
  italic: "605c01c711b44480a7508d349dfbf3264e81fa43d69e61cfa7d10b86e764c4d1",
91
92
  bold_italic: "79451f3c09fe25116098853b7a2ca6e2436220ccc11af022979adbcf195be130")
92
93
  )
93
- ].freeze
94
+ ].map { Stationery.shareable(it) }.freeze
94
95
 
95
96
  def self.fetch(key)
96
97
  PACKS.find { |pack| pack.key.to_s == key.to_s } ||
@@ -15,7 +15,7 @@ module Stationery
15
15
  FD_ARRAY = 1236
16
16
  FD_SELECT = 1237
17
17
  SUBRS = 19
18
- REAL_NIBBLES = [*"0".."9", ".", "E", "E-", nil, "-"].freeze
18
+ REAL_NIBBLES = Stationery.shareable([*"0".."9", ".", "E", "E-", nil, "-"])
19
19
 
20
20
  # An INDEX from `start` to `stop` (exclusive); items are [offset, length].
21
21
  Index = Data.define(:start, :items, :stop)
@@ -32,6 +32,8 @@ module Stationery
32
32
  @charstrings = index_at(@top.fetch(CHARSTRINGS).first)
33
33
  @num_glyphs = @charstrings.items.size
34
34
  @charset = self.class.parse_charset(@data, @top.fetch(CHARSET).first, @num_glyphs) if cid_keyed?
35
+ @fd_array = index_at(@top.fetch(FD_ARRAY).first) if cid_keyed?
36
+ @local_subrs = (cid_keyed? ? 0...@fd_array.items.size : [0]).to_h { |fd| [fd, private_subrs(font_dict(fd))] }
35
37
  end
36
38
 
37
39
  def inspect = "#<#{self.class} glyphs=#{@num_glyphs}#{" cid" if cid_keyed?}>"
@@ -61,11 +63,9 @@ module Stationery
61
63
 
62
64
  # The local Subrs INDEX a glyph's charstring calls, or nil: the Private
63
65
  # DICT's, or in a CID-keyed font that of the Font DICT FDSelect gives
64
- # the glyph. Read once per Font DICT.
65
- def local_subrs(gid)
66
- fd = cid_keyed? ? fd_index(gid) : 0
67
- (@local_subrs ||= {}).fetch(fd) { @local_subrs[fd] = private_subrs(font_dict(fd)) }
68
- end
66
+ # the glyph. Every Font DICT's is read with the table, so a parse never
67
+ # changes once built.
68
+ def local_subrs(gid) = @local_subrs[cid_keyed? ? fd_index(gid) : 0]
69
69
 
70
70
  def item(index, number) = @data.byteslice(*index.items.fetch(number))
71
71
 
@@ -157,7 +157,6 @@ module Stationery
157
157
  def font_dict(number)
158
158
  return @top unless cid_keyed?
159
159
 
160
- @fd_array ||= index_at(@top.fetch(FD_ARRAY).first)
161
160
  self.class.parse_dict(item(@fd_array, number))
162
161
  end
163
162
 
@@ -10,7 +10,7 @@ module Stationery
10
10
  # layout they describe. Charstrings are not interpreted: a deprecated
11
11
  # `seac` accent in a drawn glyph would lose its base and accent glyphs.
12
12
  module CffSubset
13
- ENDCHAR = "\x0E".b
13
+ ENDCHAR = "\x0E".b.freeze
14
14
 
15
15
  module_function
16
16
 
@@ -28,8 +28,11 @@ module Stationery
28
28
  raise ArgumentError, "unknown font style #{unknown.join(", ")} (use #{STYLES.join(", ")})" if unknown.any?
29
29
  raise ArgumentError, "font family #{name} needs a regular face" unless paths[:regular]
30
30
 
31
- @name = name.to_s
32
- @paths = paths.transform_values(&:to_s).freeze
31
+ # Frozen copies, so a family is shareable as it is built and a
32
+ # document class holding it can be finalized for any Ractor.
33
+ @name = -name.to_s
34
+ @paths = paths.transform_values { -it.to_s }.freeze
35
+ freeze
33
36
  end
34
37
 
35
38
  def face(weight: :regular, style: :normal)
@@ -56,6 +56,7 @@ module Stationery
56
56
  @blanks = {}
57
57
  @shapes = {}.compare_by_identity
58
58
  @metrics = {}.compare_by_identity
59
+ @plans = {}.compare_by_identity
59
60
  @memoised = 0
60
61
  @keys = { true => {}, false => {} }
61
62
  @frozen_keys = { true => {}.compare_by_identity, false => {}.compare_by_identity }
@@ -256,7 +257,9 @@ module Stationery
256
257
 
257
258
  def substitute(gids, text, tags)
258
259
  start = 0
259
- glyphs = @ttf.ligatures.substitute(gids, tags)
260
+ ligatures = @ttf.ligatures
261
+ plan = @plans.fetch(tags) { @plans[tags] = ligatures.plan(tags) }
262
+ glyphs = ligatures.substitute(gids, tags, plan:)
260
263
  chars = glyphs.map { |_gid, count| text[start, count].tap { start += count } }
261
264
  [glyphs.map(&:first), chars]
262
265
  end
@@ -69,7 +69,7 @@ module Stationery
69
69
 
70
70
  def initialize(features)
71
71
  @features = features.freeze
72
- @plans = {}
72
+ @liga = plan_for(LIGA)
73
73
  freeze
74
74
  end
75
75
 
@@ -79,17 +79,21 @@ module Stationery
79
79
  # [[gid, number of source glyphs it stands for], ...] after applying the
80
80
  # lookups of `tags` (feature tag Strings) in LookupList order, each once.
81
81
  # Within a lookup the first subtable matching at a position wins and its
82
- # glyph is not matched again by that lookup.
83
- def substitute(gids, tags = LIGA)
84
- plan(tags).reduce(gids.map { |gid| [gid, 1] }) { |glyphs, subtables| apply(subtables, glyphs) }
82
+ # glyph is not matched again by that lookup. `plan:` is #plan of the
83
+ # tags, for a caller that keeps it.
84
+ def substitute(gids, tags = LIGA, plan: plan(tags))
85
+ plan.reduce(gids.map { |gid| [gid, 1] }) { |glyphs, subtables| apply(subtables, glyphs) }
85
86
  end
86
87
 
88
+ # The lookups the tags name, merged and ordered: those of the default
89
+ # tags made with the parse, so it never changes once built, any other
90
+ # list's when asked (each Font keeps the plans it uses).
91
+ def plan(tags) = tags == LIGA ? @liga : plan_for(tags)
92
+
87
93
  private
88
94
 
89
- # The lookups the tags name, merged and ordered; remembered per tag list.
90
- def plan(tags)
91
- @plans[tags] ||= tags.flat_map { |tag| @features[tag] || [] }.uniq(&:first).sort_by(&:first)
92
- .map(&:last).freeze
95
+ def plan_for(tags)
96
+ tags.flat_map { |tag| @features[tag] || [] }.uniq(&:first).sort_by(&:first).map(&:last).freeze
93
97
  end
94
98
 
95
99
  def apply(subtables, glyphs)
@@ -9,7 +9,7 @@ module Stationery
9
9
  # `force:`. Development-time only; documents never download fonts.
10
10
  class Installer
11
11
  # Fetches an https URL, following up to five redirects.
12
- HTTP = lambda do |url, redirects = 5|
12
+ HTTP = Stationery.shareable(lambda do |url, redirects = 5|
13
13
  require "net/http"
14
14
  uri = URI(url)
15
15
  raise Error, "refusing to download over #{uri.scheme}: #{url} (https only)" unless uri.scheme == "https"
@@ -23,7 +23,7 @@ module Stationery
23
23
  HTTP.call(uri.merge(response["location"]).to_s, redirects - 1)
24
24
  else raise Error, "HTTP #{response.code} fetching #{url}"
25
25
  end
26
- end
26
+ end)
27
27
 
28
28
  attr_reader :into
29
29
 
@@ -3,12 +3,14 @@
3
3
  module Stationery
4
4
  module Fonts
5
5
  # Chooses where a font's glyph substitutions come from. Every source
6
- # answers `substitute(gids, feature_tags)` with [[gid, source glyph
7
- # count], ...] and `features` with the tags it can apply.
6
+ # answers `substitute(gids, feature_tags, plan: plan(feature_tags))` with
7
+ # [[gid, source glyph count], ...] and `features` with the tags it can
8
+ # apply.
8
9
  module Ligatures
9
10
  # For fonts without a usable GSUB table: every glyph stands for itself.
10
11
  module NONE
11
- def self.substitute(gids, _tags = Gsub::LIGA) = gids.map { |gid| [gid, 1] }
12
+ def self.substitute(gids, _tags = Gsub::LIGA, **) = gids.map { |gid| [gid, 1] }
13
+ def self.plan(_tags) = nil
12
14
  def self.features = []
13
15
  end
14
16