prawnx 0.1.1

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 (160) hide show
  1. checksums.yaml +7 -0
  2. data/COPYING +2 -0
  3. data/GPLv2 +339 -0
  4. data/GPLv3 +674 -0
  5. data/LICENSE +65 -0
  6. data/LICENSE-prawn-svg +21 -0
  7. data/README.md +107 -0
  8. data/data/fonts/Courier-Bold.afm +342 -0
  9. data/data/fonts/Courier-BoldOblique.afm +342 -0
  10. data/data/fonts/Courier-Oblique.afm +342 -0
  11. data/data/fonts/Courier.afm +342 -0
  12. data/data/fonts/Helvetica-Bold.afm +2827 -0
  13. data/data/fonts/Helvetica-BoldOblique.afm +2827 -0
  14. data/data/fonts/Helvetica-Oblique.afm +3051 -0
  15. data/data/fonts/Helvetica.afm +3051 -0
  16. data/data/fonts/MustRead.html +19 -0
  17. data/data/fonts/Symbol.afm +213 -0
  18. data/data/fonts/Times-Bold.afm +2588 -0
  19. data/data/fonts/Times-BoldItalic.afm +2384 -0
  20. data/data/fonts/Times-Italic.afm +2667 -0
  21. data/data/fonts/Times-Roman.afm +2419 -0
  22. data/data/fonts/ZapfDingbats.afm +225 -0
  23. data/lib/pdf/core.rb +4 -0
  24. data/lib/prawn/measurement_extensions.rb +4 -0
  25. data/lib/prawn/svg/attributes/clip_path.rb +46 -0
  26. data/lib/prawn/svg/attributes/mask.rb +16 -0
  27. data/lib/prawn/svg/attributes/opacity.rb +30 -0
  28. data/lib/prawn/svg/attributes/space.rb +10 -0
  29. data/lib/prawn/svg/attributes/stroke.rb +50 -0
  30. data/lib/prawn/svg/attributes/transform.rb +10 -0
  31. data/lib/prawn/svg/attributes.rb +6 -0
  32. data/lib/prawn/svg/calculators/arc_to_bezier_curve.rb +57 -0
  33. data/lib/prawn/svg/calculators/aspect_ratio.rb +60 -0
  34. data/lib/prawn/svg/calculators/document_sizing.rb +103 -0
  35. data/lib/prawn/svg/calculators/path_length.rb +168 -0
  36. data/lib/prawn/svg/calculators/pixels.rb +57 -0
  37. data/lib/prawn/svg/color.rb +240 -0
  38. data/lib/prawn/svg/css/font_face_parser.rb +72 -0
  39. data/lib/prawn/svg/css/font_family_parser.rb +39 -0
  40. data/lib/prawn/svg/css/font_parser.rb +46 -0
  41. data/lib/prawn/svg/css/selector_parser.rb +178 -0
  42. data/lib/prawn/svg/css/stylesheets.rb +233 -0
  43. data/lib/prawn/svg/css/values_parser.rb +68 -0
  44. data/lib/prawn/svg/document.rb +170 -0
  45. data/lib/prawn/svg/elements/anchor.rb +10 -0
  46. data/lib/prawn/svg/elements/base.rb +312 -0
  47. data/lib/prawn/svg/elements/bbox_scaling.rb +78 -0
  48. data/lib/prawn/svg/elements/call_duplicator.rb +37 -0
  49. data/lib/prawn/svg/elements/circle.rb +25 -0
  50. data/lib/prawn/svg/elements/clip_path.rb +26 -0
  51. data/lib/prawn/svg/elements/container.rb +10 -0
  52. data/lib/prawn/svg/elements/direct_render_base.rb +27 -0
  53. data/lib/prawn/svg/elements/ellipse.rb +22 -0
  54. data/lib/prawn/svg/elements/gradient.rb +184 -0
  55. data/lib/prawn/svg/elements/ignored.rb +5 -0
  56. data/lib/prawn/svg/elements/image.rb +177 -0
  57. data/lib/prawn/svg/elements/line.rb +27 -0
  58. data/lib/prawn/svg/elements/marker.rb +72 -0
  59. data/lib/prawn/svg/elements/mask.rb +65 -0
  60. data/lib/prawn/svg/elements/path.rb +294 -0
  61. data/lib/prawn/svg/elements/pattern.rb +150 -0
  62. data/lib/prawn/svg/elements/polygon.rb +25 -0
  63. data/lib/prawn/svg/elements/polyline.rb +23 -0
  64. data/lib/prawn/svg/elements/rect.rb +64 -0
  65. data/lib/prawn/svg/elements/root.rb +22 -0
  66. data/lib/prawn/svg/elements/switch.rb +92 -0
  67. data/lib/prawn/svg/elements/text.rb +99 -0
  68. data/lib/prawn/svg/elements/text_component.rb +346 -0
  69. data/lib/prawn/svg/elements/text_node.rb +308 -0
  70. data/lib/prawn/svg/elements/text_path.rb +153 -0
  71. data/lib/prawn/svg/elements/use.rb +142 -0
  72. data/lib/prawn/svg/elements/viewport.rb +29 -0
  73. data/lib/prawn/svg/elements.rb +46 -0
  74. data/lib/prawn/svg/extension.rb +25 -0
  75. data/lib/prawn/svg/font.rb +20 -0
  76. data/lib/prawn/svg/font_metrics.rb +144 -0
  77. data/lib/prawn/svg/font_registry.rb +315 -0
  78. data/lib/prawn/svg/funciri.rb +14 -0
  79. data/lib/prawn/svg/gradient_renderer.rb +332 -0
  80. data/lib/prawn/svg/gradients.rb +53 -0
  81. data/lib/prawn/svg/interface.rb +72 -0
  82. data/lib/prawn/svg/length.rb +43 -0
  83. data/lib/prawn/svg/link_renderer.rb +40 -0
  84. data/lib/prawn/svg/loaders/data.rb +17 -0
  85. data/lib/prawn/svg/loaders/file.rb +111 -0
  86. data/lib/prawn/svg/loaders/web.rb +24 -0
  87. data/lib/prawn/svg/paint.rb +67 -0
  88. data/lib/prawn/svg/pathable.rb +153 -0
  89. data/lib/prawn/svg/pattern_renderer.rb +112 -0
  90. data/lib/prawn/svg/pdfmatrix.rb +37 -0
  91. data/lib/prawn/svg/percentage.rb +24 -0
  92. data/lib/prawn/svg/properties.rb +329 -0
  93. data/lib/prawn/svg/renderer.rb +249 -0
  94. data/lib/prawn/svg/state.rb +24 -0
  95. data/lib/prawn/svg/transform_parser.rb +78 -0
  96. data/lib/prawn/svg/ttc.rb +35 -0
  97. data/lib/prawn/svg/ttf.rb +95 -0
  98. data/lib/prawn/svg/url_loader.rb +46 -0
  99. data/lib/prawn/svg/version.rb +5 -0
  100. data/lib/prawn/table.rb +4 -0
  101. data/lib/prawn-svg.rb +53 -0
  102. data/lib/prawn.rb +10 -0
  103. data/lib/prawnx/annotations.rb +21 -0
  104. data/lib/prawnx/document/bounding_box.rb +193 -0
  105. data/lib/prawnx/document/column_box.rb +91 -0
  106. data/lib/prawnx/document/internals.rb +144 -0
  107. data/lib/prawnx/document/span.rb +25 -0
  108. data/lib/prawnx/document.rb +253 -0
  109. data/lib/prawnx/encoding.rb +29 -0
  110. data/lib/prawnx/errors.rb +37 -0
  111. data/lib/prawnx/font.rb +214 -0
  112. data/lib/prawnx/fonts/afm.rb +171 -0
  113. data/lib/prawnx/fonts/otf.rb +50 -0
  114. data/lib/prawnx/fonts/ttf.rb +410 -0
  115. data/lib/prawnx/graphic_state.rb +41 -0
  116. data/lib/prawnx/graphics/color.rb +148 -0
  117. data/lib/prawnx/graphics/patterns.rb +100 -0
  118. data/lib/prawnx/graphics/style.rb +171 -0
  119. data/lib/prawnx/graphics.rb +208 -0
  120. data/lib/prawnx/grid.rb +216 -0
  121. data/lib/prawnx/images/image.rb +244 -0
  122. data/lib/prawnx/images/png_decoder.rb +65 -0
  123. data/lib/prawnx/images.rb +115 -0
  124. data/lib/prawnx/measurement_extensions.rb +40 -0
  125. data/lib/prawnx/measurements.rb +62 -0
  126. data/lib/prawnx/outline.rb +146 -0
  127. data/lib/prawnx/page.rb +125 -0
  128. data/lib/prawnx/page_geometry.rb +27 -0
  129. data/lib/prawnx/pdf/outline_node.rb +53 -0
  130. data/lib/prawnx/pdf/reference.rb +31 -0
  131. data/lib/prawnx/pdf/writer.rb +105 -0
  132. data/lib/prawnx/pdf.rb +66 -0
  133. data/lib/prawnx/pdf_a/srgb_profile.rb +61 -0
  134. data/lib/prawnx/pdf_a.rb +130 -0
  135. data/lib/prawnx/pdf_core.rb +25 -0
  136. data/lib/prawnx/repeater.rb +79 -0
  137. data/lib/prawnx/security.rb +108 -0
  138. data/lib/prawnx/stamp.rb +74 -0
  139. data/lib/prawnx/table/cell/kinds.rb +246 -0
  140. data/lib/prawnx/table/cell.rb +331 -0
  141. data/lib/prawnx/table/cells.rb +208 -0
  142. data/lib/prawnx/table.rb +296 -0
  143. data/lib/prawnx/text/box.rb +16 -0
  144. data/lib/prawnx/text/formatted/arranger.rb +217 -0
  145. data/lib/prawnx/text/formatted/box.rb +312 -0
  146. data/lib/prawnx/text/formatted/fragment.rb +205 -0
  147. data/lib/prawnx/text/formatted/line_wrap.rb +204 -0
  148. data/lib/prawnx/text/formatted/parser.rb +149 -0
  149. data/lib/prawnx/text/formatted/wrap.rb +103 -0
  150. data/lib/prawnx/text.rb +281 -0
  151. data/lib/prawnx/transformation_stack.rb +43 -0
  152. data/lib/prawnx/true_type/cff_subset.rb +157 -0
  153. data/lib/prawnx/true_type/file.rb +371 -0
  154. data/lib/prawnx/true_type/open_type_layout.rb +189 -0
  155. data/lib/prawnx/true_type/simple_subsets.rb +94 -0
  156. data/lib/prawnx/true_type/subset.rb +187 -0
  157. data/lib/prawnx/version.rb +7 -0
  158. data/lib/prawnx/view.rb +28 -0
  159. data/lib/prawnx.rb +102 -0
  160. metadata +251 -0
@@ -0,0 +1,371 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PrawnX
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
+ # Reading past the data a malformed font declares raises these: they become a TrueType::Error.
16
+ MALFORMED_FONT_ERRORS = [ArgumentError, TypeError, RangeError, NoMethodError, FloatDomainError].freeze
17
+ MAXIMUM_CODE_POINT = 0x10FFFF
18
+
19
+ Header = Data.define(:units_per_em, :x_min, :y_min, :x_max, :y_max, :index_to_location_format)
20
+
21
+ FILES_BY_KEY = {}
22
+ FILES_LOCK = Mutex.new
23
+
24
+ # Parsed fonts are shared by every document of the process, until their file changes.
25
+ def self.open(path, index: 0, resource: false)
26
+ key = [::File.expand_path(path), index, resource, ::File.mtime(path)]
27
+ FILES_LOCK.synchronize { FILES_BY_KEY[key] ||= new(::File.binread(path), index:, resource:) }
28
+ end
29
+
30
+ attr_reader :bytes, :offset
31
+
32
+ def initialize(bytes, index: 0, resource: false)
33
+ @bytes = bytes.b.freeze
34
+ @offset = resource ? resource_offset(index) : 0
35
+ @offset = collection_offset(index) if @bytes.byteslice(@offset, 4) == COLLECTION_TAG
36
+ @location_by_tag = read_table_directory
37
+ validate!
38
+ rescue *MALFORMED_FONT_ERRORS => exception
39
+ raise Error, "Malformed font: #{exception.message}"
40
+ end
41
+
42
+ def table?(tag)
43
+ @location_by_tag.key?(tag)
44
+ end
45
+
46
+ def table(tag)
47
+ offset, length = @location_by_tag[tag]
48
+ @bytes.byteslice(offset, length) if offset
49
+ end
50
+
51
+ def outline_format
52
+ if table?("glyf") then :true_type
53
+ elsif table?("CFF ") then :open_type
54
+ else :unknown
55
+ end
56
+ end
57
+
58
+ def header
59
+ @header ||= begin
60
+ units_per_em, x_min, y_min, x_max, y_max = table("head").unpack("@18 n x16 s>4")
61
+ Header.new(units_per_em:, x_min:, y_min:, x_max:, y_max:, index_to_location_format: table("head").unpack1("@50 s>"))
62
+ end
63
+ end
64
+
65
+ def glyph_count
66
+ @glyph_count ||= table("maxp").unpack1("@4 n")
67
+ end
68
+
69
+ # The OS/2 typographic metric when present and not zero, the hhea one otherwise.
70
+ def ascent
71
+ @ascent ||= nonzero_os2(:ascent) || horizontal_header[0]
72
+ end
73
+
74
+ def descent
75
+ @descent ||= nonzero_os2(:descent) || horizontal_header[1]
76
+ end
77
+
78
+ def line_gap
79
+ @line_gap ||= nonzero_os2(:line_gap) || horizontal_header[2]
80
+ end
81
+
82
+ def os2
83
+ @os2 ||= begin
84
+ os2 = table("OS/2")
85
+ if os2
86
+ version = os2.unpack1("n")
87
+ family_class = os2.unpack1("@30 s>")
88
+ ascent, descent, line_gap = os2.unpack("@68 s>3")
89
+ x_height, cap_height = os2.unpack("@86 s>2") if version >= 2 && os2.bytesize >= 90
90
+ {family_class:, ascent:, descent:, line_gap:, x_height:, cap_height:}
91
+ else
92
+ {}
93
+ end
94
+ end
95
+ end
96
+
97
+ def italic_angle_fixed
98
+ table("post")&.unpack1("@4 N")
99
+ end
100
+
101
+ def fixed_pitch?
102
+ table("post")&.unpack1("@12 N").to_i != 0
103
+ end
104
+
105
+ def postscript_name
106
+ @postscript_name ||= name_string(6) || name_string(1) || "unnamed"
107
+ end
108
+
109
+ def font_name
110
+ name_string(4)
111
+ end
112
+
113
+ def family_name
114
+ name_string(1)
115
+ end
116
+
117
+ # Underline position and thickness from the post table, in font units.
118
+ def underline_metrics
119
+ table("post").unpack("@8 s>2")
120
+ end
121
+
122
+ def strikeout_position
123
+ table("OS/2").unpack1("@28 s>")
124
+ end
125
+
126
+ # The lowest and highest points of a glyph's outline, in font units, from its glyf header.
127
+ def glyph_vertical_bounds(glyph)
128
+ data = glyph_data(glyph)
129
+ return [0, 0] if data.bytesize < 10
130
+
131
+ data.unpack("@4 s> x2 s>")
132
+ end
133
+
134
+ # Advance widths in font units, one per glyph: glyphs past the last metric share its advance.
135
+ def advance_widths
136
+ @advance_widths ||= begin
137
+ metric_count = horizontal_header[3]
138
+ raise Error, "Malformed font: no horizontal metrics" unless metric_count.between?(1, table("hmtx").bytesize / 4)
139
+ widths = table("hmtx").unpack("n*").each_slice(2).first(metric_count).map(&:first)
140
+ widths + ([widths.last] * (glyph_count - metric_count))
141
+ end
142
+ end
143
+
144
+ def glyph_by_code
145
+ @glyph_by_code ||= read_unicode_cmap || raise(Error, "No unicode cmap found in font")
146
+ end
147
+
148
+ def unicode_cmap?
149
+ !read_unicode_cmap.nil?
150
+ end
151
+
152
+ # The first kerning subtable's pairs of glyphs, in font units.
153
+ def kerning_by_glyph_pair
154
+ @kerning_by_glyph_pair ||= begin
155
+ kern = table("kern")
156
+ next_pairs = nil
157
+ if kern
158
+ version = kern.unpack1("n")
159
+ if version == 1
160
+ length, coverage = kern.unpack("@8 N n")
161
+ next_pairs = kern.byteslice(16, length - 8) if (coverage & 0xFF).zero?
162
+ elsif kern.unpack1("@2 n").positive?
163
+ coverage = kern.unpack1("@8 n")
164
+ next_pairs = kern.byteslice(10..) if (coverage >> 8).zero?
165
+ end
166
+ end
167
+ next_pairs ? format0_pairs(next_pairs) : {}
168
+ end
169
+ end
170
+
171
+ def kerning?
172
+ !kerning_by_glyph_pair.empty?
173
+ end
174
+
175
+ # Kerning between two glyphs, in font units, or nil.
176
+ def kerning_offset(left, right)
177
+ @kerning_by_glyph_key ||= kerning_by_glyph_pair.transform_keys { |(first, second)| (first << 16) | second }
178
+ @kerning_by_glyph_key[(left << 16) | right]
179
+ end
180
+
181
+ def glyph_data(glyph)
182
+ offsets = glyph_offsets
183
+ start = offsets[glyph]
184
+ table_bytes = glyf_table
185
+ table_bytes.byteslice(start, offsets[glyph + 1] - start) || "".b
186
+ end
187
+
188
+ private
189
+
190
+ # Fonts may come from users: everything layout reads later is read and checked once, here.
191
+ def validate!
192
+ missing = REQUIRED_TABLES.reject { table?(it) }
193
+ raise Error, "Malformed font: no #{missing.join(", ")} table" unless missing.empty?
194
+ raise Error, "Malformed font: no outlines" if outline_format == :unknown
195
+
196
+ @location_by_tag.each do |tag, (offset, length)|
197
+ raise Error, "Malformed font: the #{tag} table lies outside the file" if offset + length > @bytes.bytesize
198
+ end
199
+ raise Error, "Malformed font: no glyphs" unless glyph_count.positive?
200
+
201
+ header
202
+ advance_widths
203
+ read_unicode_cmap
204
+ kerning_by_glyph_pair
205
+ validate_glyph_offsets! if outline_format == :true_type
206
+ end
207
+
208
+ def validate_glyph_offsets!
209
+ offsets = glyph_offsets
210
+ glyf_size = glyf_table.bytesize
211
+ valid = offsets.size == glyph_count + 1 && offsets.each_cons(2).all? { |start, finish| start <= finish } && offsets.last <= glyf_size
212
+ raise Error, "Malformed font: its glyph locations do not fit its glyph table" unless valid
213
+ end
214
+
215
+ def horizontal_header
216
+ @horizontal_header ||= begin
217
+ ascent, descent, line_gap = table("hhea").unpack("@4 s>3")
218
+ [ascent, descent, line_gap, table("hhea").unpack1("@34 n")]
219
+ end
220
+ end
221
+
222
+ def nonzero_os2(metric)
223
+ value = os2[metric]
224
+ value if value&.nonzero?
225
+ end
226
+
227
+ def glyf_table
228
+ @glyf_table ||= table("glyf")
229
+ end
230
+
231
+ def glyph_offsets
232
+ @glyph_offsets ||=
233
+ if header.index_to_location_format.zero?
234
+ table("loca").unpack("n#{glyph_count + 1}").map { it * 2 }
235
+ else
236
+ table("loca").unpack("N#{glyph_count + 1}")
237
+ end
238
+ end
239
+
240
+ def format0_pairs(subtable)
241
+ pair_count = subtable.unpack1("n")
242
+ values = subtable.unpack("@8 n#{pair_count * 3}")
243
+ values.each_slice(3).each_with_object({}) do |(left, right, value), pairs|
244
+ break pairs if value.nil?
245
+
246
+ pairs[[left, right]] = value >= 0x8000 ? value - 0x10000 : value
247
+ end
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.find { it[0] == 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
+ next unless (platform == 3 && [1, 10].include?(encoding)) || platform.zero?
302
+
303
+ [format, offset]
304
+ end.compact
305
+ format, offset = candidates.max_by(&:first)
306
+ @unicode_cmap = format && parse_cmap(cmap, format, offset)
307
+ end
308
+
309
+ def parse_cmap(cmap, format, offset)
310
+ glyph_by_code = {}
311
+ case format
312
+ when 4
313
+ segment_count = cmap.unpack1("@#{offset + 6} n") / 2
314
+ ends = cmap.unpack("@#{offset + 14} n#{segment_count}")
315
+ starts_offset = offset + 16 + (segment_count * 2)
316
+ starts = cmap.unpack("@#{starts_offset} n#{segment_count}")
317
+ deltas = cmap.unpack("@#{starts_offset + (segment_count * 2)} n#{segment_count}")
318
+ range_offsets_offset = starts_offset + (segment_count * 4)
319
+ range_offsets = cmap.unpack("@#{range_offsets_offset} n#{segment_count}")
320
+ segment_count.times do |segment|
321
+ (starts[segment]..ends[segment]).each do |code|
322
+ glyph =
323
+ if range_offsets[segment].zero?
324
+ (code + deltas[segment]) & 0xFFFF
325
+ else
326
+ glyph_offset = range_offsets_offset + (segment * 2) + range_offsets[segment] + ((code - starts[segment]) * 2)
327
+ raw = cmap.unpack1("@#{glyph_offset} n").to_i
328
+ raw.zero? ? 0 : (raw + deltas[segment]) & 0xFFFF
329
+ end
330
+ glyph_by_code[code] = glyph
331
+ end
332
+ end
333
+ when 6
334
+ first_code, count = cmap.unpack("@#{offset + 6} n2")
335
+ count = [count, (cmap.bytesize - offset - 10) / 2].min
336
+ cmap.unpack("@#{offset + 10} n#{count}").each_with_index { glyph_by_code[first_code + it[1]] = it[0] }
337
+ when 10
338
+ first_code, count = cmap.unpack("@#{offset + 12} N2")
339
+ count = [count, (cmap.bytesize - offset - 20) / 2].min
340
+ cmap.unpack("@#{offset + 20} n#{count}").each_with_index { glyph_by_code[first_code + it[1]] = it[0] }
341
+ when 12
342
+ group_count = [cmap.unpack1("@#{offset + 12} N"), (cmap.bytesize - offset - 16) / 12].min
343
+ cmap.unpack("@#{offset + 16} N#{group_count * 3}").each_slice(3) do |start_code, end_code, start_glyph|
344
+ (start_code..[end_code, MAXIMUM_CODE_POINT].min).each { glyph_by_code[it] = start_glyph + (it - start_code) }
345
+ end
346
+ end
347
+ glyph_by_code.each { |code, glyph| glyph_by_code[code] = 0 if glyph >= glyph_count }
348
+ glyph_by_code.default = 0
349
+ glyph_by_code.freeze
350
+ end
351
+
352
+ def name_string(name_id)
353
+ name = table("name")
354
+ return unless name
355
+
356
+ count, strings_offset = name.unpack("@2 n2")
357
+ records = Array.new(count) { name.unpack("@#{6 + (it * 12)} n6") }
358
+ record = records.find { |platform, _, _, id| id == name_id && platform == 3 } ||
359
+ records.find { |platform, _, _, id| id == name_id && platform == 1 } ||
360
+ records.find { |_, _, _, id| id == name_id }
361
+ return unless record
362
+
363
+ platform, _encoding, _language, _id, length, offset = record
364
+ raw = name.byteslice(strings_offset + offset, length)
365
+ platform == 1 ? raw.force_encoding(::Encoding::MacRoman).encode(::Encoding::UTF_8) : raw.force_encoding(::Encoding::UTF_16BE).encode(::Encoding::UTF_8)
366
+ rescue ::Encoding::UndefinedConversionError, ::Encoding::InvalidByteSequenceError
367
+ nil
368
+ end
369
+ end
370
+ end
371
+ end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PrawnX
4
+ module TrueType
5
+ # The parts of OpenType's GSUB and GPOS tables (OpenType specification, "OpenType Layout") PrawnX 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 { [@file.glyph_by_code[it], [it]] }
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 { -it[0].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, it) }
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 { table.unpack1("@#{set + 2 + (it * (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 { it.zero? ? nil : it }
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) { table.unpack("@#{script_list + 2 + (it * 6)} a4 n") }.to_h
149
+ script_offset = offset_by_tag.values_at(*PREFERRED_SCRIPTS).compact.first || 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 { index[it] = start_index + (it - 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 { classes[start + it[1]] = it[0] }
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[it] = value } }
184
+ end
185
+ classes
186
+ end
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PrawnX
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
+ # keeping space at 32 for word spacing; the next ones take 223 characters each, from code 33.
8
+ class SimpleSubsets
9
+ Subset = Struct.new(:code_by_cluster, :glyph_by_code, :next_code) do
10
+ def full?
11
+ next_code > 255
12
+ end
13
+ end
14
+
15
+ SPACE = 32
16
+ FIRST_FREE_CODE = 33
17
+
18
+ def initialize(glyph_for_cluster)
19
+ @glyph_for_cluster = glyph_for_cluster
20
+ @location_by_cluster = {}
21
+ @subsets = [Subset.new({SPACE => SPACE}, {SPACE => glyph_for_cluster.call([SPACE])}, nil)]
22
+ end
23
+
24
+ def size
25
+ @subsets.size
26
+ end
27
+
28
+ def [](index)
29
+ @subsets[index]
30
+ end
31
+
32
+ # [[subset index, bytes], …] for clusters of characters, each cluster [glyph, codes].
33
+ def encode(clusters)
34
+ encode_keys(clusters) { |cluster| [cluster[1], cluster[0]] }
35
+ end
36
+
37
+ # The same for single characters, by code point: the common case, without building a cluster per character.
38
+ def encode_codepoints(codepoints, glyph_by_code)
39
+ encode_keys(codepoints) { |code| [code, glyph_by_code[code]] }
40
+ end
41
+
42
+ private
43
+
44
+ # A character keeps its subset and code once given: later lookups are a single hash access.
45
+ def encode_keys(items)
46
+ parts = []
47
+ items.each do |item|
48
+ key, glyph = yield item
49
+ index, code = @location_by_cluster[key] ||= code_for([glyph, key])
50
+ if parts.empty? || parts.last[0] != index
51
+ parts << [index, code.chr.b]
52
+ else
53
+ parts.last[1] << code
54
+ end
55
+ end
56
+ parts
57
+ end
58
+
59
+ def code_for(cluster)
60
+ glyph, codes = cluster
61
+ key = codes
62
+ mac_roman = mac_roman_code(codes)
63
+ if mac_roman
64
+ @subsets[0].code_by_cluster[key] ||= mac_roman.tap { @subsets[0].glyph_by_code[mac_roman] = glyph }
65
+ return [0, mac_roman]
66
+ end
67
+
68
+ @subsets.each_with_index.drop(1).each do |subset, index|
69
+ code = subset.code_by_cluster[key]
70
+ return [index, code] if code
71
+ end
72
+ subset_index = @subsets.rindex { it.next_code && !it.full? } || begin
73
+ @subsets << Subset.new({[SPACE] => SPACE, SPACE => SPACE}, {SPACE => @glyph_for_cluster.call([SPACE])}, FIRST_FREE_CODE)
74
+ @subsets.size - 1
75
+ end
76
+ subset = @subsets[subset_index]
77
+ code = subset.next_code
78
+ subset.code_by_cluster[key] = code
79
+ subset.glyph_by_code[code] = glyph
80
+ subset.next_code += 1
81
+ [subset_index, code]
82
+ end
83
+
84
+ def mac_roman_code(codes)
85
+ codes = Array(codes)
86
+ return unless codes.size == 1
87
+
88
+ [codes.first].pack("U").encode(::Encoding::MacRoman).getbyte(0)
89
+ rescue ::Encoding::UndefinedConversionError
90
+ nil
91
+ end
92
+ end
93
+ end
94
+ end