stationery 0.12.2 → 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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4 -2
  3. data/README.md +74 -6
  4. data/examples/price_card.rb +10 -8
  5. data/lib/stationery/barcode/data_matrix.rb +2 -2
  6. data/lib/stationery/barcode/data_matrix_placement.rb +1 -1
  7. data/lib/stationery/barcode/ean13.rb +2 -2
  8. data/lib/stationery/barcode/qr.rb +4 -4
  9. data/lib/stationery/barcode/qr_matrix.rb +1 -1
  10. data/lib/stationery/barcode/reed_solomon.rb +7 -6
  11. data/lib/stationery/canvas/debug.rb +1 -1
  12. data/lib/stationery/canvas/interface.rb +1 -1
  13. data/lib/stationery/canvas/marking.rb +1 -1
  14. data/lib/stationery/cli.rb +7 -2
  15. data/lib/stationery/color.rb +5 -15
  16. data/lib/stationery/css/stylesheet.rb +1 -1
  17. data/lib/stationery/document.rb +32 -8
  18. data/lib/stationery/elements/forms.rb +2 -2
  19. data/lib/stationery/examples.rb +1 -1
  20. data/lib/stationery/fonts/bundled.rb +3 -3
  21. data/lib/stationery/fonts/catalog.rb +4 -3
  22. data/lib/stationery/fonts/cff.rb +6 -7
  23. data/lib/stationery/fonts/cff_subset.rb +1 -1
  24. data/lib/stationery/fonts/family.rb +5 -2
  25. data/lib/stationery/fonts/font.rb +4 -1
  26. data/lib/stationery/fonts/gsub.rb +12 -8
  27. data/lib/stationery/fonts/installer.rb +2 -2
  28. data/lib/stationery/fonts/ligatures.rb +5 -3
  29. data/lib/stationery/fonts/registry.rb +9 -19
  30. data/lib/stationery/fonts/true_type.rb +14 -17
  31. data/lib/stationery/forms/appearance.rb +6 -5
  32. data/lib/stationery/forms/field.rb +9 -5
  33. data/lib/stationery/html/tree_builder/blocks.rb +2 -2
  34. data/lib/stationery/html/tree_builder.rb +2 -2
  35. data/lib/stationery/hyphenation.rb +4 -6
  36. data/lib/stationery/images/cache.rb +10 -15
  37. data/lib/stationery/images/images.rb +2 -2
  38. data/lib/stationery/images/jpeg/bit_reader.rb +1 -1
  39. data/lib/stationery/images/jpeg/scan.rb +1 -1
  40. data/lib/stationery/images/jpeg.rb +7 -4
  41. data/lib/stationery/images/png.rb +4 -3
  42. data/lib/stationery/images/webp/prefix_code.rb +1 -1
  43. data/lib/stationery/images/webp.rb +4 -3
  44. data/lib/stationery/instrumentation.rb +24 -4
  45. data/lib/stationery/layout/box.rb +2 -2
  46. data/lib/stationery/layout/table/cell.rb +4 -5
  47. data/lib/stationery/layout/table.rb +2 -2
  48. data/lib/stationery/layout/table_of_contents/entry.rb +1 -1
  49. data/lib/stationery/lru.rb +31 -0
  50. data/lib/stationery/memo.rb +33 -0
  51. data/lib/stationery/monochrome.rb +2 -2
  52. data/lib/stationery/page.rb +2 -2
  53. data/lib/stationery/pdf/conformance.rb +10 -6
  54. data/lib/stationery/pdf/encryption/aes.rb +1 -1
  55. data/lib/stationery/pdf/encryption/standard_security.rb +2 -2
  56. data/lib/stationery/pdf/factur_x.rb +2 -2
  57. data/lib/stationery/pdf/serializer.rb +1 -1
  58. data/lib/stationery/pdf/writer.rb +1 -1
  59. data/lib/stationery/pdf/xmp.rb +1 -1
  60. data/lib/stationery/preview.rb +15 -5
  61. data/lib/stationery/railtie.rb +3 -0
  62. data/lib/stationery/raster/png.rb +2 -2
  63. data/lib/stationery/raster/render.rb +3 -3
  64. data/lib/stationery/rich/renderer/inlines.rb +4 -4
  65. data/lib/stationery/rich/styles.rb +2 -2
  66. data/lib/stationery/shareable.rb +41 -0
  67. data/lib/stationery/skill.rb +2 -2
  68. data/lib/stationery/store.rb +42 -0
  69. data/lib/stationery/svg/region.rb +1 -1
  70. data/lib/stationery/testing/inspector.rb +1 -1
  71. data/lib/stationery/testing/marked_text.rb +1 -1
  72. data/lib/stationery/testing/page_reader.rb +1 -1
  73. data/lib/stationery/text/markup.rb +2 -2
  74. data/lib/stationery/verify/allowlist.rb +1 -1
  75. data/lib/stationery/verify/engines/pdfium.rb +1 -1
  76. data/lib/stationery/verify/engines/pdfjs.rb +1 -1
  77. data/lib/stationery/verify/engines/pdfkit.rb +1 -1
  78. data/lib/stationery/version.rb +1 -1
  79. data/lib/stationery.rb +35 -1
  80. metadata +5 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a56459cfc65f70dab12835ced22ff09d88d5c7a66b8ea045ccb0776f75da27b6
4
- data.tar.gz: 44889ebe589bed206a1b04940e6b24bad97f3be38f2b53179d8c98522c1ff7f0
3
+ metadata.gz: 20fc9e96d7c91ce004e7e9a25cbd9a2ae9d38b169707358c37e0f2cf0e48c711
4
+ data.tar.gz: c3a382d8a5e44aec3432f5e16576764ca87fe8225f2e5046b866480192ab36bc
5
5
  SHA512:
6
- metadata.gz: 7c2af80a6c317cff9cea03b5259c63d6a2bfa74730371713b490713fc0e8cb311d5b644d4a239ff49843f312cbdb20504dc9f363195df1af2784948f5af0250a
7
- data.tar.gz: 2591078b7e474565a8f35338dac311800f5c175576428756ba111fef71dc1e68e4b971efe86e7c64b6458c0b162087b1b32e75da6f4d703df735aee7dc45bafc
6
+ metadata.gz: 68a186240528e3394151ae13f71f56c0b6ab6c37f5ebbd82e15f3f2d2ff4e8ae7aecd7bf6c3262de2303778273b474e17c12e885ec65262a63b359503e4d2065
7
+ data.tar.gz: de90f228f712439d0ef21933892f58f29954099892c4a9178da93a4c81d16811ff449ec3f755c82a8f2f10305ea07d6eb8bd60f6c9bd668a1071324d77d80b05
data/CHANGELOG.md CHANGED
@@ -1,12 +1,14 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.13.0
4
4
 
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`.
5
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.
6
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.
7
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`.
8
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`.
9
- - **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.
10
12
 
11
13
  ## 0.12.0 (2026-09-29)
12
14
 
data/README.md CHANGED
@@ -139,7 +139,7 @@ ls "$(bundle show stationery)/examples" # or: gem contents stationery
139
139
  | `keep_with_next: true \| points` | On `text`, `box` or `group`: never end a page with this node; with a number, keep at least that many points of what follows with it. |
140
140
  | `text_style(**style) { }` | Default text style for a block. |
141
141
  | `canvas(height:) { \|canvas, rect\| }` | Draw directly: rectangles, rounded rectangles, circles, lines, Bézier paths, clipping, images, links; `rotate(degrees, around:) { }` and `transform([a, b, c, d, e, f]) { }` blocks. |
142
- | `text_field(name, value:, width:, height:, multiline:, max_length:, comb:, read_only:, required:, font_size:, min_font_size:, max_font_size:, align:, color:, border:, background:, radius:, tooltip:, at:)` | An interactive text input (AcroForm). `width:` is `:full` or points; `font_size:` is points or `:auto`; `align:` is `:left`, `:center` or `:right`; dotted names (`"address.city"`) group fields. See [Forms](#forms). |
142
+ | `text_field(name, value:, width:, height:, multiline:, max_length:, comb:, read_only:, required:, font_size:, min_font_size:, max_font_size:, align:, color:, border:, background:, radius:, tooltip:, at:)` | An interactive text input (AcroForm). `width:` is `:full` or points; `font_size:` is points, `:auto` or `:fit`; `align:` is `:left`, `:center` or `:right`; dotted names (`"address.city"`) group fields. See [Forms](#forms). |
143
143
  | `checkbox(name, checked:, size:, label:, at:)` | An interactive check box, with an optional label drawn to its right. |
144
144
  | `radio(name, value, checked:, size:, label:, at:)` | One choice of a radio group: radios sharing `name` form one field whose value is the checked `value`. |
145
145
  | `select(name, options:, value:, width:, height:, editable:, font_size:, min_font_size:, max_font_size:, align:, color:, at:)` | A drop-down (combo box); `editable: true` also accepts typed values. |
@@ -418,8 +418,12 @@ signature_field "signature", label: "Signature of the applicant"
418
418
  `font_size: :auto` writes `0 Tf` and the appearance draws the largest size, in tenths of a point,
419
419
  at which one line fits the field's height and width (a `comb:` field's cells; a `multiline:`
420
420
  field wrapped to its width and height), within `min_font_size:` (4 by default) and
421
- `max_font_size:` (the height by default). A `comb:` field ignores `align:`, its cells place each
422
- character. Under `monochrome`, `color:` is checked like any other colour; under PDF/A a CMYK one
421
+ `max_font_size:` (the height by default). `font_size: :fit` sizes the value the same way and writes
422
+ the number it chose in `/DA`, so every viewer reads a size, where `0 Tf` showed 9 px in Firefox's
423
+ editable input (which still caps a size at the field's height ÷ 1.35 and sets it in its own face).
424
+ A longer value typed afterwards does not shrink, and an empty field is sized to its bound, so a
425
+ field left empty to be filled in wants `:auto`. `min_font_size:` and `max_font_size:` go with
426
+ either. A `comb:` field ignores `align:`, its cells place each character. Under `monochrome`, `color:` is checked like any other colour; under PDF/A a CMYK one
423
427
  is reported. A field that uses none of them is written as it was.
424
428
  - A radio group's value is its checked choice's `value` (`Off` when none is checked); a select box
425
429
  lists its `options:` and draws the chosen `value`; a signature field is left unsigned for the
@@ -1518,7 +1522,11 @@ font_family "Noto Sans" # found i
1518
1522
  font_family "Noto Sans", **Stationery::Fonts.paths(:noto_sans, dir: "vendor/fonts") # outside Rails
1519
1523
  ```
1520
1524
 
1521
- 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).
1522
1530
  From Ruby: `Stationery::Fonts.install(:noto_sans, into: "vendor/fonts")`
1523
1531
  returns `{ regular: path, bold: path, … }`; `Stationery::Fonts.catalog` lists
1524
1532
  the packs. `rake fonts:verify` (development only) downloads every pack and
@@ -1882,7 +1890,7 @@ own; lines read top to bottom, then left to right. An image is `{ x:, y:, width:
1882
1890
  [w, h] }` in the order drawn (the rectangle it fills, before any clip); a link has its `uri:`, or the
1883
1891
  `page:` and `top:` it goes to; a field has its full `name:`, `type:` (`:text`, `:choice`,
1884
1892
  `:checkbox`, `:radio`, `:button`, `:signature`), `value:`, for a button the `state:` that turns
1885
- it on, and for a text field or a choice the `align:`, `font_size:` (`:auto` for `0 Tf`) and `color:`
1893
+ it on, and for a text field or a choice the `align:`, `font_size:` (`:auto` for `0 Tf`, the number for `:fit`) and `color:`
1886
1894
  (`"#RRGGBB"`) its value is drawn in. `outline` is the bookmarks as `{ title:, page:, top:, children: }`. `metadata` leaves out the
1887
1895
  dates, which change with every render, so two renders of one document have the same layout.
1888
1896
  `warnings` are the render's messages when the subject is a document.
@@ -1933,6 +1941,66 @@ events instead (`Stationery.instrumenter = MyInstrumenter.new`), and
1933
1941
  spans inside a document. When you hit a slow render, a trace with these
1934
1942
  events attached is the most useful thing to put in an issue.
1935
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
+
1936
2004
  ## Performance
1937
2005
 
1938
2006
  `bundle exec rake bench` renders three documents with Stationery, with Prawn 2.5 +
@@ -1978,7 +2046,7 @@ without its gem.
1978
2046
  A lossless WebP is decoded in Ruby when it is first loaded, which a JPEG or an opaque PNG
1979
2047
  (both passed through) never is: a 1000 × 1000 px image takes 0.2 to 0.4 s on the machine
1980
2048
  above, by what is in it, and the image cache keeps it for the renders that follow. A JPEG is
1981
- 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
1982
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
1983
2051
  size a label or a preview needs (progressive: 3.4 s and 1.4 s; about four times as long
1984
2052
  without YJIT).
@@ -1,12 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # A shop-window price card a person fills in on screen and prints: the
4
- # product's name centred and shrunk to fit its line (`font_size: :auto`),
5
- # the price large, centred and red, the price per unit right-aligned in grey
6
- # and a select of units. The fields have no border and no background, so the
7
- # printed card shows the values alone; a viewer that redraws a field after
8
- # an edit keeps its alignment, colour and auto-size (/Q and /DA). Run it to
9
- # write examples/price_card.pdf:
4
+ # product's name centred and shrunk to fit its line, the price large,
5
+ # centred and red, the price per unit right-aligned in grey and a select of
6
+ # units. The name and price are fitted by the gem and the size it chose is
7
+ # written to the file (`font_size: :fit`), so every viewer shows them at
8
+ # that size. The fields have no border and no background, so the printed
9
+ # card shows the values alone; a viewer that redraws a field after an edit
10
+ # keeps its alignment, colour and size (/Q and /DA). Run it to write
11
+ # examples/price_card.pdf:
10
12
  #
11
13
  # ruby -Ilib examples/price_card.rb
12
14
  # ruby -Ilib exe/stationery render examples/price_card.rb
@@ -51,14 +53,14 @@ class ExamplePriceCard < Stationery::Document
51
53
  spacer 10
52
54
  label "Product"
53
55
  text_style(weight: :bold) do
54
- text_field "product", value: @product, height: 44, font_size: :auto, max_font_size: 32, align: :center,
56
+ text_field "product", value: @product, height: 44, font_size: :fit, max_font_size: 32, align: :center,
55
57
  tooltip: "Product", **BARE
56
58
  end
57
59
  rule height: 0.5, color: HAIRLINE
58
60
  spacer 10
59
61
  label "Price"
60
62
  text_style(weight: :bold) do
61
- text_field "price", value: @price, height: 150, font_size: :auto, max_font_size: 120, align: :center,
63
+ text_field "price", value: @price, height: 150, font_size: :fit, max_font_size: 120, align: :center,
62
64
  color: SALE, tooltip: "Price", **BARE
63
65
  end
64
66
  spacer 6
@@ -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
@@ -9,8 +9,8 @@ module Stationery
9
9
  module Elements
10
10
  # A text input. `width:` is :full or points; `multiline:`, `max_length:`,
11
11
  # `comb:` (a cell count, or true with max_length:), `read_only:`,
12
- # `required:`, `font_size:` (points, or :auto with `min_font_size:` and
13
- # `max_font_size:`), `align:` (:left, :center, :right), `color:`,
12
+ # `required:`, `font_size:` (points, or :auto or :fit with `min_font_size:`
13
+ # and `max_font_size:`), `align:` (:left, :center, :right), `color:`,
14
14
  # `border:`, `background:` and `radius:`.
15
15
  def text_field(name, value: "", width: :full, height: 22, at: nil, **)
16
16
  field_node(Forms::Field.new(:text, name, value: value.to_s, typeface: field_typeface, **), width:, height:, at:)
@@ -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)