stationery 0.2.0 → 0.4.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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +100 -1
  3. data/README.md +143 -12
  4. data/lib/stationery/builder.rb +2 -1
  5. data/lib/stationery/canvas/debug.rb +2 -1
  6. data/lib/stationery/canvas/marking.rb +124 -0
  7. data/lib/stationery/canvas/text.rb +5 -4
  8. data/lib/stationery/canvas.rb +37 -8
  9. data/lib/stationery/document.rb +35 -10
  10. data/lib/stationery/elements/forms.rb +55 -0
  11. data/lib/stationery/elements/lists.rb +15 -5
  12. data/lib/stationery/elements.rb +10 -7
  13. data/lib/stationery/fonts/font.rb +36 -20
  14. data/lib/stationery/fonts/font_book.rb +3 -0
  15. data/lib/stationery/fonts/glyph_run.rb +4 -3
  16. data/lib/stationery/fonts/gpos.rb +11 -9
  17. data/lib/stationery/fonts/gsub/ligature_subst.rb +44 -0
  18. data/lib/stationery/fonts/gsub.rb +65 -0
  19. data/lib/stationery/fonts/ligatures.rb +16 -0
  20. data/lib/stationery/fonts/registry.rb +18 -9
  21. data/lib/stationery/fonts/to_unicode.rb +1 -1
  22. data/lib/stationery/fonts/true_type.rb +36 -12
  23. data/lib/stationery/fonts/woff.rb +52 -0
  24. data/lib/stationery/forms/acro_form.rb +115 -0
  25. data/lib/stationery/forms/appearance.rb +119 -0
  26. data/lib/stationery/forms/field.rb +122 -0
  27. data/lib/stationery/forms/metrics.rb +49 -0
  28. data/lib/stationery/layout/box.rb +34 -11
  29. data/lib/stationery/layout/field.rb +51 -0
  30. data/lib/stationery/layout/flow.rb +17 -9
  31. data/lib/stationery/layout/image.rb +4 -2
  32. data/lib/stationery/layout/list_item.rb +15 -6
  33. data/lib/stationery/layout/node.rb +2 -0
  34. data/lib/stationery/layout/paginator.rb +3 -2
  35. data/lib/stationery/layout/row.rb +1 -1
  36. data/lib/stationery/layout/svg.rb +9 -2
  37. data/lib/stationery/layout/table/cell.rb +10 -2
  38. data/lib/stationery/layout/table.rb +42 -10
  39. data/lib/stationery/layout/table_of_contents/entry.rb +17 -9
  40. data/lib/stationery/layout/table_of_contents.rb +1 -1
  41. data/lib/stationery/layout/text.rb +6 -4
  42. data/lib/stationery/minitest.rb +2 -0
  43. data/lib/stationery/page.rb +5 -2
  44. data/lib/stationery/page_templates.rb +7 -4
  45. data/lib/stationery/pdf/assembler.rb +38 -5
  46. data/lib/stationery/pdf/encryption/aes.rb +37 -0
  47. data/lib/stationery/pdf/encryption/rc4.rb +33 -0
  48. data/lib/stationery/pdf/encryption/revision4.rb +48 -0
  49. data/lib/stationery/pdf/encryption/revision6.rb +56 -0
  50. data/lib/stationery/pdf/encryption/standard_security.rb +78 -0
  51. data/lib/stationery/pdf/serializer.rb +17 -9
  52. data/lib/stationery/pdf/writer.rb +24 -5
  53. data/lib/stationery/resources.rb +8 -2
  54. data/lib/stationery/rich/renderer.rb +3 -3
  55. data/lib/stationery/structure.rb +15 -9
  56. data/lib/stationery/svg/bounds.rb +76 -0
  57. data/lib/stationery/svg/document.rb +48 -18
  58. data/lib/stationery/svg/gradient.rb +115 -0
  59. data/lib/stationery/svg/painter.rb +70 -0
  60. data/lib/stationery/svg/parser.rb +24 -4
  61. data/lib/stationery/svg/selector.rb +37 -0
  62. data/lib/stationery/svg/shading.rb +34 -0
  63. data/lib/stationery/svg/style.rb +37 -20
  64. data/lib/stationery/svg/stylesheet.rb +47 -0
  65. data/lib/stationery/svg/text.rb +122 -0
  66. data/lib/stationery/svg/transform.rb +11 -0
  67. data/lib/stationery/tagging/element.rb +74 -0
  68. data/lib/stationery/tagging/tree.rb +43 -0
  69. data/lib/stationery/tagging/writer.rb +81 -0
  70. data/lib/stationery/testing/inspector.rb +15 -0
  71. data/lib/stationery/testing/marked_text.rb +60 -0
  72. data/lib/stationery/testing/matchers.rb +25 -0
  73. data/lib/stationery/testing/structure_reader.rb +71 -0
  74. data/lib/stationery/text/paragraph.rb +28 -12
  75. data/lib/stationery/text/style.rb +4 -2
  76. data/lib/stationery/text/wrapper.rb +2 -1
  77. data/lib/stationery/version.rb +1 -1
  78. data/lib/stationery/warnings.rb +8 -0
  79. data/lib/stationery.rb +26 -0
  80. metadata +29 -1
@@ -7,6 +7,7 @@ module Stationery
7
7
  class Canvas
8
8
  include Text
9
9
  include Debug
10
+ include Marking
10
11
 
11
12
  CAPS = { butt: 0, round: 1, square: 2 }.freeze
12
13
  JOINS = { miter: 0, round: 1, bevel: 2 }.freeze
@@ -14,11 +15,14 @@ module Stationery
14
15
  attr_reader :page
15
16
 
16
17
  # `template: true` records anchors apart, for canvases page templates draw on.
17
- def initialize(page, resources, template: false, debug: false)
18
+ # `tagging:` (a Tagging::Tree) marks content for a tagged PDF.
19
+ def initialize(page, resources, template: false, debug: false, tagging: nil)
18
20
  @page = page
19
21
  @resources = resources
20
22
  @template = template
21
23
  @debug = debug
24
+ @tagging = tagging
25
+ @marked = 0
22
26
  end
23
27
 
24
28
  def save
@@ -58,6 +62,19 @@ module Stationery
58
62
  shape(fill:, stroke:, line_width:, cap:, join:, dash:, even_odd:, opacity:, transform:, &)
59
63
  end
60
64
 
65
+ # Paints `shading` inside the path the block traces. `matrix` maps the
66
+ # shading's coordinates into top-left page space.
67
+ def shade(shading, matrix:, transform: nil, even_odd: false, opacity: nil)
68
+ path = Path.new(self, transform:)
69
+ yield path
70
+ name = @page.use(:Shading, @resources.shading(shading))
71
+ a, b, c, d, e, f = matrix
72
+ graphics(opacity:) do |ops|
73
+ ops << path.to_s << (even_odd ? "W* n" : "W n")
74
+ ops << "#{[a, -b, c, -d, e, @page.height - f].map { |v| num(v) }.join(" ")} cm" << "/#{name} sh"
75
+ end
76
+ end
77
+
61
78
  def image(image, x:, y:, width:, height:, opacity: nil)
62
79
  name = @page.use(:XObject, @resources.image(image))
63
80
  graphics(opacity:) do |ops|
@@ -68,11 +85,22 @@ module Stationery
68
85
 
69
86
  # A clickable area opening `target`: a URL, or `#name` for an anchor in
70
87
  # this document. Annotation rectangles live in absolute, untransformed
71
- # page space, so they ignore clips and path transforms.
72
- def link(x, y, w, h, target)
88
+ # page space, so they ignore clips and path transforms. `tag:` is the
89
+ # Link element the annotation belongs to in a tagged PDF.
90
+ def link(x, y, w, h, target, tag: nil)
73
91
  target = target.to_s
74
92
  rect = [x, @page.height - y - h, x + w, @page.height - y].map { |v| num_value(v) }
75
- @page.annotations << (target.start_with?("#") ? { rect:, dest: target[1..] } : { rect:, url: target })
93
+ annotation = target.start_with?("#") ? { rect:, dest: target[1..] } : { rect:, url: target }
94
+ @page.annotations << annotation
95
+ own(annotation, tag) if tag
96
+ end
97
+
98
+ # An interactive form field's widget (a Forms::Field) over the rectangle.
99
+ def widget(field, x, y, w, h, tag: nil)
100
+ rect = [x, @page.height - y - h, x + w, @page.height - y].map { |v| num_value(v) }
101
+ annotation = { rect:, widget: field }
102
+ @page.annotations << annotation
103
+ adopt(annotation, tag, rect) if tag
76
104
  end
77
105
 
78
106
  # Names the point `y` on this page as a link target.
@@ -81,9 +109,10 @@ module Stationery
81
109
  end
82
110
 
83
111
  # Leaves room for the page number `anchor` lands on; Structure fills it in
84
- # and adds the `link:` area ([x, y, w, h]) when the anchor exists.
85
- def number_slot(anchor, x:, baseline:, width:, style:, link: nil)
86
- @page.slots << Page::Slot.new(anchor.to_s, x, baseline, width, style, link)
112
+ # and adds the `link:` area ([x, y, w, h]) when the anchor exists. `tags:`
113
+ # are the [link, number] elements they belong to in a tagged PDF.
114
+ def number_slot(anchor, x:, baseline:, width:, style:, link: nil, tags: nil)
115
+ @page.slots << Page::Slot.new(anchor.to_s, x, baseline, width, style, link, tags)
87
116
  end
88
117
 
89
118
  def num(value) = PDF::Serializer.number(num_value(value))
@@ -128,7 +157,7 @@ module Stationery
128
157
  ops = []
129
158
  ops << "/#{@page.use(:ExtGState, @resources.opacity(opacity))} gs" if opacity && opacity < 1
130
159
  yield ops
131
- emit("q", *ops, "Q")
160
+ artifact { emit("q", *ops, "Q") }
132
161
  end
133
162
 
134
163
  def emit(*ops)
@@ -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 }
27
+ templates: [], regions: [], strict: false, tagged: false }
28
28
  end
29
29
  end
30
30
 
@@ -55,6 +55,19 @@ module Stationery
55
55
  config[:strict] = value
56
56
  end
57
57
 
58
+ # Writes a tagged (accessible) PDF: a structure tree of headings,
59
+ # paragraphs and figures, and headers and footers marked as artifacts.
60
+ # Set `metadata lang:` and give images `alt:` text.
61
+ def tagged(value = true) # rubocop:disable Style/OptionalBooleanParameter
62
+ config[:tagged] = value
63
+ end
64
+
65
+ # Encrypts every render with the standard security handler; see
66
+ # PDF::Encryption::StandardSecurity for the options.
67
+ def encrypt(**)
68
+ config[:encrypt] = PDF::Encryption::StandardSecurity.options(**)
69
+ end
70
+
58
71
  # Runs after pagination on every page. `layer: :background` paints under
59
72
  # the page's content.
60
73
  def page_template(layer: :foreground, &block)
@@ -74,25 +87,29 @@ module Stationery
74
87
  end
75
88
  end
76
89
 
77
- attr_reader :warnings
90
+ # `fields` is every form field's name and value from the last render.
91
+ attr_reader :warnings, :fields
78
92
 
79
93
  def page_options = self.class.config[:page]
80
94
  def metadata = self.class.config[:metadata]
81
95
 
82
- def to_pdf(target = nil, strict: self.class.config[:strict], debug: false)
96
+ 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
83
100
  warnings = Warnings.new
84
101
  book = Fonts::FontBook.new(self.class.config[:families], fallbacks: self.class.config[:fallbacks], warnings:)
85
102
  call(builder = Builder.new(book:, text: self.class.config[:text]))
86
103
  resources = Resources.new
87
- regions = Regions.new(self.class.config[:regions], measure: region_measure(book))
88
- paginator = Layout::Paginator.new(resources:, page: page_options, warnings:, debug:, regions:)
89
- pages = paginator.paginate(builder.root)
90
- PageTemplates.new(self, book:, resources:, debug:, regions:, warnings:).apply(pages)
91
- outline = builder.outline.resolve(Structure.resolve(pages, warnings:, resources:, book:))
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])
92
107
  @warnings = warnings
108
+ @fields = Forms::AcroForm.values(pages)
93
109
  raise WarningsError, warnings if strict && warnings.any?
94
110
 
95
- write(PDF::Assembler.new(pages:, resources:, info:, outline:).render, target)
111
+ assembler = PDF::Assembler.new(pages:, resources:, info:, outline:, encryption:, tagging:, lang: metadata[:lang])
112
+ write(assembler.render, target)
96
113
  end
97
114
 
98
115
  # Used by page templates to build nodes into their own root.
@@ -113,6 +130,14 @@ module Stationery
113
130
 
114
131
  private
115
132
 
133
+ 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)
138
+ end
139
+ end
140
+
116
141
  def region_measure(book)
117
142
  page = Page.new(**page_options)
118
143
  lambda do |region, number|
@@ -122,7 +147,7 @@ module Stationery
122
147
  end
123
148
 
124
149
  def info
125
- metadata.to_h do |key, value|
150
+ metadata.except(:lang).to_h do |key, value|
126
151
  [INFO_KEYS.fetch(key.to_sym) { key.to_sym }, value.is_a?(Array) ? value.join(", ") : value]
127
152
  end
128
153
  end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ # Interactive form fields. Each takes a name (dotted names group fields,
5
+ # "address.city") and lays out like a box, or at a fixed page position with
6
+ # `at: [x, y]`.
7
+ module Elements
8
+ # A text input. `width:` is :full or points; `multiline:`, `max_length:`,
9
+ # `comb:` (a cell count, or true with max_length:), `read_only:`,
10
+ # `required:`, `font_size:`, `border:`, `background:` and `radius:`.
11
+ def text_field(name, value: "", width: :full, height: 22, at: nil, **)
12
+ field_node(Forms::Field.new(:text, name, value: value.to_s, **), width:, height:, at:)
13
+ end
14
+
15
+ # A check box `size` points square, with an optional label to its right.
16
+ def checkbox(name, checked: false, size: 12, label: nil, at: nil, **)
17
+ field = Forms::Field.new(:checkbox, name, value: checked == true, **)
18
+ field_node(field, width: size, height: size, at:, label:)
19
+ end
20
+
21
+ # One choice of the radio group `name`; its `value` becomes the group's
22
+ # value when checked.
23
+ def radio(name, value, checked: false, size: 12, label: nil, at: nil, **)
24
+ field = Forms::Field.new(:radio, name, value: value.to_s, checked:, **)
25
+ field_node(field, width: size, height: size, at:, label:)
26
+ end
27
+
28
+ # A drop-down (combo box) of `options`; `editable: true` also accepts
29
+ # typed values.
30
+ def select(name, options:, value: nil, width: :full, height: 22, at: nil, **)
31
+ field = Forms::Field.new(:select, name, value: value&.to_s, options: options.map(&:to_s), **)
32
+ field_node(field, width:, height:, at:)
33
+ end
34
+
35
+ # An empty signature field for the signer to fill, drawn as a rule over
36
+ # the label.
37
+ def signature_field(name, width: :full, height: 40, label: "Signature", at: nil, **)
38
+ field = Forms::Field.new(:signature, name, label: label.to_s, **)
39
+ field_node(field, width:, height:, at:)
40
+ end
41
+
42
+ private
43
+
44
+ def field_node(field, width:, height:, at:, label: nil)
45
+ label &&= field_label(label)
46
+ node = Layout::Field.new(field, height:, width:, label:)
47
+ @_builder.add(at ? Layout::Positioned.new(node, x: at[0], y: at[1]) : node)
48
+ end
49
+
50
+ def field_label(label)
51
+ style = @_builder.style
52
+ Layout::Text.new([Text::Run.new(label.to_s, style)], context: @_builder.context(style))
53
+ end
54
+ end
55
+ end
@@ -4,6 +4,9 @@ module Stationery
4
4
  # Bulleted and numbered lists.
5
5
  module Elements
6
6
  BULLETS = %i[disc circle square].freeze
7
+ # ListNumbering for tagged PDF, by bullet shape and number format.
8
+ NUMBERING = { disc: :Disc, circle: :Circle, square: :Square, decimal: :Decimal, alpha: :LowerAlpha,
9
+ upper_alpha: :UpperAlpha, roman: :LowerRoman, upper_roman: :UpperRoman }.freeze
7
10
 
8
11
  # A bulleted list. `style:` is :disc, :circle, :square, :dash or any
9
12
  # String; unstyled nested lists cycle disc → circle → square. Every node
@@ -11,7 +14,8 @@ module Stationery
11
14
  def ul(style: nil, gap: 4, indent: nil, marker_gap: 6, marker_color: nil, &)
12
15
  shape = style || BULLETS[@_builder.list_depth % BULLETS.size]
13
16
  marker_style = @_builder.style({ color: marker_color }.compact)
14
- list(marker_style, gap:, indent:, marker_gap:, marker: ->(_) { list_bullet(shape, marker_style) }, &)
17
+ list(marker_style, gap:, indent:, marker_gap:, marker: ->(_) { list_bullet(shape, marker_style) },
18
+ numbering: shape, &)
15
19
  end
16
20
 
17
21
  # A numbered list. `format:` is :decimal, :alpha, :upper_alpha, :roman,
@@ -19,7 +23,7 @@ module Stationery
19
23
  def ol(format: :decimal, start: 1, suffix: ".", gap: 4, indent: nil, marker_gap: 6, marker_color: nil, &)
20
24
  marker_style = @_builder.style({ color: marker_color }.compact)
21
25
  label = ->(index) { list_label(ListMarkers.label(format, start + index, suffix), marker_style) }
22
- list(marker_style, gap:, indent:, marker_gap:, marker: label, &)
26
+ list(marker_style, gap:, indent:, marker_gap:, marker: label, numbering: format, &)
23
27
  end
24
28
 
25
29
  # A list item: a String becomes a paragraph (taking every text option),
@@ -32,7 +36,7 @@ module Stationery
32
36
 
33
37
  private
34
38
 
35
- def list(marker_style, gap:, indent:, marker_gap:, marker:, &)
39
+ def list(marker_style, gap:, indent:, marker_gap:, marker:, numbering:, &)
36
40
  items = Builder::Items.new
37
41
  @_builder.nested_list { @_builder.within(items) { yield_content(&) } }
38
42
  markers = items.nodes.each_index.map(&marker)
@@ -40,7 +44,12 @@ module Stationery
40
44
  entries = items.nodes.zip(markers).map do |node, mark|
41
45
  Layout::ListItem.new(mark, node.is_a?(Layout::Flow) ? node : Layout::Flow.new([node]), indent:, marker_gap:)
42
46
  end
43
- @_builder.add(Layout::Flow.new(entries, gap:))
47
+ @_builder.add(Layout::Flow.new(entries, gap:, tag: list_tag(numbering)))
48
+ end
49
+
50
+ def list_tag(numbering)
51
+ numbering = NUMBERING[numbering] if numbering.is_a?(Symbol)
52
+ Tagging::Element.new(:L, attributes: numbering ? { List: { ListNumbering: numbering } } : {})
44
53
  end
45
54
 
46
55
  def list_bullet(shape, style)
@@ -52,7 +61,8 @@ module Stationery
52
61
  end
53
62
 
54
63
  def list_label(label, style)
55
- Layout::Text.new([Text::Run.new(label, style)], context: @_builder.context(style), align: :right)
64
+ Layout::Text.new([Text::Run.new(label, style)], context: @_builder.context(style), align: :right,
65
+ tag: Tagging::Element.new(:Lbl))
56
66
  end
57
67
  end
58
68
  end
@@ -6,21 +6,23 @@ module Stationery
6
6
  PARAGRAPH_DEFAULTS = { align: :left, leading: 0 }.freeze
7
7
 
8
8
  # A paragraph. Plain strings are always literal; pass `markup: true` to
9
- # read inline tags, or a block to build styled runs in Ruby.
9
+ # read inline tags, or a block to build styled runs in Ruby. `heading: 1..6`
10
+ # tags it as a heading in a tagged PDF.
10
11
  def text(content = nil, markup: false, keep_with_next: nil, break_inside: nil, anchor: nil, bookmark: nil,
11
- **options, &)
12
+ heading: nil, **options, &)
12
13
  settings = PARAGRAPH_DEFAULTS.merge(@_builder.text_defaults.slice(:align, :leading)).merge(options)
13
14
  style = @_builder.style(options)
14
15
  runs = text_runs(content, style, markup, &)
15
16
  node = Layout::Text.new(runs, context: @_builder.context(style), align: settings[:align],
16
- leading: settings[:leading])
17
+ leading: settings[:leading], tag: Tagging::Element.new(Tagging.heading(heading)))
17
18
  node.keep_with_next = keep_with_next
18
19
  node.break_inside = break_inside
19
20
  @_builder.add(mark(node, anchor, bookmark))
20
21
  end
21
22
 
22
23
  # A container with padding, background, border and radius. `at: [x, y]`
23
- # places it at a fixed page position outside the flow.
24
+ # places it at a fixed page position outside the flow. `role:` (:section,
25
+ # :blockquote, :note, :caption, …) groups its content in a tagged PDF.
24
26
  # `break_inside: :auto` splits it at any page break, `:avoid` never; by
25
27
  # default it splits only when it does not fit on a page of its own.
26
28
  def box(at: nil, align: nil, gap: 0, width: nil, keep_with_next: nil, break_inside: nil, anchor: nil, bookmark: nil,
@@ -65,15 +67,15 @@ module Stationery
65
67
  end
66
68
 
67
69
  # An SVG drawing: markup String, or a path to a .svg file. `currentColor`
68
- # takes `color:`.
69
- def svg(source, width: nil, height: nil, color: "#000000", align: nil)
70
+ # takes `color:`. `alt:` describes it in a tagged PDF (false: decorative).
71
+ def svg(source, width: nil, height: nil, color: "#000000", align: nil, alt: nil)
70
72
  name = source.to_s.lstrip.start_with?("<") ? "inline" : File.basename(source.to_s)
71
73
  source = File.read(source.to_s) unless name == "inline"
72
74
  document = SVG::Document.parse(source)
73
75
  if document.unsupported.any?
74
76
  @_builder.warnings << Warnings::UnsupportedSvg.new(elements: document.unsupported, source: name)
75
77
  end
76
- node = Layout::Svg.new(document, width:, height:, color:)
78
+ node = Layout::Svg.new(document, width:, height:, color:, context: @_builder.context, alt:)
77
79
  @_builder.add(align ? Layout::Flow.new([node], align:) : node)
78
80
  end
79
81
 
@@ -95,6 +97,7 @@ module Stationery
95
97
  @_builder.add(node)
96
98
  end
97
99
 
100
+ # `alt:` describes the image in a tagged PDF (false: decorative).
98
101
  def image(source, align: nil, **)
99
102
  node = Layout::Image.new(source, **)
100
103
  @_builder.add(align ? Layout::Flow.new([node], align:) : node)
@@ -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