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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +3 -2
- data/README.md +66 -2
- data/lib/stationery/barcode/data_matrix.rb +2 -2
- data/lib/stationery/barcode/data_matrix_placement.rb +1 -1
- data/lib/stationery/barcode/ean13.rb +2 -2
- data/lib/stationery/barcode/qr.rb +4 -4
- data/lib/stationery/barcode/qr_matrix.rb +1 -1
- data/lib/stationery/barcode/reed_solomon.rb +7 -6
- data/lib/stationery/canvas/debug.rb +1 -1
- data/lib/stationery/canvas/interface.rb +1 -1
- data/lib/stationery/canvas/marking.rb +1 -1
- data/lib/stationery/cli.rb +7 -2
- data/lib/stationery/color.rb +5 -15
- data/lib/stationery/css/stylesheet.rb +1 -1
- data/lib/stationery/document.rb +32 -8
- data/lib/stationery/examples.rb +1 -1
- data/lib/stationery/fonts/bundled.rb +3 -3
- data/lib/stationery/fonts/catalog.rb +4 -3
- data/lib/stationery/fonts/cff.rb +6 -7
- data/lib/stationery/fonts/cff_subset.rb +1 -1
- data/lib/stationery/fonts/family.rb +5 -2
- data/lib/stationery/fonts/font.rb +4 -1
- data/lib/stationery/fonts/gsub.rb +12 -8
- data/lib/stationery/fonts/installer.rb +2 -2
- data/lib/stationery/fonts/ligatures.rb +5 -3
- data/lib/stationery/fonts/registry.rb +9 -19
- data/lib/stationery/fonts/true_type.rb +14 -17
- data/lib/stationery/forms/appearance.rb +1 -1
- data/lib/stationery/forms/field.rb +2 -2
- data/lib/stationery/html/tree_builder/blocks.rb +2 -2
- data/lib/stationery/html/tree_builder.rb +2 -2
- data/lib/stationery/hyphenation.rb +4 -6
- data/lib/stationery/images/cache.rb +10 -15
- data/lib/stationery/images/images.rb +2 -2
- data/lib/stationery/images/jpeg/bit_reader.rb +1 -1
- data/lib/stationery/images/jpeg/scan.rb +1 -1
- data/lib/stationery/images/jpeg.rb +7 -4
- data/lib/stationery/images/png.rb +4 -3
- data/lib/stationery/images/webp/prefix_code.rb +1 -1
- data/lib/stationery/images/webp.rb +4 -3
- data/lib/stationery/instrumentation.rb +24 -4
- data/lib/stationery/layout/box.rb +2 -2
- data/lib/stationery/layout/table/cell.rb +4 -5
- data/lib/stationery/layout/table.rb +2 -2
- data/lib/stationery/layout/table_of_contents/entry.rb +1 -1
- data/lib/stationery/lru.rb +31 -0
- data/lib/stationery/memo.rb +33 -0
- data/lib/stationery/monochrome.rb +2 -2
- data/lib/stationery/page.rb +2 -2
- data/lib/stationery/pdf/conformance.rb +10 -6
- data/lib/stationery/pdf/encryption/aes.rb +1 -1
- data/lib/stationery/pdf/encryption/standard_security.rb +2 -2
- data/lib/stationery/pdf/factur_x.rb +2 -2
- data/lib/stationery/pdf/serializer.rb +1 -1
- data/lib/stationery/pdf/writer.rb +1 -1
- data/lib/stationery/pdf/xmp.rb +1 -1
- data/lib/stationery/preview.rb +15 -5
- data/lib/stationery/railtie.rb +3 -0
- data/lib/stationery/raster/png.rb +2 -2
- data/lib/stationery/raster/render.rb +3 -3
- data/lib/stationery/rich/renderer/inlines.rb +4 -4
- data/lib/stationery/rich/styles.rb +2 -2
- data/lib/stationery/shareable.rb +41 -0
- data/lib/stationery/skill.rb +2 -2
- data/lib/stationery/store.rb +42 -0
- data/lib/stationery/svg/region.rb +1 -1
- data/lib/stationery/testing/inspector.rb +1 -1
- data/lib/stationery/testing/marked_text.rb +1 -1
- data/lib/stationery/testing/page_reader.rb +1 -1
- data/lib/stationery/text/markup.rb +2 -2
- data/lib/stationery/verify/allowlist.rb +1 -1
- data/lib/stationery/verify/engines/pdfium.rb +1 -1
- data/lib/stationery/verify/engines/pdfjs.rb +1 -1
- data/lib/stationery/verify/engines/pdfkit.rb +1 -1
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery.rb +35 -1
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 20fc9e96d7c91ce004e7e9a25cbd9a2ae9d38b169707358c37e0f2cf0e48c711
|
|
4
|
+
data.tar.gz: c3a382d8a5e44aec3432f5e16576764ca87fe8225f2e5046b866480192ab36bc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 68a186240528e3394151ae13f71f56c0b6ab6c37f5ebbd82e15f3f2d2ff4e8ae7aecd7bf6c3262de2303778273b474e17c12e885ec65262a63b359503e4d2065
|
|
7
|
+
data.tar.gz: de90f228f712439d0ef21933892f58f29954099892c4a9178da93a4c81d16811ff449ec3f755c82a8f2f10305ea07d6eb8bd60f6c9bd668a1071324d77d80b05
|
data/CHANGELOG.md
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
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
|
-
-
|
|
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
|
|
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
|
-
|
|
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]]
|
|
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") }
|
|
13
|
-
G = R.map(&:reverse)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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] }
|
|
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.
|
data/lib/stationery/cli.rb
CHANGED
|
@@ -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
|
-
|
|
26
|
+
attr_reader :commands
|
|
23
27
|
|
|
24
28
|
def register(name, command)
|
|
25
|
-
commands
|
|
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)
|
data/lib/stationery/color.rb
CHANGED
|
@@ -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
|
|
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
|
-
#
|
|
29
|
-
|
|
30
|
-
|
|
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)
|
data/lib/stationery/document.rb
CHANGED
|
@@ -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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
data/lib/stationery/examples.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 } ||
|
data/lib/stationery/fonts/cff.rb
CHANGED
|
@@ -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, "-"]
|
|
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.
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
|
7
|
-
# count], ...] and `features` with the tags it can
|
|
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
|
|