stationery 0.5.0 → 0.7.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 +17 -0
- data/README.md +107 -15
- data/lib/stationery/builder.rb +5 -3
- data/lib/stationery/canvas/text.rb +4 -3
- data/lib/stationery/canvas.rb +5 -2
- data/lib/stationery/document.rb +59 -22
- data/lib/stationery/elements.rb +1 -1
- data/lib/stationery/fonts/fallback.rb +3 -3
- data/lib/stationery/fonts/font.rb +44 -22
- data/lib/stationery/fonts/font_book.rb +7 -2
- data/lib/stationery/fonts/gsub/single_subst.rb +37 -0
- data/lib/stationery/fonts/gsub.rb +65 -20
- data/lib/stationery/fonts/ligatures.rb +6 -4
- data/lib/stationery/fonts/registry.rb +7 -1
- data/lib/stationery/html/document.rb +5 -1
- data/lib/stationery/hyphenation/patterns/LICENSES.md +60 -0
- data/lib/stationery/hyphenation/patterns/de-1996.pat.txt +36709 -0
- data/lib/stationery/hyphenation/patterns/en-us.hyp.txt +14 -0
- data/lib/stationery/hyphenation/patterns/en-us.pat.txt +4938 -0
- data/lib/stationery/hyphenation/patterns/sv.pat.txt +4693 -0
- data/lib/stationery/hyphenation.rb +132 -0
- data/lib/stationery/images/images.rb +9 -1
- data/lib/stationery/images/png.rb +66 -0
- data/lib/stationery/images/resampled.rb +80 -0
- data/lib/stationery/instrumentation.rb +41 -0
- data/lib/stationery/layout/image.rb +28 -2
- data/lib/stationery/layout/paginator.rb +1 -1
- data/lib/stationery/layout/text.rb +2 -2
- data/lib/stationery/markdown/document.rb +4 -2
- data/lib/stationery/minitest.rb +5 -0
- data/lib/stationery/page_templates.rb +2 -1
- data/lib/stationery/pdf/assembler.rb +6 -2
- data/lib/stationery/pdf/page_labels.rb +71 -0
- data/lib/stationery/railtie.rb +5 -0
- data/lib/stationery/testing/inspector.rb +13 -0
- data/lib/stationery/testing/matchers.rb +10 -0
- data/lib/stationery/text/entities.rb +14 -4
- data/lib/stationery/text/paragraph.rb +1 -1
- data/lib/stationery/text/style.rb +14 -3
- data/lib/stationery/text/wrapper.rb +73 -12
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +6 -0
- data/lib/stationery.rb +5 -0
- metadata +11 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0f9e83f23437589a026e0a9a2fde163a39a2c4151c434afc5bdbea38a98891dd
|
|
4
|
+
data.tar.gz: 67e42695ae205e05049441490b89b908f6468ba94e00c095c1de8eaeae986d65
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 40756fe30e4c2d400fab7402e490609cf313e8ae06c5317489f6b0337f96fc5ad00d8f2c8b05b348812955b109cc0f9685f9c1668b8f307d1fd16ac9492facad
|
|
7
|
+
data.tar.gz: 1a7938f3b608b68df057d3385d5824dc6a57338978a76ad39bed42701838ee2a408db1c2d30daccf3a834b03bea9f6d1441ef6405691dff0090611d26cbe05a0
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.0 (2026-09-28)
|
|
4
|
+
|
|
5
|
+
Typography and images: hyphenation, OpenType features, oversized-image warnings, page labels.
|
|
6
|
+
|
|
7
|
+
- Hyphenation: `hyphenate: true | "en" | "de" | "sv"` on `text`, `text_style`, `default_text` and the rich `styles:` (`p`, `li`). Liang's algorithm over the TeX `hyph-utf8` patterns for English, German and Swedish (bundled with their licences), so a word that does not fit breaks at the longest hyphenation point that does, with the font's hyphen. A soft hyphen (U+00AD, `­` in HTML) is a break point in any language: never drawn or measured unless the line breaks there, and absent from extracted text. Off by default; unchanged output otherwise.
|
|
8
|
+
- OpenType features: `features: %i[smcp onum tnum ss01]` on the same entry points applies the font's GSUB single substitutions (formats 1 and 2, also behind Extension lookups) before ligatures; `Font#features` lists what a font offers, an absent feature is ignored. Substituted glyphs keep their source characters in ToUnicode. Contextual lookups (`calt`, `clig`, `frac`) stay unsupported and are listed as such.
|
|
9
|
+
- Oversized images: a bitmap drawn at more than twice `max_ppi` (300 by default; `images max_ppi: 220` per document, `image(max_ppi:)` per call, `nil` disables) reports `Warnings::OversizedImage` naming the source, pixel width, effective ppi and the limit, so `strict` catches a 1600 px photo drawn 160 pt wide. `downscale: true` resamples a PNG (grey, RGB, palette, alpha) to the limit before embedding; JPEG is never re-encoded and only warns.
|
|
10
|
+
- Page labels: `page_labels 1 => { style: :roman_lower }, 4 => { style: :decimal }, 12 => { style: :alpha, prefix: "Appendix " }` (or `to_pdf(page_labels:)`) writes the `/PageLabels` number tree; `Inspector#page_labels`, `have_page_labels` and `assert_page_labels` read it back.
|
|
11
|
+
- `text(…, markup: true)` decodes HTML character references (`&`, `'`, `'`, the named entities the HTML parser knows), so data escaped with `html_escape` before adding your own `<b>` tags renders as intended instead of showing `'`.
|
|
12
|
+
|
|
13
|
+
## 0.6.0 (2026-09-28)
|
|
14
|
+
|
|
15
|
+
Instrumentation, so a render shows up in AppSignal and friends, and a faster text pipeline.
|
|
16
|
+
|
|
17
|
+
- Instrumentation: `Stationery.instrument` emits `render.stationery` (the whole `to_pdf`: `document:`, `pages:`, `bytes:`, `warnings:`), `build.stationery`, `paginate.stationery`, `write.stationery` (serialise, subset, deflate), `image.stationery` (each decode on a cache miss: `format:`, `width:`, `height:`, `bytes:`), `font.stationery` (`action: :parse` on a cache miss, `action: :subset` per embedded font with `glyphs:`) and `parse.stationery` (`format: :html | :markdown`). `Stationery.instrumenter` is `ActiveSupport::Notifications` when it is loaded (the Railtie pins it), so AppSignal, Skylight and log subscribers pick the events up with no setup; otherwise a no-op. Assign any object responding to `instrument(name, payload, &block)` to plug something else in. Output is unchanged.
|
|
18
|
+
- `FontBook#resolve` memoises by family, weight and style instead of hashing the whole 13-field `Text::Style` on every measurement: the lookup is 4x faster and a photo-heavy 4-page document renders 22% faster.
|
|
19
|
+
|
|
3
20
|
## 0.5.0 (2026-09-27)
|
|
4
21
|
|
|
5
22
|
Rotated, overlapping, cropped photos (the collage look), and fixes from the first weeks of use.
|
data/README.md
CHANGED
|
@@ -70,7 +70,8 @@ InvoicePdf.new(invoice).to_pdf # => "%PDF-1.7…" (binary String)
|
|
|
70
70
|
InvoicePdf.new(invoice).to_pdf("a.pdf") # also writes a path or an IO
|
|
71
71
|
```
|
|
72
72
|
|
|
73
|
-
`examples/` has a complete, runnable invoice, annual report, letter, packing slip, fillable form
|
|
73
|
+
`examples/` has a complete, runnable invoice, annual report, letter, packing slip, fillable form, postcard collage and event flyer
|
|
74
|
+
([previews and live PDFs](https://stationery.zoolutions.llc/docs/examples)); `bundle exec rake examples`
|
|
74
75
|
renders them all, or render one with `stationery render examples/report.rb`.
|
|
75
76
|
|
|
76
77
|
## Elements
|
|
@@ -78,12 +79,12 @@ renders them all, or render one with `stationery render examples/report.rb`.
|
|
|
78
79
|
| Element | What it does |
|
|
79
80
|
|---|---|
|
|
80
81
|
| `text(string, **style)` | A paragraph. Plain strings are literal. |
|
|
81
|
-
| `text(string, markup: true)` | Reads `<b> <i> <u> <strikethrough> <sub> <sup> <br> <color rgb=""> <font size="" name=""> <link href="">`; decodes numeric and HTML 4 named entities. |
|
|
82
|
+
| `text(string, markup: true)` | Reads `<b> <i> <u> <strikethrough> <sub> <sup> <br> <color rgb=""> <font size="" name=""> <link href="">`; decodes numeric (`'`, `'`) and HTML 4 named entities once, so escape user data (`ERB::Util.html_escape`) before wrapping it in your own tags: `<b>` stays literal text. |
|
|
82
83
|
| `text { b "Total"; plain " due" }` | Styled runs in Ruby. Take a block argument (`{ \|t\| t.b @x }`) to keep your own `self`. |
|
|
83
84
|
| `box(padding:, background:, border:, radius:, width:, height:, min_height:, overflow:, at:, link:, outset:, break_inside:, decoration:, rotate:, shadow:) { }` | A container. Moves to the next page whole when it fits there and continues across pages when it does not; `break_inside: :auto` splits it at any page break, `:avoid` never splits it. At a cut, `decoration: :slice` (default) drops the padding and border, `:clone` keeps the padding. `overflow: :truncate` or `:shrink_to_fit` for fixed heights; a fixed `height:` never splits. `min_height:` is a floor that still splits: the first fragment keeps as much of it as the page holds, the next carries the rest (not combinable with `height:`). `at: [x, y]` pins it to a page position. `link:` makes the whole box clickable. `outset:` bleeds the background past the box (e.g. into the page margins). `rotate: -3` turns the painted box around its centre (layout box unchanged, never splits; a `link:` keeps its unrotated rectangle). `shadow: true` or `{ offset: [0, 4], blur: 8, color:, opacity: 0.15 }` paints a soft drop shadow under it, taking no space. `overflow: :hidden` clips the content to the rounded outline. |
|
|
84
85
|
| `row(gap:, align:, break_inside:) { column(width:) { } }` | Columns side by side. `width:` is points, a fraction (`0.5`), `:auto` or `nil` (equal share). Splits across pages like a box, every column at once; a row with a fixed-height column never splits; columns with `min_height:` do. |
|
|
85
86
|
| `table(rows, widths:, width:, header:, split_rows:, cell:) { \|t\| }` | Tables. Cells are strings, layout nodes, procs built with the DSL (`-> { image logo }`) or components. Style with `t.row(0)`, `t.rows(-1)`, `t.column(1)`, `t.columns(1..)`, chained, plus `t.zebra`. Header rows repeat after a page break. A cell may be `{ content:, colspan:, rowspan: }` plus any cell option; rows list only the cells they start, as in HTML, and pages never break through a rowspan. Spans are set in the rows, not through selections. A row taller than the page continues on the next page, cut through its cells, with the header repeated; `split_rows: true` cuts any row that reaches the page bottom instead of moving it whole. |
|
|
86
|
-
| `image(path_or_io, width:, height:, fit:, align:, radius:, rotate:)` | JPEG or PNG, aspect preserved. `fit: [w, h]` scales to fit inside; `fit: :cover` fills `width:` × `height:` and crops around the centre. `radius:` rounds the corners; `rotate:` turns it (degrees, clockwise) without changing the space it takes. |
|
|
87
|
+
| `image(path_or_io, width:, height:, fit:, align:, radius:, rotate:, max_ppi:, downscale:)` | JPEG or PNG, aspect preserved. `fit: [w, h]` scales to fit inside; `fit: :cover` fills `width:` × `height:` and crops around the centre. `radius:` rounds the corners; `rotate:` turns it (degrees, clockwise) without changing the space it takes. Drawn at more than twice `max_ppi:` (300) it is reported as oversized; `downscale: true` resamples a PNG to that resolution instead. |
|
|
87
88
|
| `svg(source_or_path, width:, height:, color:, align:)` | Vector icons and drawings; `currentColor` takes `color:`. Linear and radial gradients (`fill="url(#id)"`, `href` chains, both gradient units); `text`/`tspan` in the document's fonts; `<style>` stylesheets (element, class, id and `*` selectors). |
|
|
88
89
|
| `wrap(gap:, row_gap:, align:) { }` | Children side by side at their own widths, wrapping onto new rows (chips, tags). |
|
|
89
90
|
| `stack(gap:, align:) { }` | A base with layers painted over it: ordinary children set the height, `layer` children float over them and take no space. Moves to the next page whole. |
|
|
@@ -105,7 +106,7 @@ renders them all, or render one with `stationery render examples/report.rb`.
|
|
|
105
106
|
| `markdown(source, styles:, gap:, images:, base_path:, bookmarks:, links:)` | The same from CommonMark (plus GFM tables and strikethrough). |
|
|
106
107
|
|
|
107
108
|
Text style options: `font`, `size`, `weight` (`:regular`, `:bold`), `style` (`:italic`), `color`,
|
|
108
|
-
`letter_spacing`, `underline`, `strikethrough`, `link`, `opacity`, `kerning` (default `true`), `ligatures` (default `true`), `align` (`:left`, `:center`, `:right`, `:justify`), `leading`.
|
|
109
|
+
`letter_spacing`, `underline`, `strikethrough`, `link`, `opacity`, `kerning` (default `true`), `ligatures` (default `true`), `features` (OpenType feature tags, e.g. `%i[smcp onum]`), `hyphenate` (`true` for English, or `"de"`, `"sv"`; default off), `align` (`:left`, `:center`, `:right`, `:justify`), `leading`.
|
|
109
110
|
`align: :justify` stretches the spaces of wrapped lines to the full width; the last line, lines
|
|
110
111
|
ending in a newline and lines without spaces stay left-aligned (tabs are never stretched).
|
|
111
112
|
Colours are `"#RRGGBB"`, `"RRGGBB"`, `"#RGB"`, `[r, g, b]` (0-255) or `[c, m, y, k]` (0-100).
|
|
@@ -259,6 +260,10 @@ render Callout.new(color: "#F3F4F6") { text "Amount due" }
|
|
|
259
260
|
fits a normal page but not the last one continues onto an extra page.
|
|
260
261
|
- With a header or footer, a `page_template`'s `page.content_box` excludes their space (it is the
|
|
261
262
|
body's area).
|
|
263
|
+
- `page_labels 1 => { style: :roman_lower }, 3 => { style: :decimal, start: 1, prefix: "A-" }` names
|
|
264
|
+
the pages the way viewers show them ("i", "ii", "A-1", …): a 1-based first page mapped to the range
|
|
265
|
+
that starts there, with `style:` `:decimal`, `:roman`, `:roman_lower`, `:alpha`, `:alpha_lower` or
|
|
266
|
+
`nil` (prefix only), `start:` and `prefix:`. `to_pdf(page_labels:)` overrides it for one render.
|
|
262
267
|
- Content taller than a page is placed anyway; `document.warnings` lists every overflow.
|
|
263
268
|
- After `to_pdf`, `document.warnings` is an Enumerable of everything the render noticed but did not
|
|
264
269
|
raise on, each with a `#message`: overflows, SVG elements that were skipped (`UnsupportedSvg`), and
|
|
@@ -521,16 +526,48 @@ line is never wider than the same line unkerned.
|
|
|
521
526
|
|
|
522
527
|
Standard ligatures (fi, fl, ffi, …) come from the font's GSUB `liga` feature
|
|
523
528
|
(LigatureSubst lookups, also behind Extension lookups) and are on by default;
|
|
524
|
-
`ligatures: false` on an element or in `default_text` turns them off.
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
529
|
+
`ligatures: false` on an element or in `default_text` turns them off.
|
|
530
|
+
Ligatures form within a run of one style and font, never across a line
|
|
531
|
+
break, and letter spacing turns them off. The PDF's ToUnicode map sends a
|
|
532
|
+
ligature glyph back to all of its characters, so copied and extracted text
|
|
533
|
+
still reads "office". Fonts without a `liga` feature (such as the bundled
|
|
534
|
+
Inter) are unaffected.
|
|
535
|
+
|
|
536
|
+
Other OpenType features are opt-in per element, `text_style` or
|
|
537
|
+
`default_text`: `features: %i[smcp onum]` applies the font's small caps and
|
|
538
|
+
oldstyle figures, `tnum`/`pnum` pick tabular or proportional figures for
|
|
539
|
+
tables, `zero` a slashed zero, `dlig` discretionary ligatures, `ss01`… the
|
|
540
|
+
stylistic sets. Single (SingleSubst) and ligature (LigatureSubst) lookups
|
|
541
|
+
are applied in the font's own order; a feature the font lacks is ignored,
|
|
542
|
+
and `Font#features` lists what a font offers. Substituted glyphs keep
|
|
543
|
+
their source characters in ToUnicode, so "2026" in oldstyle figures still
|
|
544
|
+
extracts as "2026". Contextual features (`calt`, `clig`, `frac`) need
|
|
545
|
+
lookup types the reader does not implement and do nothing.
|
|
546
|
+
|
|
547
|
+
Hyphenation is off unless asked for: `hyphenate: "de"` on `text`, `text_style`,
|
|
548
|
+
`default_text` or an `html`/`markdown` style (`styles: { p: { hyphenate: "de" } }`)
|
|
549
|
+
breaks a word that does not fit at the longest point Liang's algorithm allows
|
|
550
|
+
over the bundled TeX patterns (`true` or `"en"` for American English, `"de"`
|
|
551
|
+
for German, `"sv"` for Swedish; `lib/stationery/hyphenation/patterns/LICENSES.md`
|
|
552
|
+
names their authors and licences), drawing a hyphen at the break. A soft hyphen
|
|
553
|
+
(U+00AD, `­` in HTML) names the break points of a word yourself and, as in
|
|
554
|
+
TeX, exempts that word from the patterns; it is never measured or drawn and
|
|
555
|
+
never reaches the PDF. `Stationery::Hyphenation.hyphenate("Silbentrennung", "de")`
|
|
556
|
+
answers `["Sil", "ben", "tren", "nung"]` for your own use.
|
|
530
557
|
|
|
531
558
|
Images are JPEG (grey, RGB, CMYK) and PNG (every colour type, alpha as a soft
|
|
532
559
|
mask). Parsed fonts and images are cached per process.
|
|
533
560
|
|
|
561
|
+
A bitmap keeps its pixels, so a 1600 px photo drawn 160 pt wide ships all
|
|
562
|
+
1600 px at 720 ppi. An image drawn at more than twice the document's
|
|
563
|
+
`max_ppi` (300, or `images max_ppi: 220` at class level, `nil` to switch the
|
|
564
|
+
check off) is reported as a `Warnings::OversizedImage` naming the file, its
|
|
565
|
+
pixel width and the resolution it lands at. `images downscale: true` (or
|
|
566
|
+
`image(..., downscale: true)`) resamples a PNG to `max_ppi` at its drawn size
|
|
567
|
+
before embedding it, alpha included; a JPEG is embedded byte for byte, so
|
|
568
|
+
resize it before you embed it (an ActiveStorage variant per drawn size,
|
|
569
|
+
preprocessed, keeps a render to a download).
|
|
570
|
+
|
|
534
571
|
## Testing
|
|
535
572
|
|
|
536
573
|
`stationery/rspec` and `stationery/minitest` read a rendered PDF back for
|
|
@@ -560,6 +597,7 @@ RSpec.describe InvoicePdf do
|
|
|
560
597
|
it { is_expected.to have_image_count(1) }
|
|
561
598
|
it { is_expected.to have_no_warnings }
|
|
562
599
|
it { is_expected.to have_pdf_language("en") } # the catalog /Lang from `metadata lang:`
|
|
600
|
+
it { is_expected.to have_page_labels(%w[i ii 1 2]) } # from `page_labels`
|
|
563
601
|
it { is_expected.to have_tagged_content } # a tagged PDF with every text tagged or an artifact
|
|
564
602
|
it { is_expected.to have_structure([[:Document, [[:H1, "Invoice"], [:P, "INV-7"]]]]) }
|
|
565
603
|
end
|
|
@@ -581,6 +619,7 @@ class InvoicePdfTest < Minitest::Test
|
|
|
581
619
|
assert_pdf_link pdf, /acme\.test/
|
|
582
620
|
assert_no_pdf_warnings pdf
|
|
583
621
|
assert_pdf_language pdf, "en"
|
|
622
|
+
assert_page_labels pdf, %w[i ii 1 2]
|
|
584
623
|
assert_tagged_content pdf
|
|
585
624
|
assert_pdf_structure pdf, [[:Document, [[:H1, "Invoice"], [:P, "INV-7"]]]]
|
|
586
625
|
end
|
|
@@ -592,7 +631,7 @@ The matcher names carry a `pdf_` prefix so they never clash with Capybara's
|
|
|
592
631
|
titles. The RSpec matchers compose like the built-ins: `.and` / `.or`, and inside
|
|
593
632
|
`all`, `include` or `match`. For anything else, `Stationery::Testing::Inspector.new(subject)`
|
|
594
633
|
exposes `text`, `page_texts`, `page_count`, `links`, `internal_links`,
|
|
595
|
-
`image_count`, `bookmarks`, `metadata`, `lang`, `warnings`, `tagged?`, `untagged_text` and
|
|
634
|
+
`image_count`, `bookmarks`, `metadata`, `lang`, `page_labels`, `warnings`, `tagged?`, `untagged_text` and
|
|
596
635
|
`structure` — a tagged PDF's structure tree as nested arrays, each element's text
|
|
597
636
|
read from its marked content: `[type, "text"]`, `[type, [children]]` (its own text
|
|
598
637
|
between the children, as for a `P` holding a `Link`) or `[type]` when empty; a
|
|
@@ -606,6 +645,41 @@ between the children, as for a `P` holding a `Link`) or `[type]` when empty; a
|
|
|
606
645
|
a browser per worker; stray processes and memory are the price.
|
|
607
646
|
- **Typst** is excellent but a native extension and a second template language.
|
|
608
647
|
|
|
648
|
+
## Instrumentation
|
|
649
|
+
|
|
650
|
+
Every render reports its phases as events named `<phase>.stationery`, so an
|
|
651
|
+
APM that subscribes to `ActiveSupport::Notifications` (AppSignal, Skylight,
|
|
652
|
+
Rails' own log subscribers) shows where a slow PDF spends its time with no
|
|
653
|
+
setup: AppSignal groups them under "stationery" in the event tree. Outside
|
|
654
|
+
Rails the events go to a null instrumenter that costs one method call.
|
|
655
|
+
|
|
656
|
+
| Event | Around | Payload |
|
|
657
|
+
|---|---|---|
|
|
658
|
+
| `render.stationery` | the whole `to_pdf` | `document:`, `pages:`, `bytes:`, `warnings:` (count) |
|
|
659
|
+
| `build.stationery` | building the component tree | `document:` |
|
|
660
|
+
| `paginate.stationery` | layout, page breaks and page templates | `document:`, `pages:` |
|
|
661
|
+
| `write.stationery` | serialising, subsetting and deflating | `document:`, `bytes:` |
|
|
662
|
+
| `image.stationery` | decoding one image (cache misses only) | `format:`, `width:`, `height:`, `bytes:` |
|
|
663
|
+
| `font.stationery` | parsing a font file (`action: :parse`, cache misses only) or subsetting one for a document (`action: :subset`, `glyphs:`) | `path:` or `font:`, `action:` |
|
|
664
|
+
| `parse.stationery` | parsing an `html` or `markdown` source | `format:`, `bytes:` |
|
|
665
|
+
|
|
666
|
+
Payload values known only afterwards (`pages:`, `bytes:`) are filled in
|
|
667
|
+
before the event finishes, so subscribers always see them. Log every phase
|
|
668
|
+
slower than 100 ms:
|
|
669
|
+
|
|
670
|
+
```ruby
|
|
671
|
+
ActiveSupport::Notifications.subscribe(/\.stationery\z/) do |name, start, finish, _id, payload|
|
|
672
|
+
ms = (finish - start) * 1000
|
|
673
|
+
Rails.logger.info("#{name} #{ms.round}ms #{payload.inspect}") if ms > 100
|
|
674
|
+
end
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
Any object answering `instrument(name, payload) { |payload| }` can take the
|
|
678
|
+
events instead (`Stationery.instrumenter = MyInstrumenter.new`), and
|
|
679
|
+
`Stationery.instrument("custom.stationery", key: value) { … }` adds your own
|
|
680
|
+
spans inside a document. When you hit a slow render, a trace with these
|
|
681
|
+
events attached is the most useful thing to put in an issue.
|
|
682
|
+
|
|
609
683
|
## Performance
|
|
610
684
|
|
|
611
685
|
`bundle exec rake bench` renders two documents with Stationery and with Prawn
|
|
@@ -626,10 +700,28 @@ larger file. `PROFILE=1 bundle exec ruby -Ilib benchmark/profile.rb` prints the
|
|
|
626
700
|
|
|
627
701
|
## Limitations
|
|
628
702
|
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
703
|
+
Fonts: no variable fonts (including CFF2) and no WOFF2 (it needs Brotli; convert to `.ttf` or
|
|
704
|
+
`.woff`); shaping stops at pair kerning and single or ligature substitutions (`liga` by default,
|
|
705
|
+
`smcp`, `onum`, `tnum`, `ss01`… on request), so contextual alternates (`calt`, `clig`, `frac`) do
|
|
706
|
+
nothing and scripts that need contextual shaping (Arabic, Indic, Thai) draw glyph by glyph, and colour or emoji glyphs no font
|
|
707
|
+
in the chain has are drawn as `.notdef` and reported. Text runs left to right; hyphenation
|
|
708
|
+
patterns are bundled for English, German and Swedish only (a soft hyphen works in any language),
|
|
709
|
+
and justification only widens spaces.
|
|
710
|
+
|
|
711
|
+
SVG covers the shapes, gradients, text and stylesheets icon sets use, nothing else: `use`,
|
|
712
|
+
`image`, `clipPath`, `mask`, `pattern`, `filter` and `textPath` are skipped and reported.
|
|
713
|
+
Images are JPEG and PNG (non-interlaced) and never fetched from a URL; a JPEG is embedded at its
|
|
714
|
+
source resolution (only PNGs can be downscaled), so an oversized one is reported, not resized. `html` and `markdown` render structure and inline marks, not CSS: only `text-align`
|
|
715
|
+
and inline `font-weight`/`font-style` are read, and raw HTML inside Markdown stays literal text.
|
|
716
|
+
|
|
717
|
+
Layout: a box with a fixed `height:` never splits (use `min_height:` for a floor that can); a row
|
|
718
|
+
splits only when every column can; a rotated box and a `stack` move to the next page whole. Text
|
|
719
|
+
does not wrap around images. Link and form-widget rectangles stay in page space inside `rotate`
|
|
720
|
+
and `transform`, and `shadow:` is stacked rectangles, not a blur.
|
|
721
|
+
|
|
722
|
+
PDF: tagged output is not labelled PDF/UA (no XMP), and there is no PDF/A or PDF/X, no digital
|
|
723
|
+
signing (`signature_field` is an empty field), no embedded files or JavaScript.
|
|
724
|
+
Form-field appearances use Helvetica (Windows-1252), not the document's fonts.
|
|
633
725
|
|
|
634
726
|
## License
|
|
635
727
|
|
data/lib/stationery/builder.rb
CHANGED
|
@@ -5,7 +5,7 @@ module Stationery
|
|
|
5
5
|
# (where the next node goes) and the text defaults in effect.
|
|
6
6
|
class Builder
|
|
7
7
|
STYLE_KEYS = %i[size color weight style letter_spacing underline strikethrough link opacity kerning
|
|
8
|
-
ligatures].freeze
|
|
8
|
+
ligatures features hyphenate].freeze
|
|
9
9
|
|
|
10
10
|
# Collects a row's columns; anything that is not already a column box is
|
|
11
11
|
# wrapped in one.
|
|
@@ -32,10 +32,12 @@ module Stationery
|
|
|
32
32
|
end
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
# `images` are the document's bitmap defaults (max_ppi:, downscale:).
|
|
36
|
+
attr_reader :root, :book, :list_depth, :images
|
|
36
37
|
|
|
37
|
-
def initialize(book:, text: {})
|
|
38
|
+
def initialize(book:, text: {}, images: {})
|
|
38
39
|
@book = book
|
|
40
|
+
@images = images
|
|
39
41
|
@root = Layout::Flow.new
|
|
40
42
|
@containers = [@root]
|
|
41
43
|
@text = [text]
|
|
@@ -8,14 +8,15 @@ module Stationery
|
|
|
8
8
|
|
|
9
9
|
# Draws `string` with its baseline at (x, y) in top-left coordinates and
|
|
10
10
|
# returns its advance width. Standard ligatures form unless `ligatures:`
|
|
11
|
-
# is false or letter spacing is set
|
|
11
|
+
# is false or letter spacing is set; `features:` are further OpenType
|
|
12
|
+
# feature tags (Strings) to apply.
|
|
12
13
|
def text(string, x:, y:, font:, size:, color: "#000000", letter_spacing: 0, rise: 0, opacity: nil,
|
|
13
14
|
synthetic_bold: false, synthetic_oblique: false, underline: false, strikethrough: false,
|
|
14
|
-
kerning: false, ligatures: true, word_spacing: 0)
|
|
15
|
+
kerning: false, ligatures: true, features: Fonts::Font::NO_FEATURES, word_spacing: 0)
|
|
15
16
|
return 0 if string.empty?
|
|
16
17
|
|
|
17
18
|
color = Color.parse(color)
|
|
18
|
-
run = font.glyph_run(string, kerning:, ligatures: ligatures && letter_spacing.zero
|
|
19
|
+
run = font.glyph_run(string, kerning:, ligatures: ligatures && letter_spacing.zero?, features:)
|
|
19
20
|
run = run.with_word_spacing(word_spacing, size) unless word_spacing.zero?
|
|
20
21
|
width = run.width(size, letter_spacing:)
|
|
21
22
|
graphics(opacity:) do |ops|
|
data/lib/stationery/canvas.rb
CHANGED
|
@@ -12,16 +12,19 @@ module Stationery
|
|
|
12
12
|
CAPS = { butt: 0, round: 1, square: 2 }.freeze
|
|
13
13
|
JOINS = { miter: 0, round: 1, bevel: 2 }.freeze
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
# `warnings` is the render's collector (nil on a bare canvas), for what a
|
|
16
|
+
# node notices only while painting, such as an oversized image.
|
|
17
|
+
attr_reader :page, :warnings
|
|
16
18
|
|
|
17
19
|
# `template: true` records anchors apart, for canvases page templates draw on.
|
|
18
20
|
# `tagging:` (a Tagging::Tree) marks content for a tagged PDF.
|
|
19
|
-
def initialize(page, resources, template: false, debug: false, tagging: nil)
|
|
21
|
+
def initialize(page, resources, template: false, debug: false, tagging: nil, warnings: nil)
|
|
20
22
|
@page = page
|
|
21
23
|
@resources = resources
|
|
22
24
|
@template = template
|
|
23
25
|
@debug = debug
|
|
24
26
|
@tagging = tagging
|
|
27
|
+
@warnings = warnings
|
|
25
28
|
@marked = 0
|
|
26
29
|
end
|
|
27
30
|
|
data/lib/stationery/document.rb
CHANGED
|
@@ -24,7 +24,7 @@ module Stationery
|
|
|
24
24
|
superclass.config.transform_values(&:dup)
|
|
25
25
|
else
|
|
26
26
|
{ page: { size: :letter, margin: 36 }, families: {}, fallbacks: [], text: {}, metadata: {},
|
|
27
|
-
templates: [], regions: [], strict: false, tagged: false }
|
|
27
|
+
templates: [], regions: [], strict: false, tagged: false, images: {} }
|
|
28
28
|
end
|
|
29
29
|
end
|
|
30
30
|
|
|
@@ -50,6 +50,21 @@ module Stationery
|
|
|
50
50
|
config[:metadata] = config[:metadata].merge(info)
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
+
# How viewers name the pages: a 1-based first page mapped to the range
|
|
54
|
+
# starting there, `page_labels 1 => { style: :roman_lower }, 3 =>
|
|
55
|
+
# { style: :decimal, start: 1, prefix: "A-" }`; see PDF::PageLabels.
|
|
56
|
+
def page_labels(spec)
|
|
57
|
+
PDF::PageLabels.entries(spec)
|
|
58
|
+
config[:page_labels] = spec
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Bitmap defaults: `max_ppi:` (300; nil disables) is the resolution
|
|
62
|
+
# above twice of which a drawn image is reported as oversized, and
|
|
63
|
+
# `downscale: true` resamples PNGs to it. See Layout::Image.
|
|
64
|
+
def images(**options)
|
|
65
|
+
config[:images] = config[:images].merge(options)
|
|
66
|
+
end
|
|
67
|
+
|
|
53
68
|
# Raise WarningsError instead of writing a PDF that produced warnings.
|
|
54
69
|
def strict(value = true) # rubocop:disable Style/OptionalBooleanParameter
|
|
55
70
|
config[:strict] = value
|
|
@@ -94,22 +109,10 @@ module Stationery
|
|
|
94
109
|
def metadata = self.class.config[:metadata]
|
|
95
110
|
|
|
96
111
|
def to_pdf(target = nil, strict: self.class.config[:strict], debug: false, encrypt: self.class.config[:encrypt],
|
|
97
|
-
tagged: self.class.config[:tagged])
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
book = Fonts::FontBook.new(self.class.config[:families], fallbacks: self.class.config[:fallbacks], warnings:)
|
|
102
|
-
call(builder = Builder.new(book:, text: self.class.config[:text]))
|
|
103
|
-
resources = Resources.new
|
|
104
|
-
pages = paginate(builder.root, book:, resources:, warnings:, debug:, tagging:)
|
|
105
|
-
outline = builder.outline.resolve(Structure.resolve(pages, warnings:, resources:, book:, tagging:))
|
|
106
|
-
tagging&.audit(pages, warnings, lang: metadata[:lang])
|
|
107
|
-
@warnings = warnings
|
|
108
|
-
@fields = Forms::AcroForm.values(pages)
|
|
109
|
-
raise WarningsError, warnings if strict && warnings.any?
|
|
110
|
-
|
|
111
|
-
assembler = PDF::Assembler.new(pages:, resources:, info:, outline:, encryption:, tagging:, lang: metadata[:lang])
|
|
112
|
-
write(assembler.render, target)
|
|
112
|
+
tagged: self.class.config[:tagged], page_labels: self.class.config[:page_labels])
|
|
113
|
+
Stationery.instrument("render.stationery", document: self.class.name) do |event|
|
|
114
|
+
write(render_pdf(event, strict:, debug:, encrypt:, tagged:, page_labels:), target)
|
|
115
|
+
end
|
|
113
116
|
end
|
|
114
117
|
|
|
115
118
|
# Used by page templates to build nodes into their own root.
|
|
@@ -123,18 +126,52 @@ module Stationery
|
|
|
123
126
|
|
|
124
127
|
# Builds a page template or region block into a fresh root node.
|
|
125
128
|
def template_root(info, book:, &)
|
|
126
|
-
builder =
|
|
129
|
+
builder = builder_for(book)
|
|
127
130
|
build_with(builder) { instance_exec(info, &) }
|
|
128
131
|
builder.root
|
|
129
132
|
end
|
|
130
133
|
|
|
131
134
|
private
|
|
132
135
|
|
|
136
|
+
def builder_for(book) = Builder.new(book:, text: self.class.config[:text], images: self.class.config[:images])
|
|
137
|
+
|
|
138
|
+
# The PDF bytes; `event` is the render.stationery payload it fills in.
|
|
139
|
+
def render_pdf(event, strict:, debug:, encrypt:, tagged:, page_labels:)
|
|
140
|
+
tagging = Tagging::Tree.new if tagged
|
|
141
|
+
warnings = Warnings.new
|
|
142
|
+
book = Fonts::FontBook.new(self.class.config[:families], fallbacks: self.class.config[:fallbacks], warnings:)
|
|
143
|
+
builder = builder_for(book)
|
|
144
|
+
Stationery.instrument("build.stationery", document: self.class.name) { call(builder) }
|
|
145
|
+
resources = Resources.new
|
|
146
|
+
pages = paginate(builder.root, book:, resources:, warnings:, debug:, tagging:)
|
|
147
|
+
outline = builder.outline.resolve(Structure.resolve(pages, warnings:, resources:, book:, tagging:))
|
|
148
|
+
tagging&.audit(pages, warnings, lang: metadata[:lang])
|
|
149
|
+
@warnings = warnings
|
|
150
|
+
@fields = Forms::AcroForm.values(pages)
|
|
151
|
+
event[:pages] = pages.size
|
|
152
|
+
event[:warnings] = warnings.size
|
|
153
|
+
raise WarningsError, warnings if strict && warnings.any?
|
|
154
|
+
|
|
155
|
+
assemble(pages, resources, outline, encrypt:, tagging:, page_labels:).tap { |pdf| event[:bytes] = pdf.bytesize }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def assemble(pages, resources, outline, encrypt:, tagging:, page_labels:)
|
|
159
|
+
encryption = encrypt && PDF::Encryption::StandardSecurity.new(**encrypt)
|
|
160
|
+
assembler = PDF::Assembler.new(pages:, resources:, info:, outline:, encryption:, tagging:,
|
|
161
|
+
lang: metadata[:lang], page_labels: PDF::PageLabels.entries(page_labels))
|
|
162
|
+
Stationery.instrument("write.stationery", document: self.class.name) do |event|
|
|
163
|
+
assembler.render.tap { |pdf| event[:bytes] = pdf.bytesize }
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
133
167
|
def paginate(root, book:, resources:, warnings:, debug:, tagging:)
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
168
|
+
Stationery.instrument("paginate.stationery", document: self.class.name) do |event|
|
|
169
|
+
regions = Regions.new(self.class.config[:regions], measure: region_measure(book))
|
|
170
|
+
paginator = Layout::Paginator.new(resources:, page: page_options, warnings:, debug:, regions:, tagging:)
|
|
171
|
+
paginator.paginate(root).tap do |pages|
|
|
172
|
+
PageTemplates.new(self, book:, resources:, debug:, regions:, warnings:, tagging:).apply(pages)
|
|
173
|
+
event[:pages] = pages.size
|
|
174
|
+
end
|
|
138
175
|
end
|
|
139
176
|
end
|
|
140
177
|
|
data/lib/stationery/elements.rb
CHANGED
|
@@ -123,7 +123,7 @@ module Stationery
|
|
|
123
123
|
# `alt:` describes the image in a tagged PDF (false: decorative). Sizing,
|
|
124
124
|
# `fit: :cover`, `radius:` and `rotate:` as for Layout::Image.
|
|
125
125
|
def image(source, align: nil, **)
|
|
126
|
-
node = Layout::Image.new(source, **)
|
|
126
|
+
node = Layout::Image.new(source, **@_builder.images, **)
|
|
127
127
|
@_builder.add(align ? Layout::Flow.new([node], align:) : node)
|
|
128
128
|
end
|
|
129
129
|
|
|
@@ -6,13 +6,13 @@ module Stationery
|
|
|
6
6
|
# run's family, then the book's fallbacks in order, then bundled Inter. A
|
|
7
7
|
# glyph no font has stays in the run's family and draws as .notdef.
|
|
8
8
|
#
|
|
9
|
-
# Whitespace (any Unicode White_Space), joiners, variation
|
|
10
|
-
# combining marks carry the family of the character before them (or
|
|
9
|
+
# Whitespace (any Unicode White_Space), soft hyphens, joiners, variation
|
|
10
|
+
# selectors and combining marks carry the family of the character before them (or
|
|
11
11
|
# after them at the start of a run) so words are not chopped and marks
|
|
12
12
|
# stay with their base; whitespace a font lacks draws as a blank of the
|
|
13
13
|
# right width (see Font::WHITESPACE).
|
|
14
14
|
class Fallback
|
|
15
|
-
CARRIED = /[\p{Space}
|
|
15
|
+
CARRIED = /[\p{Space}︀-️\p{M}]/
|
|
16
16
|
|
|
17
17
|
def self.carried?(char) = CARRIED.match?(char)
|
|
18
18
|
|
|
@@ -23,6 +23,7 @@ module Stationery
|
|
|
23
23
|
0x2005 => 0.25, 0x2006 => 1.0 / 6, 0x2009 => 0.2, 0x200A => 0.125, 0x205F => 4.0 / 18,
|
|
24
24
|
0x3000 => 1.0 }.freeze
|
|
25
25
|
SPACE_LIKE = { 0x2007 => "0", 0x2008 => "." }.freeze
|
|
26
|
+
NO_FEATURES = [].freeze
|
|
26
27
|
|
|
27
28
|
attr_reader :ttf
|
|
28
29
|
|
|
@@ -32,9 +33,9 @@ module Stationery
|
|
|
32
33
|
@pairs = {}
|
|
33
34
|
@glyphs = {}
|
|
34
35
|
@blanks = {}
|
|
35
|
-
@shapes = {
|
|
36
|
-
@advances = {
|
|
37
|
-
@kerns = {
|
|
36
|
+
@shapes = {}
|
|
37
|
+
@advances = {}
|
|
38
|
+
@kerns = {}
|
|
38
39
|
@cid_keyed = ttf.cff? && ttf.cff.cid_keyed?
|
|
39
40
|
end
|
|
40
41
|
|
|
@@ -44,14 +45,19 @@ module Stationery
|
|
|
44
45
|
# word again (wrapping, then laying out the line) allocates nothing.
|
|
45
46
|
# Letter spacing is added per glyph, so a ligature counts once; any
|
|
46
47
|
# letter spacing turns ligatures off, as it does when drawing.
|
|
47
|
-
|
|
48
|
+
# `features:` are OpenType feature tags applied on top of `liga`.
|
|
49
|
+
def width_of(text, size, letter_spacing: 0, kerning: false, ligatures: true, features: NO_FEATURES)
|
|
48
50
|
ligatures &&= letter_spacing.zero?
|
|
49
|
-
|
|
51
|
+
key = shape_key(ligatures, features)
|
|
52
|
+
width = scale(advance_units(text, key), size) + (letter_spacing * shape(text, key).first.size)
|
|
50
53
|
return width unless kerning
|
|
51
54
|
|
|
52
|
-
width + (kerning_units(text,
|
|
55
|
+
width + (kerning_units(text, key) * size / 1000.0)
|
|
53
56
|
end
|
|
54
57
|
|
|
58
|
+
# The GSUB feature tags this font can apply.
|
|
59
|
+
def features = @ttf.ligatures.features
|
|
60
|
+
|
|
55
61
|
def ascender(size) = scale(@ttf.ascender, size)
|
|
56
62
|
def descender(size) = -scale(@ttf.descender, size)
|
|
57
63
|
def line_gap(size) = scale(@ttf.line_gap, size)
|
|
@@ -69,10 +75,11 @@ module Stationery
|
|
|
69
75
|
|
|
70
76
|
def encode(text) = glyph_run(text).gids.map { |gid| code(gid) }.pack("n*")
|
|
71
77
|
|
|
72
|
-
# `ligatures:` substitutes the font's standard ligatures
|
|
73
|
-
#
|
|
74
|
-
|
|
75
|
-
|
|
78
|
+
# `ligatures:` substitutes the font's standard ligatures and `features:`
|
|
79
|
+
# any further OpenType features; `kerning:` then fills the adjustments
|
|
80
|
+
# with pair kerning between the glyphs.
|
|
81
|
+
def glyph_run(text, kerning: false, ligatures: true, features: NO_FEATURES)
|
|
82
|
+
gids, chars, blanks = shape(text, shape_key(ligatures, features))
|
|
76
83
|
gids.each_with_index { |gid, i| @used[gid] ||= blanks[i] ? " " : chars[i] }
|
|
77
84
|
adjust = gids.each_with_index.map do |gid, i|
|
|
78
85
|
kern = kerning && i + 1 < gids.size ? pair(gid, gids[i + 1]) : 0
|
|
@@ -104,7 +111,11 @@ module Stationery
|
|
|
104
111
|
|
|
105
112
|
def build(writer)
|
|
106
113
|
embedding = (@ttf.cff? ? Embedding::CFF : Embedding::TrueType).new(self)
|
|
107
|
-
|
|
114
|
+
gids = @used.keys.sort
|
|
115
|
+
name, cid_font = Stationery.instrument("font.stationery", font: @ttf.postscript_name, action: :subset,
|
|
116
|
+
glyphs: gids.size) do
|
|
117
|
+
embedding.build(writer, gids)
|
|
118
|
+
end
|
|
108
119
|
|
|
109
120
|
writer.add(
|
|
110
121
|
Type: :Font, Subtype: :Type0, BaseFont: name, Encoding: :"Identity-H",
|
|
@@ -114,19 +125,30 @@ module Stationery
|
|
|
114
125
|
|
|
115
126
|
private
|
|
116
127
|
|
|
128
|
+
# The feature tags to substitute with: `liga` when ligatures are on, plus
|
|
129
|
+
# the requested features. Frozen and shared, so it keys the memos cheaply.
|
|
130
|
+
def shape_key(ligatures, features)
|
|
131
|
+
features = Text::Style.features(features) unless features.frozen? && features.all?(String)
|
|
132
|
+
return features unless ligatures
|
|
133
|
+
return Gsub::LIGA if features.empty?
|
|
134
|
+
|
|
135
|
+
@keys ||= {}
|
|
136
|
+
@keys[features] ||= (features + Gsub::LIGA).sort.freeze
|
|
137
|
+
end
|
|
138
|
+
|
|
117
139
|
# [gids, source text of each glyph, extra advance in font units after
|
|
118
|
-
# each blank or nil], remembered per string.
|
|
119
|
-
def shape(text,
|
|
120
|
-
@shapes[
|
|
140
|
+
# each blank or nil], remembered per string and feature tags.
|
|
141
|
+
def shape(text, tags)
|
|
142
|
+
(@shapes[tags] ||= {})[text] ||= begin
|
|
121
143
|
gids = text.each_char.map { |char| glyph_for(char) }
|
|
122
|
-
gids, chars =
|
|
144
|
+
gids, chars = tags.empty? ? [gids, text.chars] : substitute(gids, text, tags)
|
|
123
145
|
[gids, chars, chars.map { |char| blank_units(char) }].each(&:freeze).freeze
|
|
124
146
|
end
|
|
125
147
|
end
|
|
126
148
|
|
|
127
|
-
def
|
|
149
|
+
def substitute(gids, text, tags)
|
|
128
150
|
start = 0
|
|
129
|
-
glyphs = @ttf.ligatures.substitute(gids)
|
|
151
|
+
glyphs = @ttf.ligatures.substitute(gids, tags)
|
|
130
152
|
chars = glyphs.map { |_gid, count| text[start, count].tap { start += count } }
|
|
131
153
|
[glyphs.map(&:first), chars]
|
|
132
154
|
end
|
|
@@ -148,15 +170,15 @@ module Stationery
|
|
|
148
170
|
width - space
|
|
149
171
|
end
|
|
150
172
|
|
|
151
|
-
def advance_units(text,
|
|
152
|
-
@advances[
|
|
153
|
-
gids, _, blanks = shape(text,
|
|
173
|
+
def advance_units(text, tags)
|
|
174
|
+
(@advances[tags] ||= {})[text] ||= begin
|
|
175
|
+
gids, _, blanks = shape(text, tags)
|
|
154
176
|
gids.sum { |gid| @ttf.advance(gid) } + blanks.sum { |units| units || 0 }
|
|
155
177
|
end
|
|
156
178
|
end
|
|
157
179
|
|
|
158
|
-
def kerning_units(text,
|
|
159
|
-
@kerns[
|
|
180
|
+
def kerning_units(text, tags)
|
|
181
|
+
(@kerns[tags] ||= {})[text] ||= shape(text, tags).first.each_cons(2).sum { |left, right| pair(left, right) }
|
|
160
182
|
end
|
|
161
183
|
|
|
162
184
|
# Kerning between two glyphs in thousandths of an em.
|
|
@@ -26,9 +26,14 @@ module Stationery
|
|
|
26
26
|
@families[name.to_s] = Family.build(name, **paths)
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
# Returns [Font, Family::Face] for a Text::Style, memoised
|
|
29
|
+
# Returns [Font, Family::Face] for a Text::Style, memoised by the three
|
|
30
|
+
# fields that pick a face (family, weight, style) in nested hashes: a
|
|
31
|
+
# style's size, colour and the rest do not matter here, and hashing a
|
|
32
|
+
# 13-field Data on every text measurement did.
|
|
30
33
|
def resolve(style)
|
|
31
|
-
@resolved[style] ||=
|
|
34
|
+
weights = (@resolved[style.family] ||= {})
|
|
35
|
+
styles = (weights[style.weight] ||= {})
|
|
36
|
+
styles[style.style] ||= begin
|
|
32
37
|
face = family(style.family).face(weight: style.weight, style: style.style)
|
|
33
38
|
[@fonts[face.path] ||= Font.new(Registry.load(face.path)), face].freeze
|
|
34
39
|
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Fonts
|
|
5
|
+
class Gsub
|
|
6
|
+
# SingleSubst (GSUB lookup type 1): one glyph for another. Format 1
|
|
7
|
+
# adds a delta to every covered glyph, format 2 lists the substitute
|
|
8
|
+
# for each covered glyph in coverage order. Small caps, oldstyle and
|
|
9
|
+
# tabular figures, stylistic sets and slashed zero are all this.
|
|
10
|
+
class SingleSubst
|
|
11
|
+
def self.read(ttf, offset)
|
|
12
|
+
format = ttf.u16(offset)
|
|
13
|
+
coverage = Gpos::Coverage.read(ttf, offset + ttf.u16(offset + 2))
|
|
14
|
+
case format
|
|
15
|
+
when 1
|
|
16
|
+
delta = ttf.i16(offset + 4)
|
|
17
|
+
new(coverage.to_h { |gid| [gid, (gid + delta) & 0xFFFF] })
|
|
18
|
+
when 2
|
|
19
|
+
substitutes = ttf.data.byteslice(offset + 6, ttf.u16(offset + 4) * 2).unpack("n*")
|
|
20
|
+
new(coverage.zip(substitutes).to_h.compact)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(map)
|
|
25
|
+
@map = map.freeze
|
|
26
|
+
freeze
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# [substitute gid, 1] for a covered glyph at `index`, or nil.
|
|
30
|
+
def match(gids, index)
|
|
31
|
+
gid = @map[gids[index]]
|
|
32
|
+
[gid, 1] if gid
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|