gamba 2.0.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 (163) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +80 -0
  3. data/COPYING +2 -0
  4. data/GPLv2 +339 -0
  5. data/GPLv3 +674 -0
  6. data/LICENSE +65 -0
  7. data/LICENSE-prawn-svg +21 -0
  8. data/README.md +148 -0
  9. data/data/fonts/Courier-Bold.afm +342 -0
  10. data/data/fonts/Courier-BoldOblique.afm +342 -0
  11. data/data/fonts/Courier-Oblique.afm +342 -0
  12. data/data/fonts/Courier.afm +342 -0
  13. data/data/fonts/Helvetica-Bold.afm +2827 -0
  14. data/data/fonts/Helvetica-BoldOblique.afm +2827 -0
  15. data/data/fonts/Helvetica-Oblique.afm +3051 -0
  16. data/data/fonts/Helvetica.afm +3051 -0
  17. data/data/fonts/MustRead.html +19 -0
  18. data/data/fonts/Symbol.afm +213 -0
  19. data/data/fonts/Times-Bold.afm +2588 -0
  20. data/data/fonts/Times-BoldItalic.afm +2384 -0
  21. data/data/fonts/Times-Italic.afm +2667 -0
  22. data/data/fonts/Times-Roman.afm +2419 -0
  23. data/data/fonts/ZapfDingbats.afm +225 -0
  24. data/lib/gamba/annotations.rb +21 -0
  25. data/lib/gamba/combine.rb +307 -0
  26. data/lib/gamba/document/bounding_box.rb +193 -0
  27. data/lib/gamba/document/column_box.rb +91 -0
  28. data/lib/gamba/document/internals.rb +264 -0
  29. data/lib/gamba/document/span.rb +25 -0
  30. data/lib/gamba/document.rb +268 -0
  31. data/lib/gamba/encoding.rb +13 -0
  32. data/lib/gamba/errors.rb +39 -0
  33. data/lib/gamba/font.rb +228 -0
  34. data/lib/gamba/fonts/afm.rb +171 -0
  35. data/lib/gamba/fonts/otf.rb +50 -0
  36. data/lib/gamba/fonts/ttf.rb +413 -0
  37. data/lib/gamba/graphic_state.rb +84 -0
  38. data/lib/gamba/graphics/color.rb +174 -0
  39. data/lib/gamba/graphics/patterns.rb +100 -0
  40. data/lib/gamba/graphics/style.rb +159 -0
  41. data/lib/gamba/graphics.rb +212 -0
  42. data/lib/gamba/grid.rb +216 -0
  43. data/lib/gamba/images/image.rb +271 -0
  44. data/lib/gamba/images/png_decoder.rb +65 -0
  45. data/lib/gamba/images.rb +115 -0
  46. data/lib/gamba/measurement_extensions.rb +40 -0
  47. data/lib/gamba/measurements.rb +62 -0
  48. data/lib/gamba/outline.rb +148 -0
  49. data/lib/gamba/page.rb +125 -0
  50. data/lib/gamba/page_geometry.rb +27 -0
  51. data/lib/gamba/pdf/decryption.rb +165 -0
  52. data/lib/gamba/pdf/outline_node.rb +53 -0
  53. data/lib/gamba/pdf/reader.rb +616 -0
  54. data/lib/gamba/pdf/reference.rb +31 -0
  55. data/lib/gamba/pdf/writer.rb +105 -0
  56. data/lib/gamba/pdf.rb +87 -0
  57. data/lib/gamba/pdf_a/srgb_profile.rb +61 -0
  58. data/lib/gamba/pdf_a.rb +132 -0
  59. data/lib/gamba/repeater.rb +79 -0
  60. data/lib/gamba/security.rb +111 -0
  61. data/lib/gamba/stamp.rb +76 -0
  62. data/lib/gamba/table/cell/kinds.rb +246 -0
  63. data/lib/gamba/table/cell.rb +331 -0
  64. data/lib/gamba/table/cells.rb +208 -0
  65. data/lib/gamba/table.rb +298 -0
  66. data/lib/gamba/text/box.rb +16 -0
  67. data/lib/gamba/text/formatted/arranger.rb +217 -0
  68. data/lib/gamba/text/formatted/box.rb +312 -0
  69. data/lib/gamba/text/formatted/fragment.rb +205 -0
  70. data/lib/gamba/text/formatted/line_wrap.rb +205 -0
  71. data/lib/gamba/text/formatted/parser.rb +150 -0
  72. data/lib/gamba/text/formatted/wrap.rb +103 -0
  73. data/lib/gamba/text.rb +310 -0
  74. data/lib/gamba/transformation_stack.rb +43 -0
  75. data/lib/gamba/true_type/cff_subset.rb +157 -0
  76. data/lib/gamba/true_type/file.rb +372 -0
  77. data/lib/gamba/true_type/open_type_layout.rb +189 -0
  78. data/lib/gamba/true_type/simple_subsets.rb +108 -0
  79. data/lib/gamba/true_type/subset.rb +206 -0
  80. data/lib/gamba/version.rb +10 -0
  81. data/lib/gamba/view.rb +28 -0
  82. data/lib/gamba.rb +124 -0
  83. data/lib/pdf/core.rb +4 -0
  84. data/lib/prawn/measurement_extensions.rb +4 -0
  85. data/lib/prawn/svg/attributes/clip_path.rb +46 -0
  86. data/lib/prawn/svg/attributes/mask.rb +16 -0
  87. data/lib/prawn/svg/attributes/opacity.rb +30 -0
  88. data/lib/prawn/svg/attributes/space.rb +10 -0
  89. data/lib/prawn/svg/attributes/stroke.rb +50 -0
  90. data/lib/prawn/svg/attributes/transform.rb +10 -0
  91. data/lib/prawn/svg/attributes.rb +6 -0
  92. data/lib/prawn/svg/calculators/arc_to_bezier_curve.rb +57 -0
  93. data/lib/prawn/svg/calculators/aspect_ratio.rb +60 -0
  94. data/lib/prawn/svg/calculators/document_sizing.rb +103 -0
  95. data/lib/prawn/svg/calculators/path_length.rb +168 -0
  96. data/lib/prawn/svg/calculators/pixels.rb +57 -0
  97. data/lib/prawn/svg/color.rb +240 -0
  98. data/lib/prawn/svg/css/font_face_parser.rb +72 -0
  99. data/lib/prawn/svg/css/font_family_parser.rb +39 -0
  100. data/lib/prawn/svg/css/font_parser.rb +46 -0
  101. data/lib/prawn/svg/css/selector_parser.rb +180 -0
  102. data/lib/prawn/svg/css/stylesheets.rb +233 -0
  103. data/lib/prawn/svg/css/values_parser.rb +68 -0
  104. data/lib/prawn/svg/document.rb +169 -0
  105. data/lib/prawn/svg/elements/anchor.rb +10 -0
  106. data/lib/prawn/svg/elements/base.rb +315 -0
  107. data/lib/prawn/svg/elements/bbox_scaling.rb +78 -0
  108. data/lib/prawn/svg/elements/call_duplicator.rb +37 -0
  109. data/lib/prawn/svg/elements/circle.rb +19 -0
  110. data/lib/prawn/svg/elements/clip_path.rb +26 -0
  111. data/lib/prawn/svg/elements/container.rb +10 -0
  112. data/lib/prawn/svg/elements/direct_render_base.rb +28 -0
  113. data/lib/prawn/svg/elements/ellipse.rb +20 -0
  114. data/lib/prawn/svg/elements/gradient.rb +183 -0
  115. data/lib/prawn/svg/elements/ignored.rb +5 -0
  116. data/lib/prawn/svg/elements/image.rb +178 -0
  117. data/lib/prawn/svg/elements/line.rb +27 -0
  118. data/lib/prawn/svg/elements/marker.rb +72 -0
  119. data/lib/prawn/svg/elements/mask.rb +65 -0
  120. data/lib/prawn/svg/elements/path.rb +294 -0
  121. data/lib/prawn/svg/elements/pattern.rb +150 -0
  122. data/lib/prawn/svg/elements/polygon.rb +25 -0
  123. data/lib/prawn/svg/elements/polyline.rb +23 -0
  124. data/lib/prawn/svg/elements/rect.rb +64 -0
  125. data/lib/prawn/svg/elements/root.rb +22 -0
  126. data/lib/prawn/svg/elements/switch.rb +92 -0
  127. data/lib/prawn/svg/elements/text.rb +99 -0
  128. data/lib/prawn/svg/elements/text_component.rb +352 -0
  129. data/lib/prawn/svg/elements/text_node.rb +308 -0
  130. data/lib/prawn/svg/elements/text_path.rb +153 -0
  131. data/lib/prawn/svg/elements/use.rb +142 -0
  132. data/lib/prawn/svg/elements/viewport.rb +29 -0
  133. data/lib/prawn/svg/elements.rb +46 -0
  134. data/lib/prawn/svg/extension.rb +25 -0
  135. data/lib/prawn/svg/font.rb +20 -0
  136. data/lib/prawn/svg/font_metrics.rb +144 -0
  137. data/lib/prawn/svg/font_registry.rb +315 -0
  138. data/lib/prawn/svg/funciri.rb +14 -0
  139. data/lib/prawn/svg/gradient_renderer.rb +332 -0
  140. data/lib/prawn/svg/gradients.rb +53 -0
  141. data/lib/prawn/svg/interface.rb +72 -0
  142. data/lib/prawn/svg/length.rb +43 -0
  143. data/lib/prawn/svg/link_renderer.rb +40 -0
  144. data/lib/prawn/svg/loaders/data.rb +17 -0
  145. data/lib/prawn/svg/loaders/file.rb +111 -0
  146. data/lib/prawn/svg/loaders/web.rb +24 -0
  147. data/lib/prawn/svg/paint.rb +67 -0
  148. data/lib/prawn/svg/pathable.rb +153 -0
  149. data/lib/prawn/svg/pattern_renderer.rb +112 -0
  150. data/lib/prawn/svg/pdfmatrix.rb +39 -0
  151. data/lib/prawn/svg/percentage.rb +24 -0
  152. data/lib/prawn/svg/properties.rb +329 -0
  153. data/lib/prawn/svg/renderer.rb +249 -0
  154. data/lib/prawn/svg/state.rb +24 -0
  155. data/lib/prawn/svg/transform_parser.rb +78 -0
  156. data/lib/prawn/svg/ttc.rb +35 -0
  157. data/lib/prawn/svg/ttf.rb +95 -0
  158. data/lib/prawn/svg/url_loader.rb +46 -0
  159. data/lib/prawn/svg/version.rb +5 -0
  160. data/lib/prawn/table.rb +4 -0
  161. data/lib/prawn-svg.rb +52 -0
  162. data/lib/prawn.rb +3 -0
  163. metadata +263 -0
@@ -0,0 +1,372 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gamba
4
+ module TrueType
5
+ class Error < StandardError
6
+ end
7
+
8
+ # The tables of a TrueType font that layout and embedding need, read once from its bytes: from a .ttf, one
9
+ # font of a .ttc collection, or one sfnt resource of a Mac .dfont.
10
+ class File
11
+ TRUE_TYPE_VERSIONS = ["\x00\x01\x00\x00".b, "true".b].freeze
12
+ COLLECTION_TAG = "ttcf".b
13
+ UNICODE_CMAP_FORMATS = [4, 6, 10, 12].freeze
14
+ REQUIRED_TABLES = %w[head hhea maxp hmtx cmap].freeze
15
+ # The fewest bytes a table may hold (head and hhea: their fixed sizes, OS/2: Apple's 68-byte version 0): shorter,
16
+ # the fields read at fixed offsets would read as nil or past its end.
17
+ MINIMUM_TABLE_SIZES = {"head" => 54, "hhea" => 36, "maxp" => 6, "OS/2" => 68, "post" => 16}.freeze
18
+ # Reading past the data a malformed font declares raises these: they become a TrueType::Error.
19
+ MALFORMED_FONT_ERRORS = [ArgumentError, TypeError, RangeError, NoMethodError, FloatDomainError].freeze
20
+ MAXIMUM_CODE_POINT = 0x10FFFF
21
+
22
+ Header = Data.define(:units_per_em, :x_min, :y_min, :x_max, :y_max, :index_to_location_format)
23
+
24
+ FILES_BY_KEY = {}
25
+ FILES_LOCK = Mutex.new
26
+
27
+ # Parsed fonts are shared by every document of the process, until their file changes.
28
+ def self.open(path, index: 0, resource: false)
29
+ key = [::File.expand_path(path), index, resource, ::File.mtime(path)]
30
+ FILES_LOCK.synchronize { FILES_BY_KEY[key] ||= new(::File.binread(path), index:, resource:) }
31
+ end
32
+
33
+ attr_reader :bytes, :offset
34
+
35
+ def initialize(bytes, index: 0, resource: false)
36
+ @bytes = bytes.b.freeze
37
+ @offset = resource ? resource_offset(index) : 0
38
+ @offset = collection_offset(index) if @bytes.byteslice(@offset, 4) == COLLECTION_TAG
39
+ @location_by_tag = read_table_directory
40
+ validate!
41
+ rescue *MALFORMED_FONT_ERRORS => exception
42
+ raise Error, "Malformed font: #{exception.message}"
43
+ end
44
+
45
+ def table?(tag)
46
+ @location_by_tag.key?(tag)
47
+ end
48
+
49
+ def table(tag)
50
+ offset, length = @location_by_tag[tag]
51
+ @bytes.byteslice(offset, length) if offset
52
+ end
53
+
54
+ def outline_format
55
+ if table?("glyf") then :true_type
56
+ elsif table?("CFF ") then :open_type
57
+ else :unknown
58
+ end
59
+ end
60
+
61
+ def header
62
+ @header ||= begin
63
+ units_per_em, x_min, y_min, x_max, y_max = table("head").unpack("@18 n x16 s>4")
64
+ Header.new(units_per_em:, x_min:, y_min:, x_max:, y_max:, index_to_location_format: table("head").unpack1("@50 s>"))
65
+ end
66
+ end
67
+
68
+ def glyph_count
69
+ @glyph_count ||= table("maxp").unpack1("@4 n")
70
+ end
71
+
72
+ # The OS/2 typographic metric when present and not zero, the hhea one otherwise.
73
+ def ascent
74
+ @ascent ||= nonzero_os2(:ascent) || horizontal_header[0]
75
+ end
76
+
77
+ def descent
78
+ @descent ||= nonzero_os2(:descent) || horizontal_header[1]
79
+ end
80
+
81
+ def line_gap
82
+ @line_gap ||= nonzero_os2(:line_gap) || horizontal_header[2]
83
+ end
84
+
85
+ def os2
86
+ @os2 ||= begin
87
+ os2 = table("OS/2")
88
+ if os2
89
+ version = os2.unpack1("n")
90
+ family_class = os2.unpack1("@30 s>")
91
+ ascent, descent, line_gap = os2.unpack("@68 s>3")
92
+ x_height, cap_height = os2.unpack("@86 s>2") if version >= 2 && os2.bytesize >= 90
93
+ {family_class:, ascent:, descent:, line_gap:, x_height:, cap_height:}
94
+ else
95
+ {}
96
+ end
97
+ end
98
+ end
99
+
100
+ def italic_angle_fixed
101
+ table("post")&.unpack1("@4 N")
102
+ end
103
+
104
+ def fixed_pitch?
105
+ table("post")&.unpack1("@12 N").to_i != 0
106
+ end
107
+
108
+ def postscript_name
109
+ @postscript_name ||= name_string(6) || name_string(1) || "unnamed"
110
+ end
111
+
112
+ def font_name
113
+ name_string(4)
114
+ end
115
+
116
+ def family_name
117
+ name_string(1)
118
+ end
119
+
120
+ # Underline position and thickness from the post table, in font units.
121
+ def underline_metrics
122
+ table("post").unpack("@8 s>2")
123
+ end
124
+
125
+ def strikeout_position
126
+ table("OS/2").unpack1("@28 s>")
127
+ end
128
+
129
+ # The lowest and highest points of a glyph's outline, in font units, from its glyf header.
130
+ def glyph_vertical_bounds(glyph)
131
+ data = glyph_data(glyph)
132
+ return [0, 0] if data.bytesize < 10
133
+
134
+ data.unpack("@4 s> x2 s>")
135
+ end
136
+
137
+ # Advance widths in font units, one per glyph: glyphs past the last metric share its advance.
138
+ def advance_widths
139
+ @advance_widths ||= begin
140
+ metric_count = horizontal_header[3]
141
+ raise Error, "Malformed font: no horizontal metrics" unless metric_count.between?(1, table("hmtx").bytesize / 4)
142
+ widths = table("hmtx").unpack("n*").each_slice(2).first(metric_count).map!(&:first)
143
+ widths + ([widths.last] * (glyph_count - metric_count))
144
+ end
145
+ end
146
+
147
+ def glyph_by_code
148
+ @glyph_by_code ||= read_unicode_cmap || raise(Error, "No unicode cmap found in font")
149
+ end
150
+
151
+ def unicode_cmap?
152
+ !read_unicode_cmap.nil?
153
+ end
154
+
155
+ def kerning?
156
+ !kerning_by_glyph_key.empty?
157
+ end
158
+
159
+ # Kerning between two glyphs, in font units, or nil.
160
+ def kerning_offset(left, right)
161
+ kerning_by_glyph_key[(left << 16) | right]
162
+ end
163
+
164
+ def glyph_data(glyph)
165
+ offsets = glyph_offsets
166
+ start = offsets[glyph]
167
+ table_bytes = glyf_table
168
+ table_bytes.byteslice(start, offsets[glyph + 1] - start) || "".b
169
+ end
170
+
171
+ private
172
+
173
+ # Fonts may come from users: everything layout reads later is read and checked once, here.
174
+ def validate!
175
+ missing = REQUIRED_TABLES.reject { table?(_1) }
176
+ raise Error, "Malformed font: no #{missing.join(", ")} table" unless missing.empty?
177
+ raise Error, "Malformed font: no outlines" if outline_format == :unknown
178
+
179
+ @location_by_tag.each do |tag, (offset, length)|
180
+ raise Error, "Malformed font: the #{tag} table lies outside the file" if offset + length > @bytes.bytesize
181
+ raise Error, "Malformed font: its #{tag} table is too short" if length < MINIMUM_TABLE_SIZES.fetch(tag, 0)
182
+ end
183
+ raise Error, "Malformed font: no glyphs" unless glyph_count.positive?
184
+ # Glyph units scale to text space by 1000 / unitsPerEm: zero would make every metric infinite.
185
+ raise Error, "Malformed font: its unitsPerEm is zero" if header.units_per_em.zero?
186
+
187
+ advance_widths
188
+ read_unicode_cmap
189
+ kerning_by_glyph_key
190
+ validate_glyph_offsets! if outline_format == :true_type
191
+ end
192
+
193
+ def validate_glyph_offsets!
194
+ offsets = glyph_offsets
195
+ glyf_size = glyf_table.bytesize
196
+ valid = offsets.size == glyph_count + 1 && offsets.each_cons(2).all? { |start, finish| start <= finish } && offsets.last <= glyf_size
197
+ raise Error, "Malformed font: its glyph locations do not fit its glyph table" unless valid
198
+ end
199
+
200
+ def horizontal_header
201
+ @horizontal_header ||= begin
202
+ ascent, descent, line_gap = table("hhea").unpack("@4 s>3")
203
+ [ascent, descent, line_gap, table("hhea").unpack1("@34 n")]
204
+ end
205
+ end
206
+
207
+ def nonzero_os2(metric)
208
+ value = os2[metric]
209
+ value if value&.nonzero?
210
+ end
211
+
212
+ def glyf_table
213
+ @glyf_table ||= table("glyf")
214
+ end
215
+
216
+ def glyph_offsets
217
+ @glyph_offsets ||=
218
+ if header.index_to_location_format.zero?
219
+ table("loca").unpack("n#{glyph_count + 1}").map { _1 * 2 }
220
+ else
221
+ table("loca").unpack("N#{glyph_count + 1}")
222
+ end
223
+ end
224
+
225
+ # The first kerning subtable's values, keyed by `left << 16 | right` like its records.
226
+ def kerning_by_glyph_key
227
+ @kerning_by_glyph_key ||= begin
228
+ kern = table("kern")
229
+ subtable = nil
230
+ if kern
231
+ version = kern.unpack1("n")
232
+ if version == 1
233
+ length, coverage = kern.unpack("@8 N n")
234
+ subtable = kern.byteslice(16, length - 8) if (coverage & 0xFF).zero?
235
+ elsif kern.unpack1("@2 n").positive?
236
+ coverage = kern.unpack1("@8 n")
237
+ subtable = kern.byteslice(10..) if (coverage >> 8).zero?
238
+ end
239
+ end
240
+ subtable ? format0_kerning_by_glyph_key(subtable) : {}
241
+ end
242
+ end
243
+
244
+ # A format 0 record is a uint32 glyph pair key then an int16 value: `N s>` reads both as they are stored.
245
+ def format0_kerning_by_glyph_key(subtable)
246
+ pair_count = [subtable.unpack1("n"), (subtable.bytesize - 8) / 6].min
247
+ Hash[*subtable.unpack("@8 #{"N s>" * pair_count}")]
248
+ end
249
+
250
+ def read_table_directory
251
+ version, table_count = @bytes.unpack("@#{@offset} a4 n")
252
+ raise Error, "Not a TrueType font" unless TRUE_TYPE_VERSIONS.include?(version) || version == "OTTO"
253
+
254
+ Array.new(table_count) do |index|
255
+ tag, _checksum, offset, length = @bytes.unpack("@#{@offset + 12 + (index * 16)} a4 N3")
256
+ [tag, [offset + (@offset_base || 0), length]]
257
+ end.to_h
258
+ end
259
+
260
+ def collection_offset(index)
261
+ font_count = @bytes.unpack1("@8 N")
262
+ raise Error, "Font index #{index} out of a collection of #{font_count}" if index >= font_count
263
+
264
+ @bytes.unpack1("@#{12 + (index * 4)} N")
265
+ end
266
+
267
+ # A Mac resource fork: table offsets in its sfnt resources count from the resource's own start.
268
+ def resource_offset(index)
269
+ data_offset, map_offset = @bytes.unpack("N2")
270
+ type_list_offset, name_list_offset = @bytes.unpack("@#{map_offset + 24} n2")
271
+ type_list = map_offset + type_list_offset
272
+ type_count = @bytes.unpack1("@#{type_list} n") + 1
273
+ type_count.times do |type_index|
274
+ type, count, reference_list_offset = @bytes.unpack("@#{type_list + 2 + (type_index * 8)} a4 n2")
275
+ next unless type == "sfnt"
276
+
277
+ references = Array.new(count + 1) do |reference_index|
278
+ reference = type_list + reference_list_offset + (reference_index * 12)
279
+ _id, name_offset, attributes_and_offset = @bytes.unpack("@#{reference} n2 N")
280
+ name = (@bytes.byteslice(map_offset + name_list_offset + name_offset + 1, @bytes.getbyte(map_offset + name_list_offset + name_offset)) unless name_offset == 0xFFFF)
281
+ [name, data_offset + (attributes_and_offset & 0xFFFFFF) + 4]
282
+ end
283
+ chosen = index.is_a?(String) ? references.assoc(index) : references[index]
284
+ raise Error, "No font #{index.inspect} in this resource file" unless chosen
285
+
286
+ @offset_base = chosen[1]
287
+ return chosen[1]
288
+ end
289
+ raise Error, "No sfnt resource in this resource file"
290
+ end
291
+
292
+ def read_unicode_cmap
293
+ return @unicode_cmap if defined?(@unicode_cmap)
294
+
295
+ cmap = table("cmap")
296
+ subtable_count = cmap.unpack1("@2 n")
297
+ candidates = Array.new(subtable_count) do |index|
298
+ platform, encoding, offset = cmap.unpack("@#{4 + (index * 8)} n2 N")
299
+ format = cmap.unpack1("@#{offset} n")
300
+ next unless UNICODE_CMAP_FORMATS.include?(format)
301
+ windows_unicode = platform == 3 && [1, 10].include?(encoding)
302
+ next unless windows_unicode || platform.zero?
303
+
304
+ [format, offset]
305
+ end.compact
306
+ format, offset = candidates.max_by(&:first)
307
+ @unicode_cmap = format && parse_cmap(cmap, format, offset)
308
+ end
309
+
310
+ def parse_cmap(cmap, format, offset)
311
+ glyph_by_code = {}
312
+ case format
313
+ when 4
314
+ segment_count = cmap.unpack1("@#{offset + 6} n") / 2
315
+ ends = cmap.unpack("@#{offset + 14} n#{segment_count}")
316
+ starts_offset = offset + 16 + (segment_count * 2)
317
+ starts = cmap.unpack("@#{starts_offset} n#{segment_count}")
318
+ deltas = cmap.unpack("@#{starts_offset + (segment_count * 2)} n#{segment_count}")
319
+ range_offsets_offset = starts_offset + (segment_count * 4)
320
+ range_offsets = cmap.unpack("@#{range_offsets_offset} n#{segment_count}")
321
+ segment_count.times do |segment|
322
+ (starts[segment]..ends[segment]).each do |code|
323
+ glyph =
324
+ if range_offsets[segment].zero?
325
+ (code + deltas[segment]) & 0xFFFF
326
+ else
327
+ glyph_offset = range_offsets_offset + (segment * 2) + range_offsets[segment] + ((code - starts[segment]) * 2)
328
+ raw = cmap.unpack1("@#{glyph_offset} n").to_i
329
+ raw.zero? ? 0 : (raw + deltas[segment]) & 0xFFFF
330
+ end
331
+ glyph_by_code[code] = glyph
332
+ end
333
+ end
334
+ when 6
335
+ first_code, count = cmap.unpack("@#{offset + 6} n2")
336
+ count = [count, (cmap.bytesize - offset - 10) / 2].min
337
+ cmap.unpack("@#{offset + 10} n#{count}").each_with_index { |glyph, index| glyph_by_code[first_code + index] = glyph }
338
+ when 10
339
+ first_code, count = cmap.unpack("@#{offset + 12} N2")
340
+ count = [count, (cmap.bytesize - offset - 20) / 2].min
341
+ cmap.unpack("@#{offset + 20} n#{count}").each_with_index { |glyph, index| glyph_by_code[first_code + index] = glyph }
342
+ when 12
343
+ group_count = [cmap.unpack1("@#{offset + 12} N"), (cmap.bytesize - offset - 16) / 12].min
344
+ cmap.unpack("@#{offset + 16} N#{group_count * 3}").each_slice(3) do |start_code, end_code, start_glyph|
345
+ (start_code..[end_code, MAXIMUM_CODE_POINT].min).each { |code| glyph_by_code[code] = start_glyph + (code - start_code) }
346
+ end
347
+ end
348
+ glyph_by_code.each { |code, glyph| glyph_by_code[code] = 0 if glyph >= glyph_count }
349
+ glyph_by_code.default = 0
350
+ glyph_by_code.freeze
351
+ end
352
+
353
+ def name_string(name_id)
354
+ name = table("name")
355
+ return unless name
356
+
357
+ count, strings_offset = name.unpack("@2 n2")
358
+ records = Array.new(count) { |record_index| name.unpack("@#{6 + (record_index * 12)} n6") }
359
+ record = records.find { |platform, _, _, id| id == name_id && platform == 3 } ||
360
+ records.find { |platform, _, _, id| id == name_id && platform == 1 } ||
361
+ records.find { |_, _, _, id| id == name_id }
362
+ return unless record
363
+
364
+ platform, _encoding, _language, _id, length, offset = record
365
+ raw = name.byteslice(strings_offset + offset, length)
366
+ (platform == 1) ? raw.force_encoding(::Encoding::MacRoman).encode(::Encoding::UTF_8) : raw.force_encoding(::Encoding::UTF_16BE).encode(::Encoding::UTF_8)
367
+ rescue ::Encoding::UndefinedConversionError, ::Encoding::InvalidByteSequenceError
368
+ nil
369
+ end
370
+ end
371
+ end
372
+ end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gamba
4
+ module TrueType
5
+ # The parts of OpenType's GSUB and GPOS tables (OpenType specification, "OpenType Layout") Gamba applies on
6
+ # request: ligatures (GSUB lookup type 4) and pair kerning (GPOS lookup type 2), for the font's Latin script.
7
+ class OpenTypeLayout
8
+ LIGATURE_SUBSTITUTION = 4
9
+ GSUB_EXTENSION = 7
10
+ PAIR_ADJUSTMENT = 2
11
+ GPOS_EXTENSION = 9
12
+ X_ADVANCE = 0x0004
13
+ PREFERRED_SCRIPTS = ["latn", "DFLT"].freeze
14
+
15
+ def initialize(file, features)
16
+ @file = file
17
+ @features = features.map(&:to_s)
18
+ @ligatures_by_first_glyph = read_ligatures
19
+ @pair_lookups = read_pair_lookups
20
+ end
21
+
22
+ # Glyphs and the characters each stands for, ligatures applied: [[glyph, [code, …]], …].
23
+ def shape(codes)
24
+ clusters = codes.map { |code| [@file.glyph_by_code[code], [code]] }
25
+ return clusters if @ligatures_by_first_glyph.empty?
26
+
27
+ shaped = []
28
+ index = 0
29
+ while index < clusters.size
30
+ ligature = (@ligatures_by_first_glyph[clusters[index][0]] || []).find do |components, _|
31
+ components.each_with_index.all? { |component, offset| clusters[index + 1 + offset]&.first == component }
32
+ end
33
+ if ligature
34
+ components, glyph = ligature
35
+ shaped << [glyph, clusters[index, components.size + 1].flat_map(&:last)]
36
+ index += components.size + 1
37
+ else
38
+ shaped << clusters[index]
39
+ index += 1
40
+ end
41
+ end
42
+ shaped
43
+ end
44
+
45
+ # The horizontal advance adjustment between two glyphs, in font units, or nil.
46
+ def kerning(left, right)
47
+ @pair_lookups.each do |lookup|
48
+ adjustment = lookup.call(left, right)
49
+ return adjustment if adjustment
50
+ end
51
+ nil
52
+ end
53
+
54
+ private
55
+
56
+ def read_ligatures
57
+ table = @file.table("GSUB")
58
+ return {} unless table && @features.include?("liga")
59
+
60
+ ligatures = Hash.new { |hash, glyph| hash[glyph] = [] }
61
+ lookups(table, "liga", LIGATURE_SUBSTITUTION, GSUB_EXTENSION).each do |subtable|
62
+ next unless table.unpack1("@#{subtable} n") == 1
63
+
64
+ coverage = coverage_glyphs(table, subtable + table.unpack1("@#{subtable + 2} n"))
65
+ set_count = table.unpack1("@#{subtable + 4} n")
66
+ table.unpack("@#{subtable + 6} n#{set_count}").each_with_index do |set_offset, index|
67
+ set = subtable + set_offset
68
+ table.unpack("@#{set + 2} n#{table.unpack1("@#{set} n")}").each do |ligature_offset|
69
+ ligature = set + ligature_offset
70
+ glyph, component_count = table.unpack("@#{ligature} n2")
71
+ ligatures[coverage[index]] << [table.unpack("@#{ligature + 4} n#{component_count - 1}"), glyph]
72
+ end
73
+ end
74
+ end
75
+ ligatures.transform_values { |list| list.sort_by { |components, _glyph| -components.size } }
76
+ end
77
+
78
+ def read_pair_lookups
79
+ table = @file.table("GPOS")
80
+ return [] unless table && @features.include?("kern")
81
+
82
+ lookups(table, "kern", PAIR_ADJUSTMENT, GPOS_EXTENSION).filter_map { pair_lookup(table, _1) }
83
+ end
84
+
85
+ def pair_lookup(table, subtable)
86
+ format, coverage_offset, first_format, second_format = table.unpack("@#{subtable} n4")
87
+ return unless (first_format & X_ADVANCE).nonzero?
88
+
89
+ coverage = coverage_index(table, subtable + coverage_offset)
90
+ advance_position = 2 * (first_format & (X_ADVANCE - 1)).digits(2).count(1)
91
+ record_size = 2 * (first_format.digits(2).count(1) + second_format.digits(2).count(1))
92
+ case format
93
+ when 1
94
+ set_offsets = table.unpack("@#{subtable + 10} n#{table.unpack1("@#{subtable + 8} n")}")
95
+ lambda do |left, right|
96
+ index = coverage[left]
97
+ next unless index
98
+
99
+ set = subtable + set_offsets[index]
100
+ count = table.unpack1("@#{set} n")
101
+ record = (0...count).find { |candidate| table.unpack1("@#{set + 2 + (candidate * (record_size + 2))} n") == right }
102
+ record && table.unpack1("@#{set + 4 + (record * (record_size + 2)) + advance_position} s>")
103
+ end
104
+ when 2
105
+ first_classes = class_definition(table, subtable + table.unpack1("@#{subtable + 8} n"))
106
+ second_classes = class_definition(table, subtable + table.unpack1("@#{subtable + 10} n"))
107
+ _first_count, second_count = table.unpack("@#{subtable + 12} n2")
108
+ lambda do |left, right|
109
+ next unless coverage[left]
110
+
111
+ record = (first_classes[left] * second_count) + second_classes[right]
112
+ table.unpack1("@#{subtable + 16 + (record * record_size) + advance_position} s>").then { |adjustment| adjustment.zero? ? nil : adjustment }
113
+ end
114
+ end
115
+ end
116
+
117
+ # Subtables of the lookups a feature uses for the preferred script's default language, of the given type.
118
+ def lookups(table, feature_tag, type, extension_type)
119
+ script_list, feature_list, lookup_list = table.unpack("@4 n3")
120
+ feature_indices = default_language_features(table, script_list)
121
+ feature_count = table.unpack1("@#{feature_list} n")
122
+ lookup_indices = feature_indices.flat_map do |feature_index|
123
+ next [] if feature_index >= feature_count
124
+
125
+ tag, offset = table.unpack("@#{feature_list + 2 + (feature_index * 6)} a4 n")
126
+ next [] unless tag == feature_tag
127
+
128
+ feature = feature_list + offset
129
+ table.unpack("@#{feature + 4} n#{table.unpack1("@#{feature + 2} n")}")
130
+ end
131
+ lookup_indices.uniq.flat_map do |lookup_index|
132
+ lookup = lookup_list + table.unpack1("@#{lookup_list + 2 + (lookup_index * 2)} n")
133
+ lookup_type, _flags, subtable_count = table.unpack("@#{lookup} n3")
134
+ table.unpack("@#{lookup + 6} n#{subtable_count}").filter_map do |offset|
135
+ subtable = lookup + offset
136
+ if lookup_type == extension_type
137
+ actual_type, extension_offset = table.unpack("@#{subtable + 2} n N")
138
+ subtable + extension_offset if actual_type == type
139
+ elsif lookup_type == type
140
+ subtable
141
+ end
142
+ end
143
+ end
144
+ end
145
+
146
+ def default_language_features(table, script_list)
147
+ script_count = table.unpack1("@#{script_list} n")
148
+ offset_by_tag = Array.new(script_count) { |script_index| table.unpack("@#{script_list + 2 + (script_index * 6)} a4 n") }.to_h
149
+ script_offset = offset_by_tag.values_at(*PREFERRED_SCRIPTS).find { !_1.nil? } || offset_by_tag.values.first
150
+ return [] unless script_offset
151
+
152
+ script = script_list + script_offset
153
+ language_offset = table.unpack1("@#{script} n")
154
+ return [] if language_offset.zero?
155
+
156
+ language = script + language_offset
157
+ table.unpack("@#{language + 6} n#{table.unpack1("@#{language + 4} n")}")
158
+ end
159
+
160
+ def coverage_glyphs(table, offset)
161
+ coverage_index(table, offset).sort_by(&:last).map(&:first)
162
+ end
163
+
164
+ def coverage_index(table, offset)
165
+ format, count = table.unpack("@#{offset} n2")
166
+ if format == 1
167
+ table.unpack("@#{offset + 4} n#{count}").each_with_index.to_h
168
+ else
169
+ table.unpack("@#{offset + 4} n#{count * 3}").each_slice(3).each_with_object({}) do |(first, last, start_index), index|
170
+ (first..last).each { |glyph| index[glyph] = start_index + (glyph - first) }
171
+ end
172
+ end
173
+ end
174
+
175
+ def class_definition(table, offset)
176
+ classes = Hash.new(0)
177
+ format = table.unpack1("@#{offset} n")
178
+ if format == 1
179
+ start, count = table.unpack("@#{offset + 2} n2")
180
+ table.unpack("@#{offset + 6} n#{count}").each_with_index { |glyph_class, index| classes[start + index] = glyph_class }
181
+ else
182
+ count = table.unpack1("@#{offset + 2} n")
183
+ table.unpack("@#{offset + 4} n#{count * 3}").each_slice(3) { |first, last, value| (first..last).each { classes[_1] = value } }
184
+ end
185
+ classes
186
+ end
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gamba
4
+ module TrueType
5
+ # Characters of a TrueType font split into simple fonts of single-byte codes, laid out as Prawn lays them
6
+ # (through TTFunk) so every reader renders them the same way: the first subset codes characters in Mac Roman,
7
+ # the next ones take 223 characters each, from code 33. Every subset holds the space at 32, for word spacing: like
8
+ # TTFunk, a space stays in the subset of the character before it.
9
+ class SimpleSubsets
10
+ Subset = Struct.new(:code_by_cluster, :glyph_by_code, :next_code) do
11
+ def full?
12
+ next_code > 255
13
+ end
14
+ end
15
+
16
+ SPACE = 32
17
+ FIRST_FREE_CODE = 33
18
+
19
+ def initialize(glyph_for_cluster)
20
+ @glyph_for_cluster = glyph_for_cluster
21
+ @location_by_cluster = {}
22
+ @subsets = [Subset.new({SPACE => SPACE}, {SPACE => glyph_for_cluster.call([SPACE])}, nil)]
23
+ end
24
+
25
+ def size
26
+ @subsets.size
27
+ end
28
+
29
+ def [](index)
30
+ @subsets[index]
31
+ end
32
+
33
+ # [[subset index, bytes], …] for clusters of characters, each cluster [glyph, codes].
34
+ def encode(clusters)
35
+ parts = []
36
+ clusters.each do |glyph, codes|
37
+ if !parts.empty? && (codes == [SPACE] || codes == SPACE) && glyph == @subsets[0].glyph_by_code[SPACE]
38
+ parts.last[1] << SPACE
39
+ else
40
+ append(parts, *(@location_by_cluster[codes] ||= code_for([glyph, codes])))
41
+ end
42
+ end
43
+ parts
44
+ end
45
+
46
+ # The same for single characters, by code point: the common case, where a character already given its code
47
+ # costs a single hash access, and its glyph is only looked up the first time.
48
+ def encode_codepoints(codepoints, glyph_by_code)
49
+ parts = []
50
+ codepoints.each do |codepoint|
51
+ if codepoint == SPACE && !parts.empty?
52
+ parts.last[1] << SPACE
53
+ else
54
+ index, code = @location_by_cluster[codepoint] ||= code_for([glyph_by_code[codepoint], codepoint])
55
+ append(parts, index, code)
56
+ end
57
+ end
58
+ parts
59
+ end
60
+
61
+ private
62
+
63
+ # Consecutive characters of one subset share a part, as in Prawn's TJ arrays.
64
+ def append(parts, index, code)
65
+ last = parts.last
66
+ if last.nil? || last[0] != index
67
+ parts << [index, code.chr(::Encoding::BINARY)]
68
+ else
69
+ last[1] << code
70
+ end
71
+ end
72
+
73
+ def code_for(cluster)
74
+ glyph, codes = cluster
75
+ key = codes
76
+ mac_roman = mac_roman_code(codes)
77
+ if mac_roman
78
+ @subsets[0].code_by_cluster[key] ||= mac_roman.tap { @subsets[0].glyph_by_code[mac_roman] = glyph }
79
+ return [0, mac_roman]
80
+ end
81
+
82
+ @subsets.each_with_index.drop(1).each do |subset, index|
83
+ code = subset.code_by_cluster[key]
84
+ return [index, code] if code
85
+ end
86
+ subset_index = @subsets.rindex { |subset| subset.next_code && !subset.full? } || begin
87
+ @subsets << Subset.new({[SPACE] => SPACE, SPACE => SPACE}, {SPACE => @glyph_for_cluster.call([SPACE])}, FIRST_FREE_CODE)
88
+ @subsets.size - 1
89
+ end
90
+ subset = @subsets[subset_index]
91
+ code = subset.next_code
92
+ subset.code_by_cluster[key] = code
93
+ subset.glyph_by_code[code] = glyph
94
+ subset.next_code += 1
95
+ [subset_index, code]
96
+ end
97
+
98
+ def mac_roman_code(codes)
99
+ codes = Array(codes)
100
+ return unless codes.size == 1
101
+
102
+ [codes.first].pack("U").encode(::Encoding::MacRoman).getbyte(0)
103
+ rescue ::Encoding::UndefinedConversionError
104
+ nil
105
+ end
106
+ end
107
+ end
108
+ end