stationery 0.2.0 → 0.3.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -1
  3. data/README.md +47 -10
  4. data/lib/stationery/builder.rb +2 -1
  5. data/lib/stationery/canvas/text.rb +5 -4
  6. data/lib/stationery/canvas.rb +13 -0
  7. data/lib/stationery/document.rb +9 -2
  8. data/lib/stationery/elements.rb +1 -1
  9. data/lib/stationery/fonts/font.rb +36 -20
  10. data/lib/stationery/fonts/font_book.rb +3 -0
  11. data/lib/stationery/fonts/glyph_run.rb +4 -3
  12. data/lib/stationery/fonts/gpos.rb +11 -9
  13. data/lib/stationery/fonts/gsub/ligature_subst.rb +44 -0
  14. data/lib/stationery/fonts/gsub.rb +65 -0
  15. data/lib/stationery/fonts/ligatures.rb +16 -0
  16. data/lib/stationery/fonts/registry.rb +18 -9
  17. data/lib/stationery/fonts/to_unicode.rb +1 -1
  18. data/lib/stationery/fonts/true_type.rb +36 -12
  19. data/lib/stationery/fonts/woff.rb +52 -0
  20. data/lib/stationery/layout/box.rb +23 -6
  21. data/lib/stationery/layout/row.rb +1 -1
  22. data/lib/stationery/layout/svg.rb +5 -2
  23. data/lib/stationery/layout/text.rb +2 -1
  24. data/lib/stationery/pdf/assembler.rb +3 -2
  25. data/lib/stationery/pdf/encryption/aes.rb +37 -0
  26. data/lib/stationery/pdf/encryption/rc4.rb +33 -0
  27. data/lib/stationery/pdf/encryption/revision4.rb +48 -0
  28. data/lib/stationery/pdf/encryption/revision6.rb +56 -0
  29. data/lib/stationery/pdf/encryption/standard_security.rb +78 -0
  30. data/lib/stationery/pdf/serializer.rb +17 -9
  31. data/lib/stationery/pdf/writer.rb +24 -5
  32. data/lib/stationery/resources.rb +8 -2
  33. data/lib/stationery/svg/bounds.rb +76 -0
  34. data/lib/stationery/svg/document.rb +48 -18
  35. data/lib/stationery/svg/gradient.rb +115 -0
  36. data/lib/stationery/svg/painter.rb +70 -0
  37. data/lib/stationery/svg/parser.rb +24 -4
  38. data/lib/stationery/svg/selector.rb +37 -0
  39. data/lib/stationery/svg/shading.rb +34 -0
  40. data/lib/stationery/svg/style.rb +37 -20
  41. data/lib/stationery/svg/stylesheet.rb +47 -0
  42. data/lib/stationery/svg/text.rb +122 -0
  43. data/lib/stationery/svg/transform.rb +11 -0
  44. data/lib/stationery/text/paragraph.rb +1 -0
  45. data/lib/stationery/text/style.rb +4 -2
  46. data/lib/stationery/text/wrapper.rb +2 -1
  47. data/lib/stationery/version.rb +1 -1
  48. data/lib/stationery.rb +16 -0
  49. metadata +17 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dabc4e22462e7af56f79f46c814ebb908ee12961948190ae2b73f2f8951478f7
4
- data.tar.gz: 9ef6ee24bfa601829d8b51fb514254a55a9470afd98be1429b2935984f6605df
3
+ metadata.gz: f9ff43b2013eb330ebc103525c440bb5e4cfdaf0d05364baf47fd329c48ad11a
4
+ data.tar.gz: 718ede92892d3c7c0cdc556708880a2ba53f559611b872cfcb8ac946adb59908
5
5
  SHA512:
6
- metadata.gz: 1e051af6e9e75e979f763b5bd47eed272fef23d754d1e11116abc890669679c71eee3a2a615305ef63c59f6c626ea3567eeba76a2a9407e81c929626e6ec6139
7
- data.tar.gz: d7c7ffab364d4502a568c6496c10413b189bebf2003ffc433e014960fc29fe7cc948097eff9fd292e9e6108aeb5cc51c42ffdba5da18e71d99f2e951b94cfb26
6
+ metadata.gz: b3a4a85b185b0f06e195a15f81773c86d79086e32e923b1c21c2d39ee528375fd5b75be1581fc3d8180e530071d653939674804c973896d0edb3b57fffbde9fa
7
+ data.tar.gz: fd368b63884a129cd0e6f03507da714891c79b81b6a1aeadde9cfeafc4987824d7cd525c2f86aa3eba397f432a14516429bc697b61177056a790a602d85369ae
data/CHANGELOG.md CHANGED
@@ -1,6 +1,26 @@
1
1
  # Changelog
2
2
 
3
- ## 0.2.0 (unreleased)
3
+ ## 0.3.0 (2026-09-27)
4
+
5
+ The Limitations page, shortened: TrueType collections, WOFF, ligatures, splittable
6
+ `min_height:` boxes, SVG gradients, text and stylesheets, and encryption.
7
+
8
+ - SVG: `linearGradient` and `radialGradient` fills (`fill="url(#id)"`, also in `style`), with stops, `href`/`xlink:href` inheritance, `objectBoundingBox` and `userSpaceOnUse` units and `gradientTransform`, drawn as PDF axial/radial shadings clipped to the shape. Approximations: `reflect`/`repeat` spreads are drawn as `pad` (reported in `unsupported`), stop opacity is the first stop's for the whole gradient, and a gradient stroke is drawn in the gradient's middle colour. A reference to a missing gradient paints its fallback colour or nothing and is reported as `url(#id)`. `Canvas#shade` paints a shading dictionary inside a path.
9
+ - SVG: `text` and `tspan` (`x`/`y`/`dx`/`dy`, first value each; `font-family`, first family the font book knows — registered, bundled or an installed pack — else the document's default; `font-size`, `font-weight`, `font-style`, `text-anchor`, `fill`, `opacity`, transforms), drawn through the document's fonts so they subset and extract like any text. Whitespace collapses as in browsers. Glyphs stay upright: a transform moves the baseline origin and scales the size uniformly, so rotated or skewed text is approximated; `dominant-baseline` is ignored and a gradient fill uses its middle colour. `Layout::Svg` takes `context:`, `SVG::Document#draw` takes `book:` and `family:`, and `FontBook#known?` tells whether a family resolves without substitution.
10
+ - SVG: `<style>` stylesheets (plain or CDATA) as Illustrator and Inkscape export them. Selectors: element, `.class` (`.a.b`), `#id`, `element.class`, comma lists and `*`; rules with combinators (`g path`, `a > b`, …) are ignored and reported once as `style selectors: …`. Cascade: presentation attributes, then rules by specificity (id > class > element, later wins on ties), then inline `style`. Rules style shapes, text (`font-*`) and gradient stops (`stop-color`, `stop-opacity`); `display: none` skips an element and its children, `visibility: hidden` skips it (a `visible` child draws). `class` takes several classes.
11
+
12
+ - Encryption with the standard security handler: `to_pdf(encrypt: { owner_password:, user_password:, permissions:, algorithm: })` or `encrypt …` at class level (`to_pdf(encrypt: nil)` opts out). AES-256 (R6, default), AES-128 (R4) and RC4-128 (R3); every string and stream, document info included, is encrypted.
13
+
14
+ - Standard ligatures from the font's GSUB `liga` feature (LigatureSubst lookups, also behind Extension lookups), **on by default**: "office" in Open Sans draws the ffi ligature, so widths of affected words change slightly. `ligatures: false` on `text`/`text_style` or in `default_text` opts out; any `letter_spacing` turns them off. Text extraction and copy still yield the source characters (ToUnicode maps a ligature glyph to all of them). Fonts without `liga` ligatures, such as the bundled Inter, are unaffected.
15
+
16
+ ### Fonts
17
+
18
+ - TrueType collections (`.ttc`): `font_family "Brand", regular: "Brand.ttc#0", bold: "Brand.ttc#2"` picks a face by a `#N` suffix (face 0 without one); each face is parsed, cached, subset and embedded on its own. `TrueType.new(data, index:)`, `TrueType.collection?` and `TrueType.faces`; an index out of range raises `ArgumentError` naming the face count.
19
+ - WOFF 1.0 web fonts (`.woff`): `font_family "Web", regular: "Brand.woff"`. Tables are inflated with zlib into an in-memory sfnt (`Fonts::WOFF.unpack`), then measured, subset and embedded like the `.ttf`. WOFF2 is still rejected: it needs Brotli, so convert to `.ttf` or `.woff`.
20
+
21
+ - `box(min_height:)` and `column(min_height:)`: a height floor that, unlike `height:`, still splits across pages. The first fragment keeps as much of the floor as the page holds and the next carries the rest, so a row of equal-height cards or an empty signature area can span a page break. Passing both `height:` and `min_height:` raises `ArgumentError`.
22
+
23
+ ## 0.2.0 (2026-09-27)
4
24
 
5
25
  Everything from the three planned milestones ("works out of the box", "typography and layout",
6
26
  "documents, Rails and testing") shipped together.
data/README.md CHANGED
@@ -80,11 +80,11 @@ renders them all, or render one with `stationery render examples/report.rb`.
80
80
  | `text(string, **style)` | A paragraph. Plain strings are literal. |
81
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
82
  | `text { b "Total"; plain " due" }` | Styled runs in Ruby. Take a block argument (`{ \|t\| t.b @x }`) to keep your own `self`. |
83
- | `box(padding:, background:, border:, radius:, width:, height:, overflow:, at:, link:, outset:, break_inside:, decoration:) { }` | 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. `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). |
84
- | `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. |
83
+ | `box(padding:, background:, border:, radius:, width:, height:, min_height:, overflow:, at:, link:, outset:, break_inside:, decoration:) { }` | 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). |
84
+ | `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
85
  | `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
86
  | `image(path_or_io, width:, height:, fit:, align:)` | JPEG or PNG, aspect preserved. |
87
- | `svg(source_or_path, width:, height:, color:, align:)` | Vector icons and drawings; `currentColor` takes `color:`. |
87
+ | `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
88
  | `wrap(gap:, row_gap:, align:) { }` | Children side by side at their own widths, wrapping onto new rows (chips, tags). |
89
89
  | `ul(style:, gap:, indent:, marker_gap:, marker_color:) { li "…" }` | Bulleted list. `style:` is `:disc`, `:circle`, `:square`, `:dash` or any String; unstyled nested lists cycle disc → circle → square. Any node added inside (not only `li`) becomes an item. Items split across pages; the marker stays with the first line. |
90
90
  | `ol(format:, start:, suffix:, gap:, indent:, marker_gap:, marker_color:) { }` | Numbered list. `format:` is `:decimal`, `:alpha`, `:upper_alpha`, `:roman`, `:upper_roman` or a Proc `(n) -> String`; `suffix:` defaults to `"."`. Markers right-align so bodies line up. |
@@ -98,7 +98,7 @@ renders them all, or render one with `stationery render examples/report.rb`.
98
98
  | `markdown(source, styles:, gap:, images:, base_path:, bookmarks:)` | The same from CommonMark (plus GFM tables and strikethrough). |
99
99
 
100
100
  Text style options: `font`, `size`, `weight` (`:regular`, `:bold`), `style` (`:italic`), `color`,
101
- `letter_spacing`, `underline`, `strikethrough`, `link`, `opacity`, `kerning` (default `true`), `align` (`:left`, `:center`, `:right`, `:justify`), `leading`.
101
+ `letter_spacing`, `underline`, `strikethrough`, `link`, `opacity`, `kerning` (default `true`), `ligatures` (default `true`), `align` (`:left`, `:center`, `:right`, `:justify`), `leading`.
102
102
  `align: :justify` stretches the spaces of wrapped lines to the full width; the last line, lines
103
103
  ending in a newline and lines without spaces stay left-aligned (tabs are never stretched).
104
104
  Colours are `"#RRGGBB"`, `"RRGGBB"`, `"#RGB"`, `[r, g, b]` (0-255) or `[c, m, y, k]` (0-100).
@@ -228,6 +228,27 @@ render Callout.new(color: "#F3F4F6") { text "Amount due" }
228
228
  (with `#warnings`) instead of writing a PDF that produced any; `to_pdf(strict: false)` opts one
229
229
  render out again.
230
230
 
231
+ ### Encryption
232
+
233
+ ```ruby
234
+ class InvoicePdf < Stationery::Document
235
+ encrypt owner_password: "s3cret", permissions: [:print] # every render
236
+ end
237
+
238
+ InvoicePdf.new(invoice).to_pdf(encrypt: { user_password: "1234", owner_password: "s3cret",
239
+ permissions: %i[print copy] }) # override for one render
240
+ InvoicePdf.new(invoice).to_pdf(encrypt: nil) # plain
241
+ ```
242
+
243
+ - `owner_password:` is required (`ArgumentError` when missing or empty); it opens the file with every
244
+ right. `user_password:` defaults to `""`: the file opens without a prompt, but viewers enforce the
245
+ permissions.
246
+ - `permissions:` is a subset of `%i[print modify copy annotate fill_forms extract_accessible assemble
247
+ print_high]` (default: all).
248
+ - `algorithm:` picks the standard security handler. `:aes_256` (default, PDF 2.0 / Acrobat X+);
249
+ `:aes_128` for older viewers; `:rc4_128` only for legacy readers that need it.
250
+ - Every string and stream is encrypted, the document info included.
251
+
231
252
  ### Debugging
232
253
 
233
254
  `to_pdf(debug: true)` outlines every layout rectangle on top of the content: boxes (red, padding dashed),
@@ -317,7 +338,14 @@ already present are kept unless `--force`. `--from` takes a directory or a
317
338
  ## Fonts and images
318
339
 
319
340
  Fonts are TrueType (`.ttf`) or OpenType/CFF (`.otf`, name-keyed or
320
- CID-keyed) files. Only the glyphs a document uses are embedded (a CFF font
341
+ CID-keyed) files, WOFF 1.0 web fonts (`.woff`, unwrapped in memory), or
342
+ faces of a TrueType collection (`.ttc`): a `#N` suffix
343
+ on the path picks face N, counted from 0 (face 0 without a suffix):
344
+
345
+ ```ruby
346
+ font_family "Brand", regular: "Brand.ttc#0", bold: "Brand.ttc#2"
347
+ ```
348
+ Only the glyphs a document uses are embedded (a CFF font
321
349
  keeps its glyph numbering and subroutines; unused glyphs are blanked), with a
322
350
  ToUnicode map so text copies and searches correctly. A style without its own
323
351
  file (bold, italic) is synthesised.
@@ -379,6 +407,15 @@ turns it off). Pairs that straddle a style or
379
407
  font change are not kerned. Kerning only tightens in practice, so a kerned
380
408
  line is never wider than the same line unkerned.
381
409
 
410
+ Standard ligatures (fi, fl, ffi, …) come from the font's GSUB `liga` feature
411
+ (LigatureSubst lookups, also behind Extension lookups) and are on by default;
412
+ `ligatures: false` on an element or in `default_text` turns them off. Only
413
+ `liga` applies, not `clig` or `dlig`. Ligatures form within a run of one
414
+ style and font, never across a line break, and letter spacing turns them off.
415
+ The PDF's ToUnicode map sends a ligature glyph back to all of its
416
+ characters, so copied and extracted text still reads "office". Fonts
417
+ without a `liga` feature (such as the bundled Inter) are unaffected.
418
+
382
419
  Images are JPEG (grey, RGB, CMYK) and PNG (every colour type, alpha as a soft
383
420
  mask). Parsed fonts and images are cached per process.
384
421
 
@@ -466,11 +503,11 @@ larger file. `PROFILE=1 bundle exec ruby -Ilib benchmark/profile.rb` prints the
466
503
 
467
504
  ## Limitations
468
505
 
469
- No ligatures, no TrueType collections, variable fonts (including CFF2) or
470
- WOFF; SVG covers the shapes icon sets use
471
- (no text, gradients, patterns, masks or CSS stylesheets); no encryption,
472
- forms or tagged PDF; fixed-height boxes, and rows holding one,
473
- never split across pages.
506
+ No variable fonts (including CFF2) or
507
+ WOFF2 (it needs Brotli; convert to `.ttf` or `.woff`); SVG covers the shapes icon sets use
508
+ (no patterns or masks); no forms or
509
+ tagged PDF. A box with a fixed `height:` never splits (use
510
+ `min_height:` for a floor that can); a row splits only when every column can.
474
511
 
475
512
  ## License
476
513
 
@@ -4,7 +4,8 @@ module Stationery
4
4
  # Collects the nodes a component tree describes. Holds the container stack
5
5
  # (where the next node goes) and the text defaults in effect.
6
6
  class Builder
7
- STYLE_KEYS = %i[size color weight style letter_spacing underline strikethrough link opacity kerning].freeze
7
+ STYLE_KEYS = %i[size color weight style letter_spacing underline strikethrough link opacity kerning
8
+ ligatures].freeze
8
9
 
9
10
  # Collects a row's columns; anything that is not already a column box is
10
11
  # wrapped in one.
@@ -7,15 +7,16 @@ module Stationery
7
7
  BOLD_STROKE = 0.03
8
8
 
9
9
  # Draws `string` with its baseline at (x, y) in top-left coordinates and
10
- # returns its advance width.
10
+ # returns its advance width. Standard ligatures form unless `ligatures:`
11
+ # is false or letter spacing is set.
11
12
  def text(string, x:, y:, font:, size:, color: "#000000", letter_spacing: 0, rise: 0, opacity: nil,
12
13
  synthetic_bold: false, synthetic_oblique: false, underline: false, strikethrough: false,
13
- kerning: false, word_spacing: 0)
14
+ kerning: false, ligatures: true, word_spacing: 0)
14
15
  return 0 if string.empty?
15
16
 
16
17
  color = Color.parse(color)
17
- run = font.glyph_run(string, kerning:)
18
- run = run.with_word_spacing(string, word_spacing, size) unless word_spacing.zero?
18
+ run = font.glyph_run(string, kerning:, ligatures: ligatures && letter_spacing.zero?)
19
+ run = run.with_word_spacing(word_spacing, size) unless word_spacing.zero?
19
20
  width = run.width(size, letter_spacing:)
20
21
  graphics(opacity:) do |ops|
21
22
  ops << color.fill
@@ -58,6 +58,19 @@ module Stationery
58
58
  shape(fill:, stroke:, line_width:, cap:, join:, dash:, even_odd:, opacity:, transform:, &)
59
59
  end
60
60
 
61
+ # Paints `shading` inside the path the block traces. `matrix` maps the
62
+ # shading's coordinates into top-left page space.
63
+ def shade(shading, matrix:, transform: nil, even_odd: false, opacity: nil)
64
+ path = Path.new(self, transform:)
65
+ yield path
66
+ name = @page.use(:Shading, @resources.shading(shading))
67
+ a, b, c, d, e, f = matrix
68
+ graphics(opacity:) do |ops|
69
+ ops << path.to_s << (even_odd ? "W* n" : "W n")
70
+ ops << "#{[a, -b, c, -d, e, @page.height - f].map { |v| num(v) }.join(" ")} cm" << "/#{name} sh"
71
+ end
72
+ end
73
+
61
74
  def image(image, x:, y:, width:, height:, opacity: nil)
62
75
  name = @page.use(:XObject, @resources.image(image))
63
76
  graphics(opacity:) do |ops|
@@ -55,6 +55,12 @@ module Stationery
55
55
  config[:strict] = value
56
56
  end
57
57
 
58
+ # Encrypts every render with the standard security handler; see
59
+ # PDF::Encryption::StandardSecurity for the options.
60
+ def encrypt(**)
61
+ config[:encrypt] = PDF::Encryption::StandardSecurity.options(**)
62
+ end
63
+
58
64
  # Runs after pagination on every page. `layer: :background` paints under
59
65
  # the page's content.
60
66
  def page_template(layer: :foreground, &block)
@@ -79,7 +85,8 @@ module Stationery
79
85
  def page_options = self.class.config[:page]
80
86
  def metadata = self.class.config[:metadata]
81
87
 
82
- def to_pdf(target = nil, strict: self.class.config[:strict], debug: false)
88
+ def to_pdf(target = nil, strict: self.class.config[:strict], debug: false, encrypt: self.class.config[:encrypt])
89
+ encryption = encrypt && PDF::Encryption::StandardSecurity.new(**encrypt)
83
90
  warnings = Warnings.new
84
91
  book = Fonts::FontBook.new(self.class.config[:families], fallbacks: self.class.config[:fallbacks], warnings:)
85
92
  call(builder = Builder.new(book:, text: self.class.config[:text]))
@@ -92,7 +99,7 @@ module Stationery
92
99
  @warnings = warnings
93
100
  raise WarningsError, warnings if strict && warnings.any?
94
101
 
95
- write(PDF::Assembler.new(pages:, resources:, info:, outline:).render, target)
102
+ write(PDF::Assembler.new(pages:, resources:, info:, outline:, encryption:).render, target)
96
103
  end
97
104
 
98
105
  # Used by page templates to build nodes into their own root.
@@ -73,7 +73,7 @@ module Stationery
73
73
  if document.unsupported.any?
74
74
  @_builder.warnings << Warnings::UnsupportedSvg.new(elements: document.unsupported, source: name)
75
75
  end
76
- node = Layout::Svg.new(document, width:, height:, color:)
76
+ node = Layout::Svg.new(document, width:, height:, color:, context: @_builder.context)
77
77
  @_builder.add(align ? Layout::Flow.new([node], align:) : node)
78
78
  end
79
79
 
@@ -17,11 +17,11 @@ module Stationery
17
17
  def initialize(ttf)
18
18
  @ttf = ttf
19
19
  @used = {}
20
- @widths = {}
21
20
  @pairs = {}
22
21
  @glyphs = {}
23
- @advances = {}
24
- @kerns = {}
22
+ @shapes = { true => {}, false => {} }
23
+ @advances = { true => {}, false => {} }
24
+ @kerns = { true => {}, false => {} }
25
25
  @cid_keyed = ttf.cff? && ttf.cff.cid_keyed?
26
26
  end
27
27
 
@@ -29,11 +29,14 @@ module Stationery
29
29
 
30
30
  # Advances and kerning are remembered per string, so measuring the same
31
31
  # word again (wrapping, then laying out the line) allocates nothing.
32
- def width_of(text, size, letter_spacing: 0, kerning: false)
33
- width = scale(advance_units(text), size) + (letter_spacing * text.length)
32
+ # Letter spacing is added per glyph, so a ligature counts once; any
33
+ # letter spacing turns ligatures off, as it does when drawing.
34
+ def width_of(text, size, letter_spacing: 0, kerning: false, ligatures: true)
35
+ ligatures &&= letter_spacing.zero?
36
+ width = scale(advance_units(text, ligatures), size) + (letter_spacing * shape(text, ligatures).first.size)
34
37
  return width unless kerning
35
38
 
36
- width + (kerning_units(text) * size / 1000.0)
39
+ width + (kerning_units(text, ligatures) * size / 1000.0)
37
40
  end
38
41
 
39
42
  def ascender(size) = scale(@ttf.ascender, size)
@@ -53,15 +56,13 @@ module Stationery
53
56
 
54
57
  def encode(text) = glyph_run(text).gids.map { |gid| code(gid) }.pack("n*")
55
58
 
56
- # `kerning:` fills the adjustments with the font's pair kerning.
57
- def glyph_run(text, kerning: false)
58
- gids = text.each_char.map do |char|
59
- gid = @ttf.glyph_id(char.ord)
60
- @used[gid] ||= char
61
- gid
62
- end
59
+ # `ligatures:` substitutes the font's standard ligatures; `kerning:`
60
+ # then fills the adjustments with pair kerning between the glyphs.
61
+ def glyph_run(text, kerning: false, ligatures: true)
62
+ gids, chars = shape(text, ligatures)
63
+ gids.each_with_index { |gid, i| @used[gid] ||= chars[i] }
63
64
  adjust = gids.each_with_index.map { |gid, i| kerning && i + 1 < gids.size ? pair(gid, gids[i + 1]) : 0 }
64
- GlyphRun.new(font: self, gids:, adjust:)
65
+ GlyphRun.new(font: self, gids:, adjust:, chars:)
65
66
  end
66
67
 
67
68
  def used?
@@ -74,7 +75,8 @@ module Stationery
74
75
  @cid_keyed ? @ttf.cff.cid_for(gid) : gid
75
76
  end
76
77
 
77
- # { code => character } for every glyph drawn so far.
78
+ # { code => text } for every glyph drawn so far; a ligature's text is
79
+ # every character it stands for.
78
80
  def used_codes
79
81
  @used.to_h { |gid, char| [code(gid), char] }
80
82
  end
@@ -91,13 +93,27 @@ module Stationery
91
93
 
92
94
  private
93
95
 
94
- def advance_units(text)
95
- @advances[text] ||= text.each_char.sum { |char| @widths[char] ||= @ttf.advance(@ttf.glyph_id(char.ord)) }
96
+ # [gids, source text of each glyph], remembered per string.
97
+ def shape(text, ligatures)
98
+ @shapes[ligatures][text] ||= begin
99
+ gids = text.each_char.map { |char| @ttf.glyph_id(char.ord) }
100
+ ligatures ? ligate(gids, text) : [gids, text.chars].each(&:freeze).freeze
101
+ end
102
+ end
103
+
104
+ def ligate(gids, text)
105
+ start = 0
106
+ glyphs = @ttf.ligatures.substitute(gids)
107
+ chars = glyphs.map { |_gid, count| text[start, count].tap { start += count } }
108
+ [glyphs.map(&:first), chars].each(&:freeze).freeze
109
+ end
110
+
111
+ def advance_units(text, ligatures)
112
+ @advances[ligatures][text] ||= shape(text, ligatures).first.sum { |gid| @ttf.advance(gid) }
96
113
  end
97
114
 
98
- def kerning_units(text)
99
- @kerns[text] ||= text.each_char.map { |char| @ttf.glyph_id(char.ord) }
100
- .each_cons(2).sum { |left, right| pair(left, right) }
115
+ def kerning_units(text, ligatures)
116
+ @kerns[ligatures][text] ||= shape(text, ligatures).first.each_cons(2).sum { |left, right| pair(left, right) }
101
117
  end
102
118
 
103
119
  # Kerning between two glyphs in thousandths of an em.
@@ -34,6 +34,9 @@ module Stationery
34
34
  end
35
35
  end
36
36
 
37
+ # Whether `name` is registered, bundled or an installed pack.
38
+ def known?(name) = !(@families[name.to_s] || bundled(name) || pack(name)).nil?
39
+
37
40
  # The runs split so every character is drawn by a font that has it. Runs
38
41
  # this book already split come back as they are.
39
42
  def fallback(runs)
@@ -3,16 +3,17 @@
3
3
  module Stationery
4
4
  module Fonts
5
5
  # Glyph ids for one run of text in one font, with an extra advance after
6
- # each glyph in thousandths of the font size (positive widens). All-zero
6
+ # each glyph in thousandths of the font size (positive widens) and the
7
+ # source text of each glyph (several characters for a ligature). All-zero
7
8
  # adjustments draw as a plain Tj string; otherwise as a TJ array. Glyphs
8
9
  # are written as the font's character codes (see Font#code).
9
- GlyphRun = Data.define(:font, :gids, :adjust) do
10
+ GlyphRun = Data.define(:font, :gids, :adjust, :chars) do
10
11
  def width(size, letter_spacing: 0)
11
12
  units = gids.sum { |gid| font.ttf.advance(gid) }
12
13
  (units * size / font.ttf.units_per_em.to_f) + (adjust.sum * size / 1000.0) + (letter_spacing * gids.size)
13
14
  end
14
15
 
15
- def with_word_spacing(chars, extra_points, size)
16
+ def with_word_spacing(extra_points, size)
16
17
  extra = extra_points * 1000.0 / size
17
18
  with(adjust: adjust.each_with_index.map { |a, i| chars[i] == " " ? a + extra : a })
18
19
  end
@@ -15,19 +15,21 @@ module Stationery
15
15
  base = ttf.table_offset("GPOS")
16
16
  return unless base && ttf.u16(base) == 1
17
17
 
18
- indices = kern_lookups(ttf, base + ttf.u16(base + 6))
19
- return if indices.empty?
20
-
21
- list = base + ttf.u16(base + 8)
22
- new(indices.map { |i| lookup(ttf, list + ttf.u16(list + 2 + (i * 2))) })
18
+ offsets = feature_lookups(ttf, base, "kern")
19
+ new(offsets.map { |offset| lookup(ttf, offset) }) if offsets.any?
23
20
  end
24
21
 
25
- def self.kern_lookups(ttf, list)
22
+ # Offsets of the lookups every `tag` feature record of the GSUB or GPOS
23
+ # table at `base` names, in LookupList order.
24
+ def self.feature_lookups(ttf, base, tag)
25
+ list = base + ttf.u16(base + 6)
26
26
  records = Array.new(ttf.u16(list)) { |i| list + 2 + (i * 6) }
27
- records.select { |record| ttf.data.byteslice(record, 4) == "kern" }.flat_map do |record|
27
+ indices = records.select { |record| ttf.data.byteslice(record, 4) == tag }.flat_map do |record|
28
28
  feature = list + ttf.u16(record + 4)
29
29
  Array.new(ttf.u16(feature + 2)) { |j| ttf.u16(feature + 4 + (j * 2)) }
30
- end.uniq
30
+ end
31
+ lookups = base + ttf.u16(base + 8)
32
+ indices.uniq.sort.map { |i| lookups + ttf.u16(lookups + 2 + (i * 2)) }
31
33
  end
32
34
 
33
35
  # A lookup's PairPos subtables; other lookup types have none.
@@ -42,7 +44,7 @@ module Stationery
42
44
  end
43
45
  end.freeze
44
46
  end
45
- private_class_method :kern_lookups, :lookup
47
+ private_class_method :lookup
46
48
 
47
49
  def initialize(lookups)
48
50
  @lookups = lookups.freeze
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Fonts
5
+ class Gsub
6
+ # LigatureSubst format 1 (GSUB lookup type 4): for each first glyph, the
7
+ # ligatures starting with it, longest first.
8
+ class LigatureSubst
9
+ def self.read(ttf, offset)
10
+ return unless ttf.u16(offset) == 1
11
+
12
+ sets = Gpos::Coverage.read(ttf, offset + ttf.u16(offset + 2)).each_with_index.to_h do |first, i|
13
+ set = offset + ttf.u16(offset + 6 + (i * 2))
14
+ [first, ligatures(ttf, set).sort_by.with_index { |(rest, _), j| [-rest.size, j] }.freeze]
15
+ end
16
+ new(sets)
17
+ end
18
+
19
+ # [[component gids after the first, ligature gid], ...]
20
+ def self.ligatures(ttf, set)
21
+ Array.new(ttf.u16(set)) do |i|
22
+ ligature = set + ttf.u16(set + 2 + (i * 2))
23
+ count = ttf.u16(ligature + 2)
24
+ [ttf.data.byteslice(ligature + 4, (count - 1) * 2).unpack("n*").freeze, ttf.u16(ligature)]
25
+ end
26
+ end
27
+ private_class_method :ligatures
28
+
29
+ def initialize(sets)
30
+ @sets = sets.freeze
31
+ freeze
32
+ end
33
+
34
+ # [ligature gid, glyphs consumed] for the longest ligature at `index`, or nil.
35
+ def match(gids, index)
36
+ @sets[gids[index]]&.each do |components, ligature|
37
+ return [ligature, components.size + 1] if gids[index + 1, components.size] == components
38
+ end
39
+ nil
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Fonts
5
+ # Standard ligatures from the GSUB `liga` feature: LigatureSubst lookups
6
+ # (format 1), directly or behind Extension lookups. Only `liga` applies;
7
+ # contextual (`clig`) and discretionary (`dlig`) ligatures are left out.
8
+ # Scripts, languages and lookup flags are not distinguished.
9
+ class Gsub
10
+ LIGATURE = 4
11
+ EXTENSION = 7
12
+
13
+ # nil when the font has no GSUB table or no `liga` ligatures.
14
+ def self.parse(ttf)
15
+ base = ttf.table_offset("GSUB")
16
+ return unless base && ttf.u16(base) == 1
17
+
18
+ lookups = Gpos.feature_lookups(ttf, base, "liga").map { |offset| lookup(ttf, offset) }.reject(&:empty?)
19
+ new(lookups) if lookups.any?
20
+ end
21
+
22
+ # A lookup's LigatureSubst subtables; other lookup types have none.
23
+ def self.lookup(ttf, offset)
24
+ type = ttf.u16(offset)
25
+ subtables = Array.new(ttf.u16(offset + 4)) { |i| offset + ttf.u16(offset + 6 + (i * 2)) }
26
+ subtables.filter_map do |subtable|
27
+ if type == LIGATURE
28
+ LigatureSubst.read(ttf, subtable)
29
+ elsif type == EXTENSION && ttf.u16(subtable + 2) == LIGATURE
30
+ LigatureSubst.read(ttf, subtable + ttf.u32(subtable + 4))
31
+ end
32
+ end.freeze
33
+ end
34
+ private_class_method :lookup
35
+
36
+ def initialize(lookups)
37
+ @lookups = lookups.freeze
38
+ freeze
39
+ end
40
+
41
+ # [[gid, number of source glyphs it stands for], ...]. Lookups apply in
42
+ # order; within one, the first subtable matching at a position wins and
43
+ # its glyph is not matched again by that lookup.
44
+ def substitute(gids)
45
+ @lookups.reduce(gids.map { |gid| [gid, 1] }) { |glyphs, subtables| apply(subtables, glyphs) }
46
+ end
47
+
48
+ private
49
+
50
+ def apply(subtables, glyphs)
51
+ ids = glyphs.map(&:first)
52
+ result = []
53
+ i = 0
54
+ while i < glyphs.size
55
+ match = nil
56
+ subtables.find { |subtable| match = subtable.match(ids, i) }
57
+ length = match ? match.last : 1
58
+ result << [match ? match.first : ids[i], glyphs[i, length].sum(&:last)]
59
+ i += length
60
+ end
61
+ result
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Fonts
5
+ # Chooses where a font's ligatures come from. Every source answers
6
+ # `substitute(gids)` with [[gid, source glyph count], ...].
7
+ module Ligatures
8
+ # For fonts without ligatures: every glyph stands for itself.
9
+ module NONE
10
+ def self.substitute(gids) = gids.map { |gid| [gid, 1] }
11
+ end
12
+
13
+ def self.for(ttf) = Gsub.parse(ttf) || NONE
14
+ end
15
+ end
16
+ end
@@ -2,21 +2,24 @@
2
2
 
3
3
  module Stationery
4
4
  module Fonts
5
- # Process-wide cache of parsed TrueType files, keyed by path and mtime so an
6
- # edited font is re-read. Parsing is the expensive part; the per-document
7
- # Font objects that track used glyphs are cheap wrappers around a parse.
5
+ # Process-wide cache of parsed TrueType files, keyed by path, face and
6
+ # mtime so an edited font is re-read. Parsing is the expensive part; the
7
+ # per-document Font objects that track used glyphs are cheap wrappers
8
+ # around a parse. A `#N` suffix on the path picks face N of a collection.
8
9
  module Registry
9
10
  SIZE = 16
11
+ FACE = /\A(.+)#(\d+)\z/
10
12
  @cache = {}
11
13
  @mutex = Mutex.new
12
14
 
13
15
  class << self
14
16
  def load(path)
15
- path = File.expand_path(path.to_s)
17
+ path, index = split(path.to_s)
18
+ path = File.expand_path(path)
16
19
  raise UnsupportedFont, "font file not found: #{path}" unless File.file?(path)
17
20
 
18
21
  mtime = File.mtime(path)
19
- @mutex.synchronize { fetch(path, mtime) }
22
+ @mutex.synchronize { fetch(path, index, mtime) }
20
23
  end
21
24
 
22
25
  def clear
@@ -25,10 +28,16 @@ module Stationery
25
28
 
26
29
  private
27
30
 
28
- def fetch(path, mtime)
29
- cached_mtime, ttf = @cache.delete(path)
30
- ttf = TrueType.new(File.binread(path)) unless cached_mtime == mtime
31
- @cache[path] = [mtime, ttf]
31
+ def split(path)
32
+ match = FACE.match(path)
33
+ match ? [match[1], match[2].to_i] : [path, 0]
34
+ end
35
+
36
+ def fetch(path, index, mtime)
37
+ key = [path, index]
38
+ cached_mtime, ttf = @cache.delete(key)
39
+ ttf = TrueType.new(File.binread(path), index:) unless cached_mtime == mtime
40
+ @cache[key] = [mtime, ttf]
32
41
  @cache.shift while @cache.size > SIZE
33
42
  ttf
34
43
  end
@@ -7,7 +7,7 @@ module Stationery
7
7
  module ToUnicode
8
8
  module_function
9
9
 
10
- # `chars` is { code => character }.
10
+ # `chars` is { code => text }; a ligature maps to several characters.
11
11
  def cmap(chars)
12
12
  mappings = chars.sort.map do |code, char|
13
13
  format("<%<code>04X> <%<utf16>s>", code:, utf16: char.encode(Encoding::UTF_16BE).unpack1("H*").upcase)