fontisan 0.4.46 → 0.4.48

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 (111) hide show
  1. checksums.yaml +4 -4
  2. data/BENCHMARKS.md +56 -0
  3. data/CHANGELOG.md +12 -0
  4. data/Cargo.lock +550 -0
  5. data/Cargo.toml +12 -0
  6. data/Gemfile +10 -0
  7. data/README.adoc +52 -0
  8. data/Rakefile +5 -1
  9. data/benchmark/baseline.json +5 -0
  10. data/benchmark/ci_gate.rb +217 -0
  11. data/benchmark/corpus/harness.rb +175 -0
  12. data/benchmark/corpus/ops.rb +150 -0
  13. data/benchmark/corpus/run.rb +182 -0
  14. data/benchmark/corpus/scan_child.rb +8 -0
  15. data/benchmark/corpus/tiers.rb +176 -0
  16. data/benchmark/tier_baseline.json +12 -0
  17. data/docs/.vitepress/config.ts +2 -1
  18. data/docs/guide/engines.md +117 -0
  19. data/docs/reference-facade.md +130 -0
  20. data/ext/fontisan_cdylib/Cargo.lock +313 -0
  21. data/ext/fontisan_cdylib/Cargo.toml +18 -0
  22. data/ext/fontisan_cdylib/build.rs +22 -0
  23. data/ext/fontisan_cdylib/extconf.rb +36 -0
  24. data/ext/fontisan_cdylib/src/lib.rs +13 -0
  25. data/ext/fontisan_native/Cargo.toml +21 -0
  26. data/ext/fontisan_native/build.rs +22 -0
  27. data/ext/fontisan_native/clippy.toml +10 -0
  28. data/ext/fontisan_native/extconf.rb +59 -0
  29. data/ext/fontisan_native/src/lib.rs +463 -0
  30. data/lib/fontisan/audit/checks/cmap_check.rb +7 -0
  31. data/lib/fontisan/audit/checks/hinting_check.rb +1 -1
  32. data/lib/fontisan/audit/checks/maxp_check.rb +1 -1
  33. data/lib/fontisan/audit/checks/opentype_conformance_check.rb +5 -2
  34. data/lib/fontisan/base_collection.rb +30 -0
  35. data/lib/fontisan/byte_ops.rb +219 -0
  36. data/lib/fontisan/cli.rb +5 -5
  37. data/lib/fontisan/commands/audit_command.rb +38 -43
  38. data/lib/fontisan/commands/convert_command.rb +53 -0
  39. data/lib/fontisan/commands/instance_command.rb +75 -0
  40. data/lib/fontisan/commands/subset_command.rb +43 -0
  41. data/lib/fontisan/commands/unpack_command.rb +56 -0
  42. data/lib/fontisan/converters/cff_table_builder.rb +1 -1
  43. data/lib/fontisan/converters/collection_converter.rb +13 -5
  44. data/lib/fontisan/converters/format_converter.rb +1 -1
  45. data/lib/fontisan/converters/woff2_encoder.rb +8 -6
  46. data/lib/fontisan/converters/woff_writer.rb +6 -5
  47. data/lib/fontisan/engine.rb +350 -0
  48. data/lib/fontisan/ffi/client.rb +121 -0
  49. data/lib/fontisan/ffi.rb +343 -0
  50. data/lib/fontisan/ffi_platform.rb +24 -0
  51. data/lib/fontisan/font_loader.rb +5 -1
  52. data/lib/fontisan/font_writer.rb +13 -1
  53. data/lib/fontisan/native.rb +231 -0
  54. data/lib/fontisan/pipeline/transformation_pipeline.rb +10 -8
  55. data/lib/fontisan/pipeline.rb +1 -1
  56. data/lib/fontisan/reference/table_digests.rb +251 -0
  57. data/lib/fontisan/reference.rb +619 -0
  58. data/lib/fontisan/scan.rb +372 -0
  59. data/lib/fontisan/sfnt_directory.rb +41 -0
  60. data/lib/fontisan/sfnt_font.rb +16 -6
  61. data/lib/fontisan/subset/builder.rb +2 -1
  62. data/lib/fontisan/tables/cff/charstring.rb +16 -0
  63. data/lib/fontisan/tables/cff.rb +15 -5
  64. data/lib/fontisan/tables/cmap.rb +11 -1
  65. data/lib/fontisan/tables/glyf.rb +4 -6
  66. data/lib/fontisan/tables/gpos.rb +7 -0
  67. data/lib/fontisan/tables/gsub.rb +7 -0
  68. data/lib/fontisan/tables/layout_common.rb +172 -0
  69. data/lib/fontisan/tables/name.rb +10 -4
  70. data/lib/fontisan/utilities/brotli_wrapper.rb +39 -4
  71. data/lib/fontisan/validators/opentype_validator.rb +15 -3
  72. data/lib/fontisan/variable/instancer.rb +23 -192
  73. data/lib/fontisan/variable/static_font_builder.rb +4 -1
  74. data/lib/fontisan/variable/table_updater.rb +4 -2
  75. data/lib/fontisan/variation/instance_generator.rb +3 -149
  76. data/lib/fontisan/variation/instancing/axis_space.rb +136 -0
  77. data/lib/fontisan/variation/instancing/glyph_outline.rb +204 -0
  78. data/lib/fontisan/variation/instancing/gvar_reader.rb +146 -0
  79. data/lib/fontisan/variation/instancing/item_variation_store.rb +92 -0
  80. data/lib/fontisan/variation/instancing/name_rewriter.rb +90 -0
  81. data/lib/fontisan/variation/instancing/true_type_instancer.rb +326 -0
  82. data/lib/fontisan/variation/instancing.rb +20 -0
  83. data/lib/fontisan/variation/variable_svg_generator.rb +19 -1
  84. data/lib/fontisan/variation.rb +1 -0
  85. data/lib/fontisan/version.rb +1 -1
  86. data/lib/fontisan/woff2/collection_decoder.rb +15 -1
  87. data/lib/fontisan/woff2/collection_encoder.rb +29 -6
  88. data/lib/fontisan/woff2/encoder_rules.rb +12 -4
  89. data/lib/fontisan/woff2/glyf_loca_reconstruct.rb +1 -1
  90. data/lib/fontisan/woff2_collection.rb +7 -5
  91. data/lib/fontisan/woff2_font.rb +12 -3
  92. data/lib/fontisan/woff_container.rb +83 -0
  93. data/lib/fontisan/woff_font.rb +24 -20
  94. data/lib/fontisan.rb +24 -1
  95. data/rakelib/bench.rake +9 -0
  96. data/rakelib/cdylib.rake +39 -0
  97. data/rakelib/corpus.rake +272 -0
  98. data/rakelib/corpus_convert.rake +43 -0
  99. data/rakelib/corpus_goldens.rake +22 -0
  100. data/rakelib/corpus_malformed.rake +20 -0
  101. data/rakelib/corpus_scan.rake +24 -0
  102. data/rakelib/java.rake +61 -0
  103. data/rakelib/native.rake +43 -0
  104. data/scripts/build-woff2.sh +57 -0
  105. data/scripts/corpus/generate_convert_goldens.rb +478 -0
  106. data/scripts/corpus/generate_goldens.rb +282 -0
  107. data/scripts/corpus/generate_malformed_goldens.rb +254 -0
  108. data/scripts/corpus/generate_scan_goldens.rb +98 -0
  109. data/scripts/tier_fallback.rb +219 -0
  110. data/scripts/tier_smoke.rb +64 -0
  111. metadata +75 -3
@@ -0,0 +1,372 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Fontisan
6
+ # Builds the scan rows served by {Fontisan::Reference.scan}: one row per font
7
+ # face, carrying everything fontist's `FontFile`, `CollectionFile` and
8
+ # indexability check read from a font, so the same rows can be produced by
9
+ # any engine tier.
10
+ #
11
+ # The field set and the name-decoding rules are taken from fontist at
12
+ # 85e904c96fff60f60781ae0eb11a8c06e0c73816 (`lib/fontist/font_file.rb`,
13
+ # `collection_file.rb`). This class never raises for a bad font: the failure
14
+ # that fontist would raise or show is recorded in the row's `errors`.
15
+ #
16
+ # Rows are plain Hashes with Symbol keys (see docs/reference-facade.md for
17
+ # the schema). Every call builds its own state, so concurrent calls on
18
+ # different paths are safe.
19
+ class Scan
20
+ NAME_FIELDS = {
21
+ full_name: Tables::Name::FULL_NAME,
22
+ family_name: Tables::Name::FAMILY,
23
+ subfamily_name: Tables::Name::SUBFAMILY,
24
+ preferred_family: Tables::Name::PREFERRED_FAMILY,
25
+ preferred_subfamily: Tables::Name::PREFERRED_SUBFAMILY,
26
+ postscript_name: Tables::Name::POSTSCRIPT_NAME,
27
+ }.freeze
28
+
29
+ RIBBI = ["regular", "bold", "italic", "bold italic"].freeze
30
+
31
+ FONT_FILE_ERROR = "Fontist::Errors::FontFileError"
32
+ INDEXABILITY_ERROR = "Fontist::Errors::FontIndexabilityValidationError"
33
+ # CollectionFile raises this inside the method whose blanket rescue wraps
34
+ # every non-indexability failure, so it surfaces as "could not be loaded"
35
+ # with the original FontFileError as its cause.
36
+ NOT_A_COLLECTION = {
37
+ class: FONT_FILE_ERROR,
38
+ message: "Font collection could not be loaded: " \
39
+ "File is not a recognized font collection (TTC/OTC/dfont)",
40
+ cause: { class: FONT_FILE_ERROR,
41
+ message: "File is not a recognized font collection (TTC/OTC/dfont)" },
42
+ }.freeze
43
+
44
+ # @param paths [Array<String>] font file paths
45
+ # @return [Array<Hash>] rows in input order, faces in index order
46
+ def self.call(paths)
47
+ Array(paths).flat_map { |path| new(path).rows }
48
+ end
49
+
50
+ # @param path [String]
51
+ def initialize(path)
52
+ @path = path.to_s
53
+ end
54
+
55
+ # @return [Array<Hash>] one row per face; one row with
56
+ # `collection_index: nil` when no face could be enumerated
57
+ def rows
58
+ facts = file_facts
59
+ return [failed_file_row(facts)] if facts[:fatal]
60
+
61
+ if facts[:is_collection]
62
+ collection_rows(facts)
63
+ else
64
+ [face_row(facts, nil, 1)]
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ # ---- file level -----------------------------------------------------
71
+
72
+ # What FontFile reads first: collection?, detect_format, sha256.
73
+ def file_facts
74
+ facts = { sha256: nil, format: nil, is_collection: false, fatal: nil }
75
+ facts[:sha256] = Digest::SHA256.file(@path).hexdigest
76
+ facts[:is_collection] = FontLoader.collection?(@path)
77
+ facts[:format] = detect_format
78
+ facts
79
+ rescue StandardError => e
80
+ facts[:fatal] = e
81
+ facts
82
+ end
83
+
84
+ def detect_format
85
+ format = FontLoader.detect_format(@path)
86
+ format&.to_s
87
+ rescue StandardError
88
+ nil
89
+ end
90
+
91
+ def failed_file_row(facts)
92
+ error = facts[:fatal]
93
+ row = base_row(facts, nil, 0)
94
+ row[:errors][:font_file] = font_file_error(error)
95
+ row[:errors][:collection_file] = load_error(error)
96
+ row
97
+ end
98
+
99
+ # ---- collections ----------------------------------------------------
100
+
101
+ def collection_rows(facts)
102
+ collection = begin
103
+ FontLoader.load_collection(@path)
104
+ rescue StandardError => e
105
+ e
106
+ end
107
+
108
+ if collection.is_a?(StandardError)
109
+ row = base_row(facts, nil, 0)
110
+ row[:errors][:font_file] = font_file_error(collection)
111
+ row[:errors][:collection_file] = load_error(collection)
112
+ return [row]
113
+ end
114
+
115
+ count = collection.num_fonts
116
+ return [empty_collection_row(facts)] if count.zero?
117
+
118
+ Array.new(count) { |index| face_row(facts, index, count) }
119
+ end
120
+
121
+ # A collection without faces has no row to hold what fontist raises for it
122
+ # (the first face cannot be loaded), so it gets one of its own.
123
+ def empty_collection_row(facts)
124
+ row = base_row(facts, nil, 0)
125
+ first = load_face(0, lazy: true)
126
+ row[:errors][:font_file] = font_file_error(first) if first.is_a?(StandardError)
127
+ row[:errors][:collection_file] = first_face_error
128
+ row
129
+ end
130
+
131
+ # ---- one face -------------------------------------------------------
132
+
133
+ def face_row(facts, index, num_faces)
134
+ row = base_row(facts, index, num_faces)
135
+ fontist_face = index.nil? || index.zero?
136
+
137
+ meta = load_face(index, lazy: true)
138
+ if meta.is_a?(StandardError)
139
+ row[:errors][:font_file] = font_file_error(meta) if fontist_face
140
+ else
141
+ begin
142
+ fill_from_face(row, meta, index)
143
+ rescue StandardError => e
144
+ row[:errors][:font_file] = font_file_error(e) if fontist_face
145
+ ensure
146
+ meta.close if meta.respond_to?(:close)
147
+ end
148
+ end
149
+
150
+ if index.nil?
151
+ row[:errors][:collection_file] = NOT_A_COLLECTION
152
+ else
153
+ fill_collection_errors(row, index)
154
+ end
155
+ row
156
+ end
157
+
158
+ # FontFile flow: validate -> names (with fallback) -> fvar -> instances.
159
+ def fill_from_face(row, font, index)
160
+ row[:validation] = validate(font)
161
+ row[:names] = names_with_fallback(font)
162
+ row[:has_fvar] = font.has_table?("fvar")
163
+ row[:variable_instances] = variable_instances(index) if row[:has_fvar] && row[:names][:family_name]
164
+ end
165
+
166
+ # CollectionFile flow: metadata, lazy: false, english names only; and the
167
+ # indexability check of the first face, which gates build_collection for
168
+ # the whole file (so every row of the file carries the same value).
169
+ def fill_collection_errors(row, index)
170
+ member = load_face(index, lazy: false)
171
+ if member.is_a?(StandardError)
172
+ row[:errors][:collection_member] = error_info(member)
173
+ else
174
+ begin
175
+ row[:english_names] = english_names(member)
176
+ rescue StandardError => e
177
+ row[:errors][:collection_member] = error_info(e)
178
+ ensure
179
+ member.close if member.respond_to?(:close)
180
+ end
181
+ end
182
+ row[:errors][:collection_file] = first_face_error
183
+ end
184
+
185
+ def first_face_error
186
+ return @first_face_error if defined?(@first_face_error)
187
+
188
+ @first_face_error = compute_first_face_error
189
+ end
190
+
191
+ def compute_first_face_error
192
+ first = load_face(0, lazy: true)
193
+ return load_error(first) if first.is_a?(StandardError)
194
+
195
+ begin
196
+ report = validate(first)
197
+ report[:valid] ? nil : indexability_error(report)
198
+ rescue StandardError => e
199
+ load_error(e)
200
+ ensure
201
+ first.close if first.respond_to?(:close)
202
+ end
203
+ end
204
+
205
+ def indexability_error(report)
206
+ { class: INDEXABILITY_ERROR,
207
+ message: "Font collection failed indexability validation " \
208
+ "(first font): #{report[:error_summary]}",
209
+ cause: nil }
210
+ end
211
+
212
+ # CollectionFile wraps every non-indexability failure this way.
213
+ def load_error(error)
214
+ wrapped("Font collection could not be loaded: #{error.message}",
215
+ FONT_FILE_ERROR, error)
216
+ end
217
+
218
+ def load_face(index, lazy:)
219
+ args = { mode: :metadata, lazy: lazy }
220
+ args[:font_index] = index unless index.nil?
221
+ FontLoader.load(@path, **args)
222
+ rescue StandardError => e
223
+ e
224
+ end
225
+
226
+ # ---- names ----------------------------------------------------------
227
+
228
+ def names_with_fallback(font)
229
+ name_table = font.table(Constants::NAME_TAG)
230
+ return {} unless name_table
231
+
232
+ NAME_FIELDS.transform_values do |id|
233
+ name_table.english_name(id) || any_name(name_table, id)
234
+ end
235
+ end
236
+
237
+ def english_names(font)
238
+ name_table = font.table(Constants::NAME_TAG)
239
+ return {} unless name_table
240
+
241
+ NAME_FIELDS.transform_values { |id| name_table.english_name(id) }
242
+ end
243
+
244
+ # fontist's extract_any_name / decode_name_record_manually.
245
+ def any_name(name_table, name_id)
246
+ records = name_table.name_records.select { |r| r.name_id == name_id }
247
+ records.each do |record|
248
+ decoded = decode_record(name_table, record)
249
+ return decoded unless decoded.nil? || decoded.empty?
250
+ end
251
+ nil
252
+ end
253
+
254
+ def decode_record(name_table, record)
255
+ storage = name_table.string_storage.to_s.b
256
+ offset = record.string_offset
257
+ length = record.string_length
258
+ return nil if offset + length > storage.bytesize || length.zero?
259
+
260
+ data = storage.byteslice(offset, length)
261
+ case record.platform_id
262
+ when 0, 3
263
+ data.dup.force_encoding("UTF-16BE")
264
+ .encode("UTF-8", invalid: :replace, undef: :replace)
265
+ when 1
266
+ data.dup.force_encoding("ASCII-8BIT")
267
+ .encode("UTF-8", invalid: :replace, undef: :replace)
268
+ else
269
+ data.dup.force_encoding("UTF-8")
270
+ end
271
+ end
272
+
273
+ # ---- variable instances (fontist FontFile.extract_variable_instances) -
274
+
275
+ # fvar data is gated out of metadata mode, so this is the second load.
276
+ def variable_instances(index)
277
+ font = if index.nil?
278
+ FontLoader.load(@path, mode: :full, lazy: true)
279
+ else
280
+ FontLoader.load(@path, font_index: index, mode: :full, lazy: true)
281
+ end
282
+ instances_of(font)
283
+ rescue StandardError
284
+ []
285
+ ensure
286
+ font.close if font.respond_to?(:close)
287
+ end
288
+
289
+ def instances_of(font)
290
+ return [] unless font.has_table?("fvar")
291
+
292
+ fvar = font.table("fvar")
293
+ name_table = font.table(Constants::NAME_TAG)
294
+ return [] unless name_table
295
+
296
+ base = name_table.english_name(Tables::Name::PREFERRED_FAMILY) ||
297
+ any_name(name_table, Tables::Name::PREFERRED_FAMILY) ||
298
+ name_table.english_name(Tables::Name::FAMILY)
299
+ return [] unless base
300
+
301
+ fvar.instances.filter_map do |instance|
302
+ style = name_table.english_name(instance[:name_id]) ||
303
+ any_name(name_table, instance[:name_id])
304
+ next unless style
305
+
306
+ instance_entry(base, style)
307
+ end
308
+ rescue StandardError
309
+ []
310
+ end
311
+
312
+ def instance_entry(base, style)
313
+ if RIBBI.include?(style.downcase)
314
+ { family: base, style: style, full_name: "#{base} #{style}" }
315
+ else
316
+ family = "#{base} #{style}"
317
+ { family: family, style: "Regular", full_name: family }
318
+ end
319
+ end
320
+
321
+ # ---- validation -----------------------------------------------------
322
+
323
+ # The raw indexability result, with fontist's joined message string.
324
+ def validate(font)
325
+ report = Validators::ProfileLoader.load(:indexability).validate(font)
326
+ errors = report.errors
327
+ {
328
+ profile: "indexability",
329
+ valid: report.valid? || false,
330
+ status: report.status,
331
+ issues: report.issues.map do |i|
332
+ { severity: i.severity, category: i.category, message: i.message,
333
+ location: i.location }
334
+ end,
335
+ error_summary: errors.map { |e| "#{e.category}: #{e.message}" }.join("; "),
336
+ }
337
+ end
338
+
339
+ # ---- rows and errors ------------------------------------------------
340
+
341
+ def base_row(facts, index, num_faces)
342
+ {
343
+ path: @path,
344
+ collection_index: index,
345
+ sha256: facts[:sha256],
346
+ format: facts[:format],
347
+ is_collection: facts[:is_collection],
348
+ num_faces: num_faces,
349
+ has_fvar: false,
350
+ names: nil,
351
+ english_names: nil,
352
+ variable_instances: [],
353
+ validation: nil,
354
+ errors: { font_file: nil, collection_file: nil, collection_member: nil },
355
+ }
356
+ end
357
+
358
+ # `FontFile.raise_font_file_error`: "Font file could not be parsed: <inspect>."
359
+ def font_file_error(error)
360
+ wrapped("Font file could not be parsed: #{error.inspect}.",
361
+ FONT_FILE_ERROR, error)
362
+ end
363
+
364
+ def wrapped(message, klass, cause)
365
+ { class: klass, message: message, cause: error_info(cause) }
366
+ end
367
+
368
+ def error_info(error)
369
+ { class: error.class.name, message: error.message }
370
+ end
371
+ end
372
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ # Bounds check of a plain sfnt (TrueType or OpenType) table directory, run
5
+ # before a conversion reads it.
6
+ #
7
+ # fontisan-rs rejects a directory that runs past the end of the file, or a
8
+ # table that does, with "invalid sfnt table directory". Without this check
9
+ # the Ruby tier fails the same files with whatever BinData or the converters
10
+ # raise: "End of file reached" for a truncated directory, and an internal
11
+ # NoMethodError text for a bad table offset. This gives every tier one error
12
+ # for the same input (fontist/fontisan#159).
13
+ module SfntDirectory
14
+ # Raised for a malformed directory; the converter adds its own prefix.
15
+ class Malformed < Error; end
16
+
17
+ MESSAGE = "Transformation failed: invalid sfnt table directory"
18
+ HEADER_SIZE = 12
19
+ ENTRY_SIZE = 16
20
+
21
+ module_function
22
+
23
+ # @param data [String] the whole file, binary
24
+ # @raise [Malformed] when the directory or a table is out of range
25
+ # @return [void]
26
+ def check!(data)
27
+ size = data.bytesize
28
+ malformed! if size < HEADER_SIZE
29
+ num_tables = data.byteslice(4, 2).unpack1("n")
30
+ malformed! if HEADER_SIZE + (num_tables * ENTRY_SIZE) > size
31
+ num_tables.times do |index|
32
+ offset, length = data.byteslice(HEADER_SIZE + (index * ENTRY_SIZE) + 8, 8).unpack("NN")
33
+ malformed! if offset + length > size
34
+ end
35
+ end
36
+
37
+ def malformed!
38
+ raise Malformed, MESSAGE
39
+ end
40
+ end
41
+ end
@@ -66,7 +66,15 @@ module Fontisan
66
66
  # silently miss due to encoding mismatch — BinData-derived tag
67
67
  # strings are ASCII-8BIT, literals callers pass are usually UTF-8,
68
68
  # and String#eql? is encoding-sensitive.
69
- attr_reader :table_data
69
+ # Raw table bytes: the Hash of all tables, or one table's bytes when a
70
+ # tag is given (the WOFF classes already accept a tag; StaticFontBuilder
71
+ # calls it that way).
72
+ #
73
+ # @param tag [String, nil]
74
+ # @return [Hash{String=>String}, String, nil]
75
+ def table_data(tag = nil)
76
+ tag.nil? ? @table_data : @table_data&.[](tag)
77
+ end
70
78
 
71
79
  def table_data=(hash)
72
80
  @table_data = hash.transform_keys { |tag| normalize_tag(tag) }
@@ -271,7 +279,9 @@ module Fontisan
271
279
 
272
280
  # Get metadata tables sorted by offset for sequential access
273
281
  metadata_entries = tables.select { |entry| LoadingModes::METADATA_TABLES_SET.include?(entry.tag) }
274
- metadata_entries.sort_by!(&:offset)
282
+ # Tables may share an offset (a zero-length table sits where the next one
283
+ # starts) and sort_by is not stable, so tie-break on the directory order.
284
+ metadata_entries = metadata_entries.each_with_index.sort_by { |entry, idx| [entry.offset, idx] }.map(&:first)
275
285
 
276
286
  return if metadata_entries.empty?
277
287
 
@@ -279,18 +289,18 @@ module Fontisan
279
289
  i = 0
280
290
  while i < metadata_entries.size
281
291
  batch_start = metadata_entries[i]
282
- batch_end = batch_start
292
+ batch_end_pos = batch_start.offset + batch_start.table_length
283
293
  batch_entries = [batch_start]
284
294
 
285
295
  # Extend batch while next table is within page threshold
286
296
  j = i + 1
287
297
  while j < metadata_entries.size
288
298
  next_entry = metadata_entries[j]
289
- gap = next_entry.offset - (batch_end.offset + batch_end.table_length)
299
+ gap = next_entry.offset - batch_end_pos
290
300
 
291
301
  # If gap is small (within page threshold), include in batch
292
302
  if gap <= page_threshold
293
- batch_end = next_entry
303
+ batch_end_pos = [batch_end_pos, next_entry.offset + next_entry.table_length].max
294
304
  batch_entries << next_entry
295
305
  j += 1
296
306
  else
@@ -307,7 +317,7 @@ module Fontisan
307
317
  else
308
318
  # Multiple tables, read contiguous segment
309
319
  batch_offset = batch_start.offset
310
- batch_length = (batch_end.offset + batch_end.table_length) - batch_start.offset
320
+ batch_length = batch_end_pos - batch_start.offset
311
321
 
312
322
  io.seek(batch_offset)
313
323
  batch_data = io.read(batch_length)
@@ -91,6 +91,7 @@ module Fontisan
91
91
  # subset_binary = builder.build
92
92
  # File.binwrite('subset.ttf', subset_binary)
93
93
  def build
94
+ Fontisan::Engine.record_ruby(:subset)
94
95
  validate_input!
95
96
  calculate_closure
96
97
  build_mapping
@@ -133,7 +134,7 @@ module Fontisan
133
134
  #
134
135
  # @raise [Fontisan::MissingTableError] If required tables are missing
135
136
  def validate_required_tables!
136
- required = %w[head maxp]
137
+ required = %w[head hhea maxp]
137
138
  required.each do |tag|
138
139
  table = font.table(tag)
139
140
  next if table
@@ -107,6 +107,11 @@ module Fontisan
107
107
  [12, 37] => :flex1,
108
108
  }.freeze
109
109
 
110
+ # Total operators and operands one CharString (subroutines included)
111
+ # may execute. Real glyphs need hundreds; this only stops exponential
112
+ # subroutine fan-out. Matches the native tiers' `MAX_OPS` (1 << 20).
113
+ MAX_OPS = 1 << 20
114
+
110
115
  # Initialize and interpret a CharString
111
116
  #
112
117
  # @param data [String] Binary CharString data
@@ -128,6 +133,7 @@ module Fontisan
128
133
  @transient_array = []
129
134
  @subroutine_bias = calculate_bias(local_subrs)
130
135
  @global_subroutine_bias = calculate_bias(global_subrs)
136
+ @ops = 0
131
137
 
132
138
  parse!
133
139
  end
@@ -184,6 +190,7 @@ module Fontisan
184
190
  width_parsed = false
185
191
 
186
192
  until io.eof?
193
+ count_op
187
194
  byte = io.getbyte
188
195
 
189
196
  if byte <= 31 && byte != 28
@@ -207,6 +214,14 @@ module Fontisan
207
214
  "Failed to parse CharString: #{e.message}"
208
215
  end
209
216
 
217
+ # Count one operator or operand against the budget.
218
+ def count_op
219
+ @ops += 1
220
+ return if @ops <= MAX_OPS
221
+
222
+ raise CorruptedTableError, "charstring operation budget exceeded"
223
+ end
224
+
210
225
  # Read an operator from the CharString
211
226
  #
212
227
  # @param io [StringIO] Input stream
@@ -836,6 +851,7 @@ module Fontisan
836
851
 
837
852
  # Process subroutine until return or end
838
853
  until @io.eof?
854
+ count_op
839
855
  byte = @io.getbyte
840
856
 
841
857
  if byte <= 31 && byte != 28
@@ -370,14 +370,10 @@ module Fontisan
370
370
  # puts charstring.bounding_box
371
371
  # charstring.to_commands.each { |cmd| puts cmd.inspect }
372
372
  def charstring_for_glyph(glyph_index, font_index = 0)
373
- charstrings = charstrings_index(font_index)
373
+ charstrings, priv_dict, local_subr_index = glyph_sources(font_index)
374
374
  return nil unless charstrings
375
-
376
- priv_dict = private_dict(font_index)
377
375
  return nil unless priv_dict
378
376
 
379
- local_subr_index = local_subrs(font_index)
380
-
381
377
  charstrings.charstring_at(
382
378
  glyph_index,
383
379
  priv_dict,
@@ -482,6 +478,20 @@ module Fontisan
482
478
  rescue StandardError => e
483
479
  raise CorruptedTableError, "Failed to parse Encoding: #{e.message}"
484
480
  end
481
+
482
+ private
483
+
484
+ # CharStrings INDEX, Private DICT and Local Subr INDEX of one font,
485
+ # parsed once. charstring_for_glyph is called per glyph, and re-parsing
486
+ # the whole INDEX each time made it quadratic in the glyph count.
487
+ def glyph_sources(font_index)
488
+ @glyph_sources ||= {}
489
+ @glyph_sources[font_index] ||= begin
490
+ charstrings = charstrings_index(font_index)
491
+ priv_dict = charstrings && private_dict(font_index)
492
+ [charstrings, priv_dict, priv_dict && local_subrs(font_index)]
493
+ end
494
+ end
485
495
  end
486
496
  end
487
497
  end
@@ -22,6 +22,9 @@ module Fontisan
22
22
  PLATFORM_MACINTOSH = 1
23
23
  PLATFORM_MICROSOFT = 3
24
24
 
25
+ # Last Unicode scalar; bounds format 12 group spans.
26
+ MAX_CODEPOINT = 0x10FFFF
27
+
25
28
  # Microsoft Encoding IDs
26
29
  ENC_MS_UNICODE_BMP = 1 # Unicode BMP (UCS-2)
27
30
  ENC_MS_UNICODE_UCS4 = 10 # Unicode full repertoire (UCS-4)
@@ -198,6 +201,10 @@ module Fontisan
198
201
  id_delta = id_deltas[i]
199
202
  id_range_offset = id_range_offsets[i]
200
203
 
204
+ # A segmentCount past the arrays inside the declared length leaves
205
+ # the later arrays short: skip the cut segment (was NoMethodError).
206
+ next if [start_code, end_code, id_delta, id_range_offset].any?(&:nil?)
207
+
201
208
  # Skip the final segment (0xFFFF)
202
209
  next if start_code == 0xFFFF
203
210
 
@@ -272,8 +279,11 @@ module Fontisan
272
279
  end
273
280
 
274
281
  # Map a range of characters to glyphs
282
+ # The end is clamped to the last Unicode scalar: a fuzzed group spanning
283
+ # the u32 range would otherwise loop for minutes (same bound as the Rust
284
+ # engine's cmap parser).
275
285
  def map_character_range(start_char, end_char, start_glyph, mappings)
276
- (start_char..end_char).each do |code|
286
+ (start_char..[end_char, MAX_CODEPOINT].min).each do |code|
277
287
  glyph_index = start_glyph + (code - start_char)
278
288
  mappings[code] = glyph_index if glyph_index != 0
279
289
  end
@@ -241,13 +241,11 @@ module Fontisan
241
241
  glyph = glyph_for(glyph_id, loca, head)
242
242
  return true if glyph.nil? # Empty glyphs are OK
243
243
 
244
- # Simple glyphs: contours should be >= 0
245
- # Compound glyphs: numberOfContours = -1
244
+ # Simple glyphs: contours must be >= 0. Compound glyphs always have
245
+ # numberOfContours = -1; CompoundGlyph.parse rejects anything else.
246
246
  case glyph
247
- when SimpleGlyph, CompoundGlyph
248
- glyph.num_contours >= -1
249
- else
250
- true
247
+ when SimpleGlyph then glyph.num_contours >= 0
248
+ else true
251
249
  end
252
250
  rescue StandardError
253
251
  false
@@ -28,6 +28,13 @@ module Fontisan
28
28
  []
29
29
  end
30
30
 
31
+ # Every script with its feature tags, parsed once and work-budgeted
32
+ # (see {LayoutCommon::ScriptFeatures}).
33
+ # @return [Hash{String=>Array<String>}] script tag => feature tags
34
+ def script_features
35
+ LayoutCommon::ScriptFeatures.new(self).call
36
+ end
37
+
31
38
  # Get all feature tags for a given script
32
39
  # @param script_tag [String] 4-character script tag (e.g., 'latn')
33
40
  # @return [Array<String>] Array of 4-character feature tags
@@ -26,6 +26,13 @@ module Fontisan
26
26
  []
27
27
  end
28
28
 
29
+ # Every script with its feature tags, parsed once and work-budgeted
30
+ # (see {LayoutCommon::ScriptFeatures}).
31
+ # @return [Hash{String=>Array<String>}] script tag => feature tags
32
+ def script_features
33
+ LayoutCommon::ScriptFeatures.new(self).call
34
+ end
35
+
29
36
  # Get all feature tags for a given script
30
37
  # @param script_tag [String] 4-character script tag (e.g., 'latn')
31
38
  # @return [Array<String>] Array of 4-character feature tags