stationery 0.1.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 (138) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +114 -1
  3. data/README.md +395 -25
  4. data/exe/stationery +6 -0
  5. data/lib/generators/stationery/fonts_generator.rb +24 -0
  6. data/lib/stationery/builder.rb +26 -3
  7. data/lib/stationery/canvas/debug.rb +26 -0
  8. data/lib/stationery/canvas/path.rb +22 -12
  9. data/lib/stationery/canvas/text.rb +10 -6
  10. data/lib/stationery/canvas.rb +38 -8
  11. data/lib/stationery/cli/fonts.rb +85 -0
  12. data/lib/stationery/cli/render.rb +137 -0
  13. data/lib/stationery/cli.rb +74 -0
  14. data/lib/stationery/document.rb +60 -9
  15. data/lib/stationery/elements/lists.rb +58 -0
  16. data/lib/stationery/elements/rich.rb +27 -0
  17. data/lib/stationery/elements.rb +88 -12
  18. data/lib/stationery/errors.rb +13 -1
  19. data/lib/stationery/fonts/bundled.rb +25 -0
  20. data/lib/stationery/fonts/catalog.rb +101 -0
  21. data/lib/stationery/fonts/cff.rb +140 -0
  22. data/lib/stationery/fonts/cff_subset.rb +145 -0
  23. data/lib/stationery/fonts/data/Inter-Bold.ttf +0 -0
  24. data/lib/stationery/fonts/data/Inter-BoldItalic.ttf +0 -0
  25. data/lib/stationery/fonts/data/Inter-Italic.ttf +0 -0
  26. data/lib/stationery/fonts/data/Inter-Regular.ttf +0 -0
  27. data/lib/stationery/fonts/data/OFL.txt +92 -0
  28. data/lib/stationery/fonts/data/README.md +14 -0
  29. data/lib/stationery/fonts/embedding/base.rb +56 -0
  30. data/lib/stationery/fonts/embedding/cff.rb +35 -0
  31. data/lib/stationery/fonts/embedding/true_type.rb +35 -0
  32. data/lib/stationery/fonts/fallback.rb +58 -0
  33. data/lib/stationery/fonts/family.rb +11 -0
  34. data/lib/stationery/fonts/font.rb +68 -88
  35. data/lib/stationery/fonts/font_book.rb +49 -8
  36. data/lib/stationery/fonts/glyph_run.rb +39 -0
  37. data/lib/stationery/fonts/gpos/class_def.rb +25 -0
  38. data/lib/stationery/fonts/gpos/coverage.rb +20 -0
  39. data/lib/stationery/fonts/gpos/pair_pos.rb +83 -0
  40. data/lib/stationery/fonts/gpos.rb +64 -0
  41. data/lib/stationery/fonts/gsub/ligature_subst.rb +44 -0
  42. data/lib/stationery/fonts/gsub.rb +65 -0
  43. data/lib/stationery/fonts/installer.rb +121 -0
  44. data/lib/stationery/fonts/kern_table.rb +52 -0
  45. data/lib/stationery/fonts/kerning.rb +17 -0
  46. data/lib/stationery/fonts/ligatures.rb +16 -0
  47. data/lib/stationery/fonts/packs.rb +45 -0
  48. data/lib/stationery/fonts/registry.rb +18 -9
  49. data/lib/stationery/fonts/sfnt_metrics.rb +51 -0
  50. data/lib/stationery/fonts/to_unicode.rb +36 -0
  51. data/lib/stationery/fonts/true_type.rb +64 -57
  52. data/lib/stationery/fonts/woff.rb +52 -0
  53. data/lib/stationery/html/document.rb +11 -0
  54. data/lib/stationery/html/tokenizer.rb +74 -0
  55. data/lib/stationery/html/tree_builder/blocks.rb +137 -0
  56. data/lib/stationery/html/tree_builder/collector.rb +50 -0
  57. data/lib/stationery/html/tree_builder.rb +72 -0
  58. data/lib/stationery/html/whitespace.rb +43 -0
  59. data/lib/stationery/layout/box.rb +102 -16
  60. data/lib/stationery/layout/flow.rb +48 -15
  61. data/lib/stationery/layout/image.rb +1 -0
  62. data/lib/stationery/layout/list_item.rb +89 -0
  63. data/lib/stationery/layout/mark.rb +58 -0
  64. data/lib/stationery/layout/node.rb +26 -2
  65. data/lib/stationery/layout/paginator.rb +35 -6
  66. data/lib/stationery/layout/positioned.rb +3 -1
  67. data/lib/stationery/layout/row.rb +34 -4
  68. data/lib/stationery/layout/svg.rb +48 -0
  69. data/lib/stationery/layout/table/cell.rb +33 -6
  70. data/lib/stationery/layout/table/grid.rb +93 -0
  71. data/lib/stationery/layout/table/row_splitter.rb +36 -0
  72. data/lib/stationery/layout/table/selection.rb +3 -2
  73. data/lib/stationery/layout/table.rb +69 -23
  74. data/lib/stationery/layout/table_of_contents/entry.rb +56 -0
  75. data/lib/stationery/layout/table_of_contents.rb +43 -0
  76. data/lib/stationery/layout/text.rb +6 -4
  77. data/lib/stationery/layout/wrap.rb +78 -0
  78. data/lib/stationery/list_markers.rb +47 -0
  79. data/lib/stationery/markdown/block_parser/containers.rb +94 -0
  80. data/lib/stationery/markdown/block_parser/tables.rb +54 -0
  81. data/lib/stationery/markdown/block_parser.rb +141 -0
  82. data/lib/stationery/markdown/document.rb +50 -0
  83. data/lib/stationery/markdown/inline_parser/emphasis.rb +80 -0
  84. data/lib/stationery/markdown/inline_parser/links.rb +70 -0
  85. data/lib/stationery/markdown/inline_parser/nodes.rb +39 -0
  86. data/lib/stationery/markdown/inline_parser.rb +89 -0
  87. data/lib/stationery/minitest.rb +33 -0
  88. data/lib/stationery/outline.rb +37 -0
  89. data/lib/stationery/page.rb +18 -4
  90. data/lib/stationery/page_templates.rb +33 -10
  91. data/lib/stationery/pdf/assembler.rb +20 -8
  92. data/lib/stationery/pdf/encryption/aes.rb +37 -0
  93. data/lib/stationery/pdf/encryption/rc4.rb +33 -0
  94. data/lib/stationery/pdf/encryption/revision4.rb +48 -0
  95. data/lib/stationery/pdf/encryption/revision6.rb +56 -0
  96. data/lib/stationery/pdf/encryption/standard_security.rb +78 -0
  97. data/lib/stationery/pdf/outline_writer.rb +72 -0
  98. data/lib/stationery/pdf/serializer.rb +22 -10
  99. data/lib/stationery/pdf/writer.rb +28 -5
  100. data/lib/stationery/preview.rb +64 -0
  101. data/lib/stationery/rails/previews_controller.rb +61 -0
  102. data/lib/stationery/rails.rb +2 -0
  103. data/lib/stationery/railtie.rb +60 -0
  104. data/lib/stationery/regions.rb +67 -0
  105. data/lib/stationery/resources.rb +8 -2
  106. data/lib/stationery/rich/nodes.rb +55 -0
  107. data/lib/stationery/rich/renderer/inlines.rb +53 -0
  108. data/lib/stationery/rich/renderer.rb +121 -0
  109. data/lib/stationery/rich/styles.rb +34 -0
  110. data/lib/stationery/rspec.rb +6 -0
  111. data/lib/stationery/structure.rb +78 -0
  112. data/lib/stationery/svg/arc.rb +91 -0
  113. data/lib/stationery/svg/bounds.rb +76 -0
  114. data/lib/stationery/svg/document.rb +103 -0
  115. data/lib/stationery/svg/gradient.rb +115 -0
  116. data/lib/stationery/svg/painter.rb +70 -0
  117. data/lib/stationery/svg/parser.rb +58 -0
  118. data/lib/stationery/svg/path_data.rb +137 -0
  119. data/lib/stationery/svg/selector.rb +37 -0
  120. data/lib/stationery/svg/shading.rb +34 -0
  121. data/lib/stationery/svg/shapes.rb +54 -0
  122. data/lib/stationery/svg/style.rb +85 -0
  123. data/lib/stationery/svg/stylesheet.rb +47 -0
  124. data/lib/stationery/svg/text.rb +122 -0
  125. data/lib/stationery/svg/transform.rb +54 -0
  126. data/lib/stationery/testing/inspector.rb +98 -0
  127. data/lib/stationery/testing/matchers.rb +123 -0
  128. data/lib/stationery/text/entities/html4.rb +49 -0
  129. data/lib/stationery/text/entities.rb +6 -1
  130. data/lib/stationery/text/line.rb +8 -2
  131. data/lib/stationery/text/paragraph.rb +33 -11
  132. data/lib/stationery/text/runs_builder.rb +9 -1
  133. data/lib/stationery/text/style.rb +4 -2
  134. data/lib/stationery/text/wrapper.rb +4 -2
  135. data/lib/stationery/version.rb +1 -1
  136. data/lib/stationery/warnings.rb +65 -0
  137. data/lib/stationery.rb +67 -0
  138. metadata +101 -4
@@ -1,14 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "digest/md5"
4
-
5
3
  module Stationery
6
4
  module Fonts
7
- # One TrueType font as used by one document: measures text, encodes it as
8
- # glyph ids and remembers which glyphs were drawn so only those are embedded.
5
+ # One font as used by one document: measures text, encodes it as
6
+ # character codes and remembers which glyphs were drawn so only those are
7
+ # embedded (TrueType) or so the widths and ToUnicode cover them (CFF).
9
8
  #
10
- # Embedded as a Type0 font over a CIDFontType2 with Identity-H encoding and a
11
- # ToUnicode map, so text extracts and searches correctly.
9
+ # Embedded as a Type0 font with Identity-H encoding and a ToUnicode map, so
10
+ # text extracts and searches correctly; the descendant CIDFont comes from
11
+ # Embedding::TrueType or Embedding::CFF.
12
12
  class Font
13
13
  OBLIQUE_SKEW = Math.tan(12 * Math::PI / 180)
14
14
 
@@ -17,132 +17,112 @@ module Stationery
17
17
  def initialize(ttf)
18
18
  @ttf = ttf
19
19
  @used = {}
20
- @widths = {}
20
+ @pairs = {}
21
+ @glyphs = {}
22
+ @shapes = { true => {}, false => {} }
23
+ @advances = { true => {}, false => {} }
24
+ @kerns = { true => {}, false => {} }
25
+ @cid_keyed = ttf.cff? && ttf.cff.cid_keyed?
21
26
  end
22
27
 
23
28
  def inspect = "#<#{self.class} #{@ttf.postscript_name} used=#{@used.size}>"
24
29
 
25
- def width_of(text, size, letter_spacing: 0)
26
- units = text.each_char.sum { |char| @widths[char] ||= @ttf.advance(@ttf.glyph_id(char.ord)) }
27
- scale(units, size) + (letter_spacing * text.length)
30
+ # Advances and kerning are remembered per string, so measuring the same
31
+ # word again (wrapping, then laying out the line) allocates nothing.
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)
37
+ return width unless kerning
38
+
39
+ width + (kerning_units(text, ligatures) * size / 1000.0)
28
40
  end
29
41
 
30
42
  def ascender(size) = scale(@ttf.ascender, size)
31
43
  def descender(size) = -scale(@ttf.descender, size)
32
44
  def line_gap(size) = scale(@ttf.line_gap, size)
33
45
  def line_height(size) = ascender(size) + descender(size) + line_gap(size)
46
+ def x_height(size) = scale(@ttf.x_height, size)
34
47
  def underline_position(size) = scale(@ttf.underline_position, size)
35
48
  def underline_thickness(size) = scale(@ttf.underline_thickness, size)
36
49
  def strikeout_position(size) = scale(@ttf.strikeout_position, size)
37
50
  def strikeout_size(size) = scale(@ttf.strikeout_size, size)
38
- def glyph?(char) = @ttf.glyph?(char)
51
+ def glyph?(char) = @glyphs.fetch(char) { @glyphs[char] = @ttf.glyph?(char) }
39
52
 
40
53
  def bold?
41
54
  @ttf.weight >= 600
42
55
  end
43
56
 
44
- def encode(text)
45
- text.each_char.map do |char|
46
- gid = @ttf.glyph_id(char.ord)
47
- @used[gid] ||= char
48
- gid
49
- end.pack("n*")
57
+ def encode(text) = glyph_run(text).gids.map { |gid| code(gid) }.pack("n*")
58
+
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] }
64
+ adjust = gids.each_with_index.map { |gid, i| kerning && i + 1 < gids.size ? pair(gid, gids[i + 1]) : 0 }
65
+ GlyphRun.new(font: self, gids:, adjust:, chars:)
50
66
  end
51
67
 
52
68
  def used?
53
69
  @used.any?
54
70
  end
55
71
 
56
- def build(writer)
57
- gids = @used.keys.sort
58
- subset, mapping = Subset.build(@ttf, gids)
59
- name = :"#{subset_tag(gids)}+#{@ttf.postscript_name}"
60
-
61
- writer.add(
62
- Type: :Font, Subtype: :Type0, BaseFont: name, Encoding: :"Identity-H",
63
- DescendantFonts: [cid_font(writer, name, gids, subset, mapping)],
64
- ToUnicode: writer.add(PDF::Stream.new(to_unicode_cmap))
65
- )
72
+ # The two-byte character code a glyph is written as: its CID in a
73
+ # CID-keyed CFF font, otherwise the glyph id itself.
74
+ def code(gid)
75
+ @cid_keyed ? @ttf.cff.cid_for(gid) : gid
66
76
  end
67
77
 
68
- private
69
-
70
- def cid_font(writer, name, gids, subset, mapping)
71
- writer.add(
72
- Type: :Font, Subtype: :CIDFontType2, BaseFont: name,
73
- CIDSystemInfo: { Registry: "Adobe", Ordering: "Identity", Supplement: 0 },
74
- FontDescriptor: descriptor(writer, name, subset),
75
- DW: glyph_space(@ttf.advance(0)), W: glyph_widths(gids),
76
- CIDToGIDMap: writer.add(PDF::Stream.new(cid_to_gid_map(gids, mapping)))
77
- )
78
+ # { code => text } for every glyph drawn so far; a ligature's text is
79
+ # every character it stands for.
80
+ def used_codes
81
+ @used.to_h { |gid, char| [code(gid), char] }
78
82
  end
79
83
 
80
- def descriptor(writer, name, subset)
84
+ def build(writer)
85
+ embedding = (@ttf.cff? ? Embedding::CFF : Embedding::TrueType).new(self)
86
+ name, cid_font = embedding.build(writer, @used.keys.sort)
87
+
81
88
  writer.add(
82
- Type: :FontDescriptor, FontName: name, Flags: flags,
83
- FontBBox: @ttf.bbox.map { |v| glyph_space(v) }, ItalicAngle: @ttf.italic_angle,
84
- Ascent: glyph_space(@ttf.ascender), Descent: glyph_space(@ttf.descender),
85
- CapHeight: glyph_space(@ttf.cap_height), XHeight: glyph_space(@ttf.x_height),
86
- StemV: bold? ? 120 : 80,
87
- FontFile2: writer.add(PDF::Stream.new(subset, { Length1: subset.bytesize }))
89
+ Type: :Font, Subtype: :Type0, BaseFont: name, Encoding: :"Identity-H",
90
+ DescendantFonts: [cid_font], ToUnicode: writer.add(PDF::Stream.new(ToUnicode.cmap(used_codes)))
88
91
  )
89
92
  end
90
93
 
91
- def scale(units, size)
92
- units * size / @ttf.units_per_em.to_f
93
- end
94
+ private
94
95
 
95
- def glyph_space(units)
96
- (units * 1000.0 / @ttf.units_per_em).round
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
97
102
  end
98
103
 
99
- def flags
100
- flags = 32 # Nonsymbolic
101
- flags |= 1 if @ttf.fixed_pitch?
102
- flags |= 64 unless @ttf.italic_angle.zero?
103
- flags
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
104
109
  end
105
110
 
106
- # Subset fonts are named with six uppercase letters: ABCDEF+OpenSans-Regular.
107
- def subset_tag(gids)
108
- Digest::MD5.digest(gids.pack("n*") + @ttf.postscript_name).bytes.first(6).map { |b| (65 + (b % 26)).chr }.join
111
+ def advance_units(text, ligatures)
112
+ @advances[ligatures][text] ||= shape(text, ligatures).first.sum { |gid| @ttf.advance(gid) }
109
113
  end
110
114
 
111
- # Text uses the original glyph ids as CIDs; this maps them to the subset's ids.
112
- def cid_to_gid_map(gids, mapping)
113
- map = Array.new((gids.max || 0) + 1, 0)
114
- gids.each { |gid| map[gid] = mapping.fetch(gid) }
115
- map.pack("n*")
115
+ def kerning_units(text, ligatures)
116
+ @kerns[ligatures][text] ||= shape(text, ligatures).first.each_cons(2).sum { |left, right| pair(left, right) }
116
117
  end
117
118
 
118
- def glyph_widths(gids)
119
- gids.slice_when { |a, b| b != a + 1 }.flat_map do |run|
120
- [run.first, run.map { |gid| glyph_space(@ttf.advance(gid)) }]
121
- end
119
+ # Kerning between two glyphs in thousandths of an em.
120
+ def pair(left, right)
121
+ @pairs[(left << 16) | right] ||= @ttf.kerning.adjust(left, right) * 1000.0 / @ttf.units_per_em
122
122
  end
123
123
 
124
- def to_unicode_cmap
125
- mappings = @used.sort.map do |gid, char|
126
- format("<%<gid>04X> <%<utf16>s>", gid:, utf16: char.encode(Encoding::UTF_16BE).unpack1("H*").upcase)
127
- end
128
- blocks = mappings.each_slice(100).map { |slice| "#{slice.size} beginbfchar\n#{slice.join("\n")}\nendbfchar" }
129
-
130
- <<~CMAP
131
- /CIDInit /ProcSet findresource begin
132
- 12 dict begin
133
- begincmap
134
- /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def
135
- /CMapName /Adobe-Identity-UCS def
136
- /CMapType 2 def
137
- 1 begincodespacerange
138
- <0000> <FFFF>
139
- endcodespacerange
140
- #{blocks.join("\n")}
141
- endcmap
142
- CMapName currentdict /CMap defineresource pop
143
- end
144
- end
145
- CMAP
124
+ def scale(units, size)
125
+ units * size / @ttf.units_per_em.to_f
146
126
  end
147
127
  end
148
128
  end
@@ -6,30 +6,71 @@ module Stationery
6
6
  # document gets its own book so glyph usage (and so subsetting) is per
7
7
  # document, while the parsed TrueType data is shared through the Registry.
8
8
  class FontBook
9
- attr_reader :families
9
+ attr_reader :families, :fallbacks, :warnings
10
10
 
11
- def initialize(families = {})
11
+ # `fallbacks:` names the families tried, in order, for a character the
12
+ # run's own family has no glyph for.
13
+ def initialize(families = {}, fallbacks: [], warnings: Warnings.new)
12
14
  @families = families.dup
15
+ @fallbacks = fallbacks.map(&:to_s).freeze
16
+ @warnings = warnings
13
17
  @fonts = {}
18
+ @resolved = {}
19
+ @split = {}.compare_by_identity
14
20
  end
15
21
 
16
22
  def inspect = "#<#{self.class} families=#{@families.keys.inspect}>"
17
23
 
18
24
  def register(name, **paths)
19
- @families[name.to_s] = Family.new(name, **paths)
25
+ @resolved.clear
26
+ @families[name.to_s] = Family.build(name, **paths)
20
27
  end
21
28
 
22
- # Returns [Font, Family::Face] for a Text::Style.
29
+ # Returns [Font, Family::Face] for a Text::Style, memoised per style.
23
30
  def resolve(style)
24
- face = family(style.family).face(weight: style.weight, style: style.style)
25
- [@fonts[face.path] ||= Font.new(Registry.load(face.path)), face]
31
+ @resolved[style] ||= begin
32
+ face = family(style.family).face(weight: style.weight, style: style.style)
33
+ [@fonts[face.path] ||= Font.new(Registry.load(face.path)), face].freeze
34
+ end
35
+ end
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
+
40
+ # The runs split so every character is drawn by a font that has it. Runs
41
+ # this book already split come back as they are.
42
+ def fallback(runs)
43
+ return runs if @split.key?(runs)
44
+
45
+ (@fallback ||= Fallback.new(self)).apply(runs).tap { |split| @split[split] = true }
26
46
  end
27
47
 
28
48
  private
29
49
 
50
+ # Registered by name, bundled by name, an installed pack by name, then
51
+ # the first registered family, then the bundled default.
30
52
  def family(name)
31
- @families[name.to_s] || @families.values.first ||
32
- raise(Error, "no font registered; declare one with `font_family \"Name\", regular: \"path/to/font.ttf\"`")
53
+ @families[name.to_s] || bundled(name) || pack(name) || substitute(name)
54
+ end
55
+
56
+ # An unknown name draws with the first registered family, else bundled
57
+ # Inter, and says so once.
58
+ def substitute(name)
59
+ used = @families.values.first || bundled(Bundled::DEFAULT)
60
+ @warnings << Warnings::UnknownFamily.new(requested: name.to_s, used: used.name)
61
+ used
62
+ end
63
+
64
+ def bundled(name)
65
+ @bundled ||= {}
66
+ @bundled[name.to_s] ||= Bundled.family(name)
67
+ end
68
+
69
+ def pack(name)
70
+ @packs ||= {}
71
+ return @packs[name.to_s] if @packs.key?(name.to_s)
72
+
73
+ @packs[name.to_s] = Packs.family(name)
33
74
  end
34
75
  end
35
76
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Fonts
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) and the
7
+ # source text of each glyph (several characters for a ligature). All-zero
8
+ # adjustments draw as a plain Tj string; otherwise as a TJ array. Glyphs
9
+ # are written as the font's character codes (see Font#code).
10
+ GlyphRun = Data.define(:font, :gids, :adjust, :chars) do
11
+ def width(size, letter_spacing: 0)
12
+ units = gids.sum { |gid| font.ttf.advance(gid) }
13
+ (units * size / font.ttf.units_per_em.to_f) + (adjust.sum * size / 1000.0) + (letter_spacing * gids.size)
14
+ end
15
+
16
+ def with_word_spacing(extra_points, size)
17
+ extra = extra_points * 1000.0 / size
18
+ with(adjust: adjust.each_with_index.map { |a, i| chars[i] == " " ? a + extra : a })
19
+ end
20
+
21
+ def to_operator
22
+ return "<#{hex(gids)}> Tj" unless adjusted?
23
+
24
+ parts = gids.each_index.slice_after { |i| !adjust[i].zero? }.flat_map do |indices|
25
+ last = indices.last
26
+ chunk = ["<#{hex(indices.map { |i| gids[i] })}>"]
27
+ last == gids.size - 1 || adjust[last].zero? ? chunk : chunk << PDF::Serializer.number(-adjust[last])
28
+ end
29
+ "[#{parts.join(" ")}] TJ"
30
+ end
31
+
32
+ private
33
+
34
+ def adjusted? = adjust[0...-1].any? { |a| !a.zero? }
35
+
36
+ def hex(ids) = ids.map { |gid| font.code(gid) }.pack("n*").unpack1("H*").upcase
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Fonts
5
+ class Gpos
6
+ # OpenType ClassDef table as a Hash of glyph id => class. Glyphs it
7
+ # does not list are class 0. Format 1 is a run of classes from a start
8
+ # glyph, format 2 ranges of glyphs sharing a class.
9
+ module ClassDef
10
+ def self.read(ttf, offset)
11
+ if ttf.u16(offset) == 1
12
+ start = ttf.u16(offset + 2)
13
+ classes = ttf.data.byteslice(offset + 6, ttf.u16(offset + 4) * 2).unpack("n*")
14
+ return classes.each_with_index.to_h { |klass, i| [start + i, klass] }.freeze
15
+ end
16
+
17
+ ranges = ttf.data.byteslice(offset + 4, ttf.u16(offset + 2) * 6).unpack("n*").each_slice(3)
18
+ ranges.each_with_object({}) do |(first, last, klass), map|
19
+ (first..last).each { |gid| map[gid] = klass }
20
+ end.freeze
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Fonts
5
+ class Gpos
6
+ # OpenType Coverage table: the glyphs a subtable applies to, in
7
+ # coverage-index order. Format 1 lists glyphs, format 2 ranges.
8
+ module Coverage
9
+ def self.read(ttf, offset)
10
+ count = ttf.u16(offset + 2)
11
+ return ttf.data.byteslice(offset + 4, count * 2).unpack("n*") if ttf.u16(offset) == 1
12
+
13
+ ttf.data.byteslice(offset + 4, count * 6).unpack("n*").each_slice(3).flat_map do |first, last, _index|
14
+ (first..last).to_a
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Fonts
5
+ class Gpos
6
+ # PairPos subtables (GPOS lookup type 2), keeping only the first glyph's
7
+ # X advance. `adjust` answers nil when the subtable does not apply, so
8
+ # the lookup tries its next subtable.
9
+ module PairPos
10
+ X_ADVANCE = 0x0004
11
+
12
+ def self.read(ttf, offset)
13
+ ttf.u16(offset) == 1 ? Pairs.read(ttf, offset) : Classes.read(ttf, offset)
14
+ end
15
+
16
+ # The first glyph's X advance of `count` records at `offset`, each
17
+ # `lead` 16-bit fields (a second glyph id) followed by two value
18
+ # records. A value record has one field per bit set in its format.
19
+ def self.x_advances(ttf, offset, count, format1, format2, lead: 0)
20
+ width = [lead + fields(format1) + fields(format2), 1].max
21
+ records = ttf.data.byteslice(offset, count * width * 2).unpack("s>*").each_slice(width)
22
+ return records.map { |record| [record.first & 0xFFFF, 0] } unless format1.anybits?(X_ADVANCE)
23
+
24
+ index = lead + fields(format1 & (X_ADVANCE - 1))
25
+ records.map { |record| [record.first & 0xFFFF, record[index]] }
26
+ end
27
+
28
+ def self.fields(format) = format.to_s(2).count("1")
29
+
30
+ # Format 1: explicit pairs, grouped by first glyph.
31
+ class Pairs
32
+ def self.read(ttf, offset)
33
+ format1 = ttf.u16(offset + 4)
34
+ format2 = ttf.u16(offset + 6)
35
+ pairs = {}
36
+ Coverage.read(ttf, offset + ttf.u16(offset + 2)).each_with_index do |left, i|
37
+ set = offset + ttf.u16(offset + 10 + (i * 2))
38
+ PairPos.x_advances(ttf, set + 2, ttf.u16(set), format1, format2, lead: 1).each do |right, value|
39
+ pairs[(left << 16) | right] = value
40
+ end
41
+ end
42
+ new(pairs)
43
+ end
44
+
45
+ def initialize(pairs)
46
+ @pairs = pairs.freeze
47
+ freeze
48
+ end
49
+
50
+ def adjust(left, right) = @pairs[(left << 16) | right]
51
+ end
52
+
53
+ # Format 2: a class-by-class matrix over two ClassDefs, kept as classes
54
+ # rather than expanded into every glyph pair.
55
+ class Classes
56
+ def self.read(ttf, offset)
57
+ format1 = ttf.u16(offset + 4)
58
+ format2 = ttf.u16(offset + 6)
59
+ columns = ttf.u16(offset + 14)
60
+ values = PairPos.x_advances(ttf, offset + 16, ttf.u16(offset + 12) * columns, format1, format2)
61
+ new(Coverage.read(ttf, offset + ttf.u16(offset + 2)),
62
+ ClassDef.read(ttf, offset + ttf.u16(offset + 8)), ClassDef.read(ttf, offset + ttf.u16(offset + 10)),
63
+ values.map(&:last).each_slice(columns).to_a)
64
+ end
65
+
66
+ def initialize(coverage, first_classes, second_classes, matrix)
67
+ @coverage = coverage.to_h { |gid| [gid, true] }.freeze
68
+ @first_classes = first_classes
69
+ @second_classes = second_classes
70
+ @matrix = matrix.each(&:freeze).freeze
71
+ freeze
72
+ end
73
+
74
+ def adjust(left, right)
75
+ return unless @coverage[left]
76
+
77
+ @matrix.dig(@first_classes.fetch(left, 0), @second_classes.fetch(right, 0)) || 0
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Fonts
5
+ # Pair kerning from the GPOS `kern` feature: PairPos lookups (formats 1
6
+ # and 2), directly or behind Extension lookups. Scripts and languages are
7
+ # not distinguished; every lookup any `kern` feature record names applies.
8
+ # Adjustments are the first glyph's X advance, in font units.
9
+ class Gpos
10
+ PAIR = 2
11
+ EXTENSION = 9
12
+
13
+ # nil when the font has no GPOS table or no `kern` feature.
14
+ def self.parse(ttf)
15
+ base = ttf.table_offset("GPOS")
16
+ return unless base && ttf.u16(base) == 1
17
+
18
+ offsets = feature_lookups(ttf, base, "kern")
19
+ new(offsets.map { |offset| lookup(ttf, offset) }) if offsets.any?
20
+ end
21
+
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
+ records = Array.new(ttf.u16(list)) { |i| list + 2 + (i * 6) }
27
+ indices = records.select { |record| ttf.data.byteslice(record, 4) == tag }.flat_map do |record|
28
+ feature = list + ttf.u16(record + 4)
29
+ Array.new(ttf.u16(feature + 2)) { |j| ttf.u16(feature + 4 + (j * 2)) }
30
+ end
31
+ lookups = base + ttf.u16(base + 8)
32
+ indices.uniq.sort.map { |i| lookups + ttf.u16(lookups + 2 + (i * 2)) }
33
+ end
34
+
35
+ # A lookup's PairPos subtables; other lookup types have none.
36
+ def self.lookup(ttf, offset)
37
+ type = ttf.u16(offset)
38
+ subtables = Array.new(ttf.u16(offset + 4)) { |i| offset + ttf.u16(offset + 6 + (i * 2)) }
39
+ subtables.filter_map do |subtable|
40
+ if type == PAIR
41
+ PairPos.read(ttf, subtable)
42
+ elsif type == EXTENSION && ttf.u16(subtable + 2) == PAIR
43
+ PairPos.read(ttf, subtable + ttf.u32(subtable + 4))
44
+ end
45
+ end.freeze
46
+ end
47
+ private_class_method :lookup
48
+
49
+ def initialize(lookups)
50
+ @lookups = lookups.freeze
51
+ freeze
52
+ end
53
+
54
+ # Within a lookup the first subtable that applies wins; lookups add up.
55
+ def adjust(left, right)
56
+ @lookups.sum do |subtables|
57
+ value = nil
58
+ subtables.find { |subtable| value = subtable.adjust(left, right) }
59
+ value || 0
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -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