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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -0
  3. data/README.md +107 -15
  4. data/lib/stationery/builder.rb +5 -3
  5. data/lib/stationery/canvas/text.rb +4 -3
  6. data/lib/stationery/canvas.rb +5 -2
  7. data/lib/stationery/document.rb +59 -22
  8. data/lib/stationery/elements.rb +1 -1
  9. data/lib/stationery/fonts/fallback.rb +3 -3
  10. data/lib/stationery/fonts/font.rb +44 -22
  11. data/lib/stationery/fonts/font_book.rb +7 -2
  12. data/lib/stationery/fonts/gsub/single_subst.rb +37 -0
  13. data/lib/stationery/fonts/gsub.rb +65 -20
  14. data/lib/stationery/fonts/ligatures.rb +6 -4
  15. data/lib/stationery/fonts/registry.rb +7 -1
  16. data/lib/stationery/html/document.rb +5 -1
  17. data/lib/stationery/hyphenation/patterns/LICENSES.md +60 -0
  18. data/lib/stationery/hyphenation/patterns/de-1996.pat.txt +36709 -0
  19. data/lib/stationery/hyphenation/patterns/en-us.hyp.txt +14 -0
  20. data/lib/stationery/hyphenation/patterns/en-us.pat.txt +4938 -0
  21. data/lib/stationery/hyphenation/patterns/sv.pat.txt +4693 -0
  22. data/lib/stationery/hyphenation.rb +132 -0
  23. data/lib/stationery/images/images.rb +9 -1
  24. data/lib/stationery/images/png.rb +66 -0
  25. data/lib/stationery/images/resampled.rb +80 -0
  26. data/lib/stationery/instrumentation.rb +41 -0
  27. data/lib/stationery/layout/image.rb +28 -2
  28. data/lib/stationery/layout/paginator.rb +1 -1
  29. data/lib/stationery/layout/text.rb +2 -2
  30. data/lib/stationery/markdown/document.rb +4 -2
  31. data/lib/stationery/minitest.rb +5 -0
  32. data/lib/stationery/page_templates.rb +2 -1
  33. data/lib/stationery/pdf/assembler.rb +6 -2
  34. data/lib/stationery/pdf/page_labels.rb +71 -0
  35. data/lib/stationery/railtie.rb +5 -0
  36. data/lib/stationery/testing/inspector.rb +13 -0
  37. data/lib/stationery/testing/matchers.rb +10 -0
  38. data/lib/stationery/text/entities.rb +14 -4
  39. data/lib/stationery/text/paragraph.rb +1 -1
  40. data/lib/stationery/text/style.rb +14 -3
  41. data/lib/stationery/text/wrapper.rb +73 -12
  42. data/lib/stationery/version.rb +1 -1
  43. data/lib/stationery/warnings.rb +6 -0
  44. data/lib/stationery.rb +5 -0
  45. metadata +11 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dab8be06e23e27d74ddd2ec627bc34923a79a9c0cb5fe40da55f43bdcb81e337
4
- data.tar.gz: a7f90eadef2004318933e8a65cd1f86841c5b9bfc4cba8ff6191b2f96946b8e5
3
+ metadata.gz: 0f9e83f23437589a026e0a9a2fde163a39a2c4151c434afc5bdbea38a98891dd
4
+ data.tar.gz: 67e42695ae205e05049441490b89b908f6468ba94e00c095c1de8eaeae986d65
5
5
  SHA512:
6
- metadata.gz: ce40fc76233a4c6ba0ca455c9c80ac3a651832ba86ab7003c8f907b2abd8291cbe0a7421302b4a6762e876df284ebe98ed862017eaed7cb4854d609e3fa557ec
7
- data.tar.gz: b1c1928f47210a8b29586f73881b8430978c644a2ea7489fab251796b7192213057a414c3145c47502fa267e3c857d5b581f91d8bf2bca394e61384844fc21d8
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 (`&amp;`, `&#39;`, `&#x27;`, 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 `&#39;`.
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 and postcard collage; `bundle exec rake examples`
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 (`&#39;`, `&#x27;`) and HTML 4 named entities once, so escape user data (`ERB::Util.html_escape`) before wrapping it in your own tags: `&lt;b&gt;` 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. Only
525
- `liga` applies, not `clig` or `dlig`. Ligatures form within a run of one
526
- style and font, never across a line break, and letter spacing turns them off.
527
- The PDF's ToUnicode map sends a ligature glyph back to all of its
528
- characters, so copied and extracted text still reads "office". Fonts
529
- without a `liga` feature (such as the bundled Inter) are unaffected.
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, `&shy;` 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
- No variable fonts (including CFF2) or
630
- WOFF2 (it needs Brotli; convert to `.ttf` or `.woff`); SVG covers the shapes icon sets use
631
- (no patterns or masks). A box with a fixed `height:` never splits (use
632
- `min_height:` for a floor that can); a row splits only when every column can.
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
 
@@ -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
- attr_reader :root, :book, :list_depth
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|
@@ -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
- attr_reader :page
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
 
@@ -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
- encryption = encrypt && PDF::Encryption::StandardSecurity.new(**encrypt)
99
- tagging = Tagging::Tree.new if tagged
100
- warnings = Warnings.new
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 = Builder.new(book:, text: self.class.config[:text])
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
- regions = Regions.new(self.class.config[:regions], measure: region_measure(book))
135
- paginator = Layout::Paginator.new(resources:, page: page_options, warnings:, debug:, regions:, tagging:)
136
- paginator.paginate(root).tap do |pages|
137
- PageTemplates.new(self, book:, resources:, debug:, regions:, warnings:, tagging:).apply(pages)
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
 
@@ -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 selectors and
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}‌‍︀-️\p{M}]/
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 = { true => {}, false => {} }
36
- @advances = { true => {}, false => {} }
37
- @kerns = { true => {}, false => {} }
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
- def width_of(text, size, letter_spacing: 0, kerning: false, ligatures: true)
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
- width = scale(advance_units(text, ligatures), size) + (letter_spacing * shape(text, ligatures).first.size)
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, ligatures) * size / 1000.0)
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; `kerning:`
73
- # then fills the adjustments with pair kerning between the glyphs.
74
- def glyph_run(text, kerning: false, ligatures: true)
75
- gids, chars, blanks = shape(text, ligatures)
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
- name, cid_font = embedding.build(writer, @used.keys.sort)
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, ligatures)
120
- @shapes[ligatures][text] ||= begin
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 = ligatures ? ligate(gids, text) : [gids, text.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 ligate(gids, text)
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, ligatures)
152
- @advances[ligatures][text] ||= begin
153
- gids, _, blanks = shape(text, ligatures)
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, ligatures)
159
- @kerns[ligatures][text] ||= shape(text, ligatures).first.each_cons(2).sum { |left, right| pair(left, right) }
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 per style.
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] ||= begin
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