stationery 0.6.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -0
  3. data/README.md +72 -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 +26 -9
  8. data/lib/stationery/elements.rb +1 -1
  9. data/lib/stationery/fonts/fallback.rb +3 -3
  10. data/lib/stationery/fonts/font.rb +39 -21
  11. data/lib/stationery/fonts/gsub/single_subst.rb +37 -0
  12. data/lib/stationery/fonts/gsub.rb +65 -20
  13. data/lib/stationery/fonts/ligatures.rb +6 -4
  14. data/lib/stationery/hyphenation/patterns/LICENSES.md +60 -0
  15. data/lib/stationery/hyphenation/patterns/de-1996.pat.txt +36709 -0
  16. data/lib/stationery/hyphenation/patterns/en-us.hyp.txt +14 -0
  17. data/lib/stationery/hyphenation/patterns/en-us.pat.txt +4938 -0
  18. data/lib/stationery/hyphenation/patterns/sv.pat.txt +4693 -0
  19. data/lib/stationery/hyphenation.rb +132 -0
  20. data/lib/stationery/images/png.rb +66 -0
  21. data/lib/stationery/images/resampled.rb +80 -0
  22. data/lib/stationery/layout/image.rb +28 -2
  23. data/lib/stationery/layout/paginator.rb +1 -1
  24. data/lib/stationery/layout/text.rb +2 -2
  25. data/lib/stationery/minitest.rb +5 -0
  26. data/lib/stationery/page_templates.rb +2 -1
  27. data/lib/stationery/pdf/assembler.rb +6 -2
  28. data/lib/stationery/pdf/page_labels.rb +71 -0
  29. data/lib/stationery/testing/inspector.rb +13 -0
  30. data/lib/stationery/testing/matchers.rb +10 -0
  31. data/lib/stationery/text/entities.rb +14 -4
  32. data/lib/stationery/text/paragraph.rb +1 -1
  33. data/lib/stationery/text/style.rb +14 -3
  34. data/lib/stationery/text/wrapper.rb +73 -12
  35. data/lib/stationery/version.rb +1 -1
  36. data/lib/stationery/warnings.rb +6 -0
  37. data/lib/stationery.rb +4 -0
  38. metadata +10 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bfd8cada05438bcc43cc85da2535da95b55c2dbf6020bf95607bd8ebb65c8346
4
- data.tar.gz: 1115be789042eebfdd48a036fc433994694236c4c133ede893a554478b6174bf
3
+ metadata.gz: 0f9e83f23437589a026e0a9a2fde163a39a2c4151c434afc5bdbea38a98891dd
4
+ data.tar.gz: 67e42695ae205e05049441490b89b908f6468ba94e00c095c1de8eaeae986d65
5
5
  SHA512:
6
- metadata.gz: 2e55c39993d7c6c7c66373f5097a8796ac655b46eabe34eb148808cca220823112a728d4c26b971c54f163dc2b6c45f50f3b6481c050089871daf5964a4011d6
7
- data.tar.gz: 456ca535dc230cbc747dac22322d7a0886ea8da7f70b159bb59224a2afe211dd1cb1a6e79a2a1a7c1f7b0d2007701b465fb49434484953db010a869ae6e4ad97
6
+ metadata.gz: 40756fe30e4c2d400fab7402e490609cf313e8ae06c5317489f6b0337f96fc5ad00d8f2c8b05b348812955b109cc0f9685f9c1668b8f307d1fd16ac9492facad
7
+ data.tar.gz: 1a7938f3b608b68df057d3385d5824dc6a57338978a76ad39bed42701838ee2a408db1c2d30daccf3a834b03bea9f6d1441ef6405691dff0090611d26cbe05a0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
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
+
3
13
  ## 0.6.0 (2026-09-28)
4
14
 
5
15
  Instrumentation, so a render shows up in AppSignal and friends, and a faster text pipeline.
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
@@ -661,10 +700,28 @@ larger file. `PROFILE=1 bundle exec ruby -Ilib benchmark/profile.rb` prints the
661
700
 
662
701
  ## Limitations
663
702
 
664
- No variable fonts (including CFF2) or
665
- WOFF2 (it needs Brotli; convert to `.ttf` or `.woff`); SVG covers the shapes icon sets use
666
- (no patterns or masks). A box with a fixed `height:` never splits (use
667
- `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.
668
725
 
669
726
  ## License
670
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,9 +109,9 @@ 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])
112
+ tagged: self.class.config[:tagged], page_labels: self.class.config[:page_labels])
98
113
  Stationery.instrument("render.stationery", document: self.class.name) do |event|
99
- write(render_pdf(event, strict:, debug:, encrypt:, tagged:), target)
114
+ write(render_pdf(event, strict:, debug:, encrypt:, tagged:, page_labels:), target)
100
115
  end
101
116
  end
102
117
 
@@ -111,19 +126,21 @@ module Stationery
111
126
 
112
127
  # Builds a page template or region block into a fresh root node.
113
128
  def template_root(info, book:, &)
114
- builder = Builder.new(book:, text: self.class.config[:text])
129
+ builder = builder_for(book)
115
130
  build_with(builder) { instance_exec(info, &) }
116
131
  builder.root
117
132
  end
118
133
 
119
134
  private
120
135
 
136
+ def builder_for(book) = Builder.new(book:, text: self.class.config[:text], images: self.class.config[:images])
137
+
121
138
  # The PDF bytes; `event` is the render.stationery payload it fills in.
122
- def render_pdf(event, strict:, debug:, encrypt:, tagged:)
139
+ def render_pdf(event, strict:, debug:, encrypt:, tagged:, page_labels:)
123
140
  tagging = Tagging::Tree.new if tagged
124
141
  warnings = Warnings.new
125
142
  book = Fonts::FontBook.new(self.class.config[:families], fallbacks: self.class.config[:fallbacks], warnings:)
126
- builder = Builder.new(book:, text: self.class.config[:text])
143
+ builder = builder_for(book)
127
144
  Stationery.instrument("build.stationery", document: self.class.name) { call(builder) }
128
145
  resources = Resources.new
129
146
  pages = paginate(builder.root, book:, resources:, warnings:, debug:, tagging:)
@@ -135,13 +152,13 @@ module Stationery
135
152
  event[:warnings] = warnings.size
136
153
  raise WarningsError, warnings if strict && warnings.any?
137
154
 
138
- assemble(pages, resources, outline, encrypt:, tagging:).tap { |pdf| event[:bytes] = pdf.bytesize }
155
+ assemble(pages, resources, outline, encrypt:, tagging:, page_labels:).tap { |pdf| event[:bytes] = pdf.bytesize }
139
156
  end
140
157
 
141
- def assemble(pages, resources, outline, encrypt:, tagging:)
158
+ def assemble(pages, resources, outline, encrypt:, tagging:, page_labels:)
142
159
  encryption = encrypt && PDF::Encryption::StandardSecurity.new(**encrypt)
143
160
  assembler = PDF::Assembler.new(pages:, resources:, info:, outline:, encryption:, tagging:,
144
- lang: metadata[:lang])
161
+ lang: metadata[:lang], page_labels: PDF::PageLabels.entries(page_labels))
145
162
  Stationery.instrument("write.stationery", document: self.class.name) do |event|
146
163
  assembler.render.tap { |pdf| event[:bytes] = pdf.bytesize }
147
164
  end
@@ -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
@@ -118,19 +125,30 @@ module Stationery
118
125
 
119
126
  private
120
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
+
121
139
  # [gids, source text of each glyph, extra advance in font units after
122
- # each blank or nil], remembered per string.
123
- def shape(text, ligatures)
124
- @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
125
143
  gids = text.each_char.map { |char| glyph_for(char) }
126
- gids, chars = ligatures ? ligate(gids, text) : [gids, text.chars]
144
+ gids, chars = tags.empty? ? [gids, text.chars] : substitute(gids, text, tags)
127
145
  [gids, chars, chars.map { |char| blank_units(char) }].each(&:freeze).freeze
128
146
  end
129
147
  end
130
148
 
131
- def ligate(gids, text)
149
+ def substitute(gids, text, tags)
132
150
  start = 0
133
- glyphs = @ttf.ligatures.substitute(gids)
151
+ glyphs = @ttf.ligatures.substitute(gids, tags)
134
152
  chars = glyphs.map { |_gid, count| text[start, count].tap { start += count } }
135
153
  [glyphs.map(&:first), chars]
136
154
  end
@@ -152,15 +170,15 @@ module Stationery
152
170
  width - space
153
171
  end
154
172
 
155
- def advance_units(text, ligatures)
156
- @advances[ligatures][text] ||= begin
157
- gids, _, blanks = shape(text, ligatures)
173
+ def advance_units(text, tags)
174
+ (@advances[tags] ||= {})[text] ||= begin
175
+ gids, _, blanks = shape(text, tags)
158
176
  gids.sum { |gid| @ttf.advance(gid) } + blanks.sum { |units| units || 0 }
159
177
  end
160
178
  end
161
179
 
162
- def kerning_units(text, ligatures)
163
- @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) }
164
182
  end
165
183
 
166
184
  # Kerning between two glyphs in thousandths of an em.
@@ -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