fontisan 0.2.1 → 0.2.2

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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +57 -385
  3. data/README.adoc +1483 -1435
  4. data/Rakefile +3 -2
  5. data/benchmark/variation_quick_bench.rb +4 -4
  6. data/docs/FONT_HINTING.adoc +562 -0
  7. data/docs/VARIABLE_FONT_OPERATIONS.adoc +599 -0
  8. data/lib/fontisan/cli.rb +10 -3
  9. data/lib/fontisan/collection/builder.rb +2 -1
  10. data/lib/fontisan/collection/offset_calculator.rb +2 -0
  11. data/lib/fontisan/commands/base_command.rb +5 -2
  12. data/lib/fontisan/commands/convert_command.rb +6 -2
  13. data/lib/fontisan/commands/info_command.rb +111 -5
  14. data/lib/fontisan/commands/instance_command.rb +8 -7
  15. data/lib/fontisan/commands/validate_command.rb +4 -1
  16. data/lib/fontisan/constants.rb +24 -24
  17. data/lib/fontisan/converters/format_converter.rb +8 -4
  18. data/lib/fontisan/converters/outline_converter.rb +21 -16
  19. data/lib/fontisan/converters/woff_writer.rb +8 -3
  20. data/lib/fontisan/font_loader.rb +11 -4
  21. data/lib/fontisan/font_writer.rb +2 -0
  22. data/lib/fontisan/formatters/text_formatter.rb +45 -1
  23. data/lib/fontisan/hints/hint_converter.rb +43 -47
  24. data/lib/fontisan/hints/hint_validator.rb +284 -0
  25. data/lib/fontisan/hints/postscript_hint_applier.rb +1 -3
  26. data/lib/fontisan/hints/postscript_hint_extractor.rb +78 -43
  27. data/lib/fontisan/hints/truetype_hint_extractor.rb +22 -26
  28. data/lib/fontisan/hints/truetype_instruction_analyzer.rb +261 -0
  29. data/lib/fontisan/hints/truetype_instruction_generator.rb +266 -0
  30. data/lib/fontisan/loading_modes.rb +4 -4
  31. data/lib/fontisan/models/collection_brief_info.rb +31 -0
  32. data/lib/fontisan/models/font_export.rb +2 -2
  33. data/lib/fontisan/models/font_info.rb +3 -30
  34. data/lib/fontisan/models/hint.rb +22 -23
  35. data/lib/fontisan/models/outline.rb +4 -1
  36. data/lib/fontisan/models/validation_report.rb +1 -1
  37. data/lib/fontisan/open_type_font.rb +3 -1
  38. data/lib/fontisan/optimizers/pattern_analyzer.rb +2 -1
  39. data/lib/fontisan/optimizers/subroutine_generator.rb +1 -1
  40. data/lib/fontisan/pipeline/output_writer.rb +8 -3
  41. data/lib/fontisan/pipeline/transformation_pipeline.rb +8 -3
  42. data/lib/fontisan/subset/table_subsetter.rb +5 -5
  43. data/lib/fontisan/tables/cff/charstring.rb +38 -12
  44. data/lib/fontisan/tables/cff/charstring_parser.rb +23 -11
  45. data/lib/fontisan/tables/cff/charstring_rebuilder.rb +14 -14
  46. data/lib/fontisan/tables/cff/dict_builder.rb +4 -1
  47. data/lib/fontisan/tables/cff/hint_operation_injector.rb +6 -4
  48. data/lib/fontisan/tables/cff/offset_recalculator.rb +1 -1
  49. data/lib/fontisan/tables/cff/private_dict_writer.rb +10 -4
  50. data/lib/fontisan/tables/cff/table_builder.rb +1 -1
  51. data/lib/fontisan/tables/cff2/charstring_parser.rb +14 -8
  52. data/lib/fontisan/tables/cff2/private_dict_blend_handler.rb +7 -6
  53. data/lib/fontisan/tables/cff2/region_matcher.rb +2 -2
  54. data/lib/fontisan/tables/cff2/table_builder.rb +26 -20
  55. data/lib/fontisan/tables/cff2/table_reader.rb +35 -33
  56. data/lib/fontisan/tables/cff2/variation_data_extractor.rb +2 -2
  57. data/lib/fontisan/tables/cff2.rb +1 -1
  58. data/lib/fontisan/tables/glyf/compound_glyph_resolver.rb +2 -1
  59. data/lib/fontisan/tables/glyf/curve_converter.rb +10 -4
  60. data/lib/fontisan/tables/glyf/glyph_builder.rb +27 -10
  61. data/lib/fontisan/tables/name.rb +4 -4
  62. data/lib/fontisan/true_type_font.rb +3 -1
  63. data/lib/fontisan/validation/checksum_validator.rb +2 -2
  64. data/lib/fontisan/variation/cache.rb +3 -1
  65. data/lib/fontisan/variation/converter.rb +2 -1
  66. data/lib/fontisan/variation/delta_applier.rb +2 -1
  67. data/lib/fontisan/variation/inspector.rb +2 -1
  68. data/lib/fontisan/variation/instance_generator.rb +2 -1
  69. data/lib/fontisan/variation/optimizer.rb +6 -3
  70. data/lib/fontisan/variation/subsetter.rb +32 -10
  71. data/lib/fontisan/variation/variation_preserver.rb +4 -1
  72. data/lib/fontisan/version.rb +1 -1
  73. data/lib/fontisan/woff2/glyf_transformer.rb +57 -30
  74. data/lib/fontisan/woff2_font.rb +31 -15
  75. data/lib/fontisan.rb +42 -2
  76. data/scripts/measure_optimization.rb +15 -7
  77. metadata +8 -2
@@ -114,7 +114,7 @@ module Fontisan
114
114
  skip_tables = @checksum_config["skip_tables"] || []
115
115
 
116
116
  font.tables.each do |table_entry|
117
- tag = table_entry.tag.to_s # Convert BinData field to string
117
+ tag = table_entry.tag.to_s # Convert BinData field to string
118
118
 
119
119
  # Skip tables that are exempt from checksum validation
120
120
  next if skip_tables.include?(tag)
@@ -125,7 +125,7 @@ module Fontisan
125
125
 
126
126
  # Calculate checksum for the table
127
127
  calculated_checksum = calculate_table_checksum(table_data)
128
- declared_checksum = table_entry.checksum.to_i # Convert BinData field to integer
128
+ declared_checksum = table_entry.checksum.to_i # Convert BinData field to integer
129
129
 
130
130
  # Special handling for head table (checksum adjustment field should be 0)
131
131
  if tag == Constants::HEAD_TAG
@@ -44,6 +44,7 @@ module Fontisan
44
44
  @ttl = ttl
45
45
  @cache = {}
46
46
  @access_times = {}
47
+ @access_counter = 0
47
48
  @stats = {
48
49
  hits: 0,
49
50
  misses: 0,
@@ -219,7 +220,8 @@ module Fontisan
219
220
  #
220
221
  # @param key [String] Cache key
221
222
  def touch(key)
222
- @access_times[key] = Time.now
223
+ @access_counter += 1
224
+ @access_times[key] = @access_counter
223
225
  end
224
226
 
225
227
  # Evict entries if cache is full
@@ -162,7 +162,8 @@ module Fontisan
162
162
  # Note: CFF2 blend deltas are per-coordinate, we need to map to x/y
163
163
  # This is a simplified mapping - full implementation would track
164
164
  # which coordinates are being varied
165
- regions_map[region_key][:deltas_per_point][idx / 2] ||= { x: 0, y: 0 }
165
+ regions_map[region_key][:deltas_per_point][idx / 2] ||= { x: 0,
166
+ y: 0 }
166
167
  if idx.even?
167
168
  regions_map[region_key][:deltas_per_point][idx / 2][:x] = delta
168
169
  else
@@ -79,7 +79,8 @@ module Fontisan
79
79
  # Apply each active tuple's deltas
80
80
  adjusted_points = base_points.dup
81
81
  matches.each do |match|
82
- apply_tuple_deltas(adjusted_points, match, tuple_data, base_points.length)
82
+ apply_tuple_deltas(adjusted_points, match, tuple_data,
83
+ base_points.length)
83
84
  end
84
85
 
85
86
  adjusted_points
@@ -107,7 +107,8 @@ module Fontisan
107
107
  index: index,
108
108
  name: instance_name(instance[:subfamily_name_id]),
109
109
  postscript_name: instance_name(instance[:postscript_name_id]),
110
- coordinates: instance_coordinates(instance[:coordinates], @context.axes),
110
+ coordinates: instance_coordinates(instance[:coordinates],
111
+ @context.axes),
111
112
  }
112
113
  end
113
114
  end
@@ -248,7 +248,8 @@ module Fontisan
248
248
  # @param scalars [Array<Float>] Region scalars
249
249
  # @return [Hash] Interpolated point
250
250
  def interpolate_point(base_point, delta_points, scalars)
251
- @context.interpolator.interpolate_point(base_point, delta_points, scalars)
251
+ @context.interpolator.interpolate_point(base_point, delta_points,
252
+ scalars)
252
253
  end
253
254
 
254
255
  private
@@ -209,9 +209,12 @@ module Fontisan
209
209
  coords2 = r2.region_axes[i]
210
210
 
211
211
  # Compare start, peak, end coordinates
212
- return false unless coords_similar?(coords1.start_coord, coords2.start_coord)
213
- return false unless coords_similar?(coords1.peak_coord, coords2.peak_coord)
214
- return false unless coords_similar?(coords1.end_coord, coords2.end_coord)
212
+ return false unless coords_similar?(coords1.start_coord,
213
+ coords2.start_coord)
214
+ return false unless coords_similar?(coords1.peak_coord,
215
+ coords2.peak_coord)
216
+ return false unless coords_similar?(coords1.end_coord,
217
+ coords2.end_coord)
215
218
  end
216
219
 
217
220
  true
@@ -112,7 +112,10 @@ module Fontisan
112
112
  validate_input if @options[:validate]
113
113
 
114
114
  fvar = variation_table("fvar")
115
- return { tables: @font.table_data.dup, report: { error: "No fvar table" } } unless fvar
115
+ unless fvar
116
+ return { tables: @font.table_data.dup,
117
+ report: { error: "No fvar table" } }
118
+ end
116
119
 
117
120
  # Find axes to keep
118
121
  all_axes = fvar.axes
@@ -175,7 +178,8 @@ module Fontisan
175
178
  optimizer = Optimizer.new(cff2, region_threshold: threshold)
176
179
  optimizer.optimize
177
180
 
178
- @report[:regions_deduplicated] = optimizer.stats[:regions_deduplicated]
181
+ @report[:regions_deduplicated] =
182
+ optimizer.stats[:regions_deduplicated]
179
183
  @report[:cff2_optimized] = true
180
184
  end
181
185
 
@@ -316,8 +320,14 @@ module Fontisan
316
320
  # @param tables [Hash] Font tables
317
321
  # @param glyph_ids [Array<Integer>] Glyph IDs to keep
318
322
  def subset_metrics_variations(tables, glyph_ids)
319
- subset_metrics_table(tables, "HVAR", glyph_ids) if has_variation_table?("HVAR")
320
- subset_metrics_table(tables, "VVAR", glyph_ids) if has_variation_table?("VVAR")
323
+ if has_variation_table?("HVAR")
324
+ subset_metrics_table(tables, "HVAR",
325
+ glyph_ids)
326
+ end
327
+ if has_variation_table?("VVAR")
328
+ subset_metrics_table(tables, "VVAR",
329
+ glyph_ids)
330
+ end
321
331
  # MVAR is font-wide, no glyph subsetting needed
322
332
  end
323
333
 
@@ -333,7 +343,8 @@ module Fontisan
333
343
  # 3. Remove unused ItemVariationData
334
344
  # 4. Rebuild and serialize
335
345
 
336
- @report[:"#{table_tag.downcase}_note"] = "#{table_tag} subsetting not yet implemented"
346
+ @report[:"#{table_tag.downcase}_note"] =
347
+ "#{table_tag} subsetting not yet implemented"
337
348
  end
338
349
 
339
350
  # Update non-variation glyph tables
@@ -396,9 +407,18 @@ module Fontisan
396
407
  # @param tables [Hash] Font tables
397
408
  # @param keep_indices [Array<Integer>] Axis indices to keep
398
409
  def subset_metrics_axes(tables, keep_indices)
399
- subset_metrics_table_axes(tables, "HVAR", keep_indices) if has_variation_table?("HVAR")
400
- subset_metrics_table_axes(tables, "VVAR", keep_indices) if has_variation_table?("VVAR")
401
- subset_metrics_table_axes(tables, "MVAR", keep_indices) if has_variation_table?("MVAR")
410
+ if has_variation_table?("HVAR")
411
+ subset_metrics_table_axes(tables, "HVAR",
412
+ keep_indices)
413
+ end
414
+ if has_variation_table?("VVAR")
415
+ subset_metrics_table_axes(tables, "VVAR",
416
+ keep_indices)
417
+ end
418
+ if has_variation_table?("MVAR")
419
+ subset_metrics_table_axes(tables, "MVAR",
420
+ keep_indices)
421
+ end
402
422
  end
403
423
 
404
424
  # Subset a single metrics table's axes
@@ -412,7 +432,8 @@ module Fontisan
412
432
  # 2. Filter ItemVariationStore regions to keep axis indices
413
433
  # 3. Rebuild and serialize
414
434
 
415
- @report[:"#{table_tag.downcase}_axes_note"] = "#{table_tag} axis subsetting not yet implemented"
435
+ @report[:"#{table_tag.downcase}_axes_note"] =
436
+ "#{table_tag} axis subsetting not yet implemented"
416
437
  end
417
438
 
418
439
  # Simplify metrics table regions
@@ -426,7 +447,8 @@ module Fontisan
426
447
  # 3. Update delta set indices
427
448
  # 4. Serialize back to binary
428
449
 
429
- @report[:metrics_simplify_note] = "Metrics region simplification not yet implemented"
450
+ @report[:metrics_simplify_note] =
451
+ "Metrics region simplification not yet implemented"
430
452
  end
431
453
 
432
454
  # Create temporary font wrapper for validation
@@ -157,7 +157,10 @@ module Fontisan
157
157
  "Source font must respond to :has_table? and :table_data"
158
158
  end
159
159
 
160
- raise ArgumentError, "Target tables cannot be nil" if @target_tables.nil?
160
+ if @target_tables.nil?
161
+ raise ArgumentError,
162
+ "Target tables cannot be nil"
163
+ end
161
164
 
162
165
  unless @target_tables.is_a?(Hash)
163
166
  raise ArgumentError,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fontisan
4
- VERSION = "0.2.1"
4
+ VERSION = "0.2.2"
5
5
  end
@@ -41,7 +41,7 @@ module Fontisan
41
41
  WE_HAVE_INSTRUCTIONS = 0x0100
42
42
  USE_MY_METRICS = 0x0200
43
43
  OVERLAP_COMPOUND = 0x0400
44
- HAVE_VARIATIONS = 0x1000 # Variable font variation data follows
44
+ HAVE_VARIATIONS = 0x1000 # Variable font variation data follows
45
45
 
46
46
  # Reconstruct glyf and loca tables from transformed data
47
47
  #
@@ -55,7 +55,8 @@ module Fontisan
55
55
 
56
56
  # Check minimum size for header
57
57
  if io.size < 8
58
- raise InvalidFontError, "Transformed glyf data too small: #{io.size} bytes"
58
+ raise InvalidFontError,
59
+ "Transformed glyf data too small: #{io.size} bytes"
59
60
  end
60
61
 
61
62
  # Read header
@@ -69,28 +70,34 @@ module Fontisan
69
70
  end
70
71
 
71
72
  # Read nContour stream
72
- n_contour_data = read_stream_safely(io, "nContour", variable_font: variable_font)
73
+ n_contour_data = read_stream_safely(io, "nContour",
74
+ variable_font: variable_font)
73
75
 
74
76
  # Read nPoints stream
75
- n_points_data = read_stream_safely(io, "nPoints", variable_font: variable_font)
77
+ n_points_data = read_stream_safely(io, "nPoints",
78
+ variable_font: variable_font)
76
79
 
77
80
  # Read flag stream
78
81
  flag_data = read_stream_safely(io, "flag", variable_font: variable_font)
79
82
 
80
83
  # Read glyph stream (coordinates, instructions, composite data)
81
- glyph_data = read_stream_safely(io, "glyph", variable_font: variable_font)
84
+ glyph_data = read_stream_safely(io, "glyph",
85
+ variable_font: variable_font)
82
86
 
83
87
  # Read composite stream
84
- composite_data = read_stream_safely(io, "composite", variable_font: variable_font)
88
+ composite_data = read_stream_safely(io, "composite",
89
+ variable_font: variable_font)
85
90
 
86
91
  # Read bbox stream
87
92
  bbox_data = read_stream_safely(io, "bbox", variable_font: variable_font)
88
93
 
89
94
  # Read instruction stream
90
- instruction_data = read_stream_safely(io, "instruction", variable_font: variable_font)
95
+ instruction_data = read_stream_safely(io, "instruction",
96
+ variable_font: variable_font)
91
97
 
92
98
  # Parse streams
93
- n_contours = parse_n_contour_stream(StringIO.new(n_contour_data), num_glyphs)
99
+ n_contours = parse_n_contour_stream(StringIO.new(n_contour_data),
100
+ num_glyphs)
94
101
 
95
102
  # Reconstruct glyphs
96
103
  glyphs = reconstruct_glyphs(
@@ -101,7 +108,7 @@ module Fontisan
101
108
  StringIO.new(composite_data),
102
109
  StringIO.new(bbox_data),
103
110
  StringIO.new(instruction_data),
104
- variable_font: variable_font
111
+ variable_font: variable_font,
105
112
  )
106
113
 
107
114
  # Build glyf and loca tables
@@ -114,7 +121,7 @@ module Fontisan
114
121
  # @param stream_name [String] Name of stream for error messages
115
122
  # @param variable_font [Boolean] Whether this is a variable font (allows incomplete streams)
116
123
  # @return [String] Stream data (empty if not available)
117
- def self.read_stream_safely(io, stream_name, variable_font: false)
124
+ def self.read_stream_safely(io, _stream_name, variable_font: false)
118
125
  remaining = io.size - io.pos
119
126
  if remaining < 4
120
127
  # Not enough data for stream size - return empty stream
@@ -131,9 +138,9 @@ module Fontisan
131
138
  if remaining < stream_size
132
139
  # Stream size extends beyond available data
133
140
  # Read what we can
134
- available = io.read(remaining) || ""
141
+ io.read(remaining) || ""
135
142
  # For variable fonts, we may have incomplete streams - just return what we have
136
- available
143
+
137
144
  else
138
145
  io.read(stream_size) || ""
139
146
  end
@@ -160,18 +167,24 @@ module Fontisan
160
167
  case code
161
168
  when 255
162
169
  return 0 if io.eof? || (io.size - io.pos) < 2
170
+
163
171
  value_bytes = io.read(2)
164
172
  return 0 unless value_bytes && value_bytes.bytesize == 2
173
+
165
174
  759 + value_bytes.unpack1("n") # 253 * 3 + value
166
175
  when 254
167
176
  return 0 if io.eof? || (io.size - io.pos) < 2
177
+
168
178
  value_bytes = io.read(2)
169
179
  return 0 unless value_bytes && value_bytes.bytesize == 2
180
+
170
181
  506 + value_bytes.unpack1("n") # 253 * 2 + value
171
182
  when 253
172
183
  return 0 if io.eof? || (io.size - io.pos) < 2
184
+
173
185
  value_bytes = io.read(2)
174
186
  return 0 unless value_bytes && value_bytes.bytesize == 2
187
+
175
188
  253 + value_bytes.unpack1("n")
176
189
  else
177
190
  code
@@ -279,15 +292,15 @@ module Fontisan
279
292
  x_min = y_min = x_max = y_max = 0
280
293
  else
281
294
  bbox_bytes = bbox_io.read(8)
282
- unless bbox_bytes && bbox_bytes.bytesize == 8
283
- x_min = y_min = x_max = y_max = 0
284
- else
295
+ if bbox_bytes && bbox_bytes.bytesize == 8
285
296
  x_min, y_min, x_max, y_max = bbox_bytes.unpack("n4")
286
297
  # Convert to signed
287
298
  x_min = x_min > 0x7FFF ? x_min - 0x10000 : x_min
288
299
  y_min = y_min > 0x7FFF ? y_min - 0x10000 : y_min
289
300
  x_max = x_max > 0x7FFF ? x_max - 0x10000 : x_max
290
301
  y_max = y_max > 0x7FFF ? y_max - 0x10000 : y_max
302
+ else
303
+ x_min = y_min = x_max = y_max = 0
291
304
  end
292
305
  end
293
306
 
@@ -302,12 +315,12 @@ module Fontisan
302
315
  instruction_length = inst_length_data
303
316
  if instruction_length.positive?
304
317
  inst_remaining = instruction_io.size - instruction_io.pos
305
- if inst_remaining >= instruction_length
306
- instructions = instruction_io.read(instruction_length) || ""
307
- else
308
- # Read what we can
309
- instructions = instruction_io.read(inst_remaining) || ""
310
- end
318
+ instructions = if inst_remaining >= instruction_length
319
+ instruction_io.read(instruction_length) || ""
320
+ else
321
+ # Read what we can
322
+ instruction_io.read(inst_remaining) || ""
323
+ end
311
324
  end
312
325
  end
313
326
  end
@@ -325,7 +338,8 @@ module Fontisan
325
338
  # @param instruction_io [StringIO] Instruction stream
326
339
  # @param variable_font [Boolean] Whether this is a variable font
327
340
  # @return [String] Glyph data in standard format
328
- def self.reconstruct_composite_glyph(composite_io, bbox_io, instruction_io, variable_font: false)
341
+ def self.reconstruct_composite_glyph(composite_io, bbox_io,
342
+ instruction_io, variable_font: false)
329
343
  # Track available bytes to prevent EOF errors
330
344
  composite_size = composite_io.size - composite_io.pos
331
345
 
@@ -364,6 +378,7 @@ module Fontisan
364
378
  # Read flags and glyph_index safely
365
379
  component_header = composite_io.read(4)
366
380
  break unless component_header && component_header.bytesize == 4
381
+
367
382
  flags, glyph_index = component_header.unpack("n2")
368
383
 
369
384
  # Write flags and index
@@ -371,18 +386,21 @@ module Fontisan
371
386
  composite_data << [glyph_index].pack("n")
372
387
 
373
388
  # Read arguments (depend on flags)
389
+ remaining = composite_io.size - composite_io.pos
374
390
  if (flags & ARG_1_AND_2_ARE_WORDS).zero?
375
- remaining = composite_io.size - composite_io.pos
376
391
  break if composite_io.eof? || remaining < 2
392
+
377
393
  arg_bytes = composite_io.read(2)
378
394
  break unless arg_bytes && arg_bytes.bytesize == 2
395
+
379
396
  arg1, arg2 = arg_bytes.unpack("c2")
380
397
  composite_data << [arg1, arg2].pack("c2")
381
398
  else
382
- remaining = composite_io.size - composite_io.pos
383
399
  break if composite_io.eof? || remaining < 4
400
+
384
401
  arg_bytes = composite_io.read(4)
385
402
  break unless arg_bytes && arg_bytes.bytesize == 4
403
+
386
404
  arg1, arg2 = arg_bytes.unpack("n2")
387
405
  # Convert to signed
388
406
  arg1 = arg1 > 0x7FFF ? arg1 - 0x10000 : arg1
@@ -394,22 +412,28 @@ module Fontisan
394
412
  if (flags & WE_HAVE_A_SCALE) != 0
395
413
  remaining = composite_io.size - composite_io.pos
396
414
  break if composite_io.eof? || remaining < 2
415
+
397
416
  scale_bytes = composite_io.read(2)
398
417
  break unless scale_bytes && scale_bytes.bytesize == 2
418
+
399
419
  scale = scale_bytes.unpack1("n")
400
420
  composite_data << [scale].pack("n")
401
421
  elsif (flags & WE_HAVE_AN_X_AND_Y_SCALE) != 0
402
422
  remaining = composite_io.size - composite_io.pos
403
423
  break if composite_io.eof? || remaining < 4
424
+
404
425
  scale_bytes = composite_io.read(4)
405
426
  break unless scale_bytes && scale_bytes.bytesize == 4
427
+
406
428
  x_scale, y_scale = scale_bytes.unpack("n2")
407
429
  composite_data << [x_scale, y_scale].pack("n2")
408
430
  elsif (flags & WE_HAVE_A_TWO_BY_TWO) != 0
409
431
  remaining = composite_io.size - composite_io.pos
410
432
  break if composite_io.eof? || remaining < 8
433
+
411
434
  matrix_bytes = composite_io.read(8)
412
435
  break unless matrix_bytes && matrix_bytes.bytesize == 8
436
+
413
437
  x_scale, scale01, scale10, y_scale = matrix_bytes.unpack("n4")
414
438
  composite_data << [x_scale, scale01, scale10, y_scale].pack("n4")
415
439
  end
@@ -462,12 +486,12 @@ module Fontisan
462
486
  instruction_length = length_bytes.unpack1("n")
463
487
  if instruction_length.positive?
464
488
  remaining = instruction_io.size - instruction_io.pos
465
- if remaining >= instruction_length
466
- instructions = instruction_io.read(instruction_length) || ""
467
- else
468
- # Read what we can
469
- instructions = instruction_io.read(remaining) || ""
470
- end
489
+ instructions = if remaining >= instruction_length
490
+ instruction_io.read(instruction_length) || ""
491
+ else
492
+ # Read what we can
493
+ instruction_io.read(remaining) || ""
494
+ end
471
495
  end
472
496
  end
473
497
  end
@@ -501,6 +525,7 @@ module Fontisan
501
525
 
502
526
  if (flag & REPEAT_FLAG) != 0
503
527
  break if io.eof? || (io.size - io.pos) < 1
528
+
504
529
  repeat_count = read_uint8(io)
505
530
  repeat_count.times { flags << flag }
506
531
  end
@@ -529,6 +554,7 @@ module Fontisan
529
554
  # EOF protection
530
555
  if (flag & short_flag) != 0
531
556
  break if io.eof? || (io.size - io.pos) < 1
557
+
532
558
  # Short vector (one byte)
533
559
  delta = read_uint8(io)
534
560
  delta = -delta if (flag & same_or_positive_flag).zero?
@@ -537,6 +563,7 @@ module Fontisan
537
563
  delta = 0
538
564
  else
539
565
  break if io.eof? || (io.size - io.pos) < 2
566
+
540
567
  # Long vector (two bytes, signed)
541
568
  delta = read_int16(io)
542
569
  end
@@ -85,8 +85,7 @@ module Fontisan
85
85
  # Simple struct for storing file path
86
86
  IOSource = Struct.new(:path)
87
87
 
88
- attr_accessor :header, :table_entries, :decompressed_tables, :parsed_tables, :io_source
89
- attr_accessor :underlying_font # Allow both reading and setting for table delegation
88
+ attr_accessor :header, :table_entries, :decompressed_tables, :parsed_tables, :io_source, :underlying_font # Allow both reading and setting for table delegation
90
89
 
91
90
  def initialize
92
91
  @header = nil
@@ -94,7 +93,7 @@ module Fontisan
94
93
  @decompressed_tables = {}
95
94
  @parsed_tables = {}
96
95
  @io_source = nil
97
- @underlying_font = nil # Store the actual TrueTypeFont/OpenTypeFont
96
+ @underlying_font = nil # Store the actual TrueTypeFont/OpenTypeFont
98
97
  end
99
98
 
100
99
  # Initialize storage hashes
@@ -159,7 +158,7 @@ module Fontisan
159
158
  # Get decompressed table data
160
159
  def table_data(tag)
161
160
  # First try underlying font's table data if available
162
- if @underlying_font && @underlying_font.respond_to?(:table_data)
161
+ if @underlying_font.respond_to?(:table_data)
163
162
  underlying_data = @underlying_font.table_data[tag]
164
163
  return underlying_data if underlying_data
165
164
  end
@@ -183,11 +182,13 @@ module Fontisan
183
182
  # Convert to TTF
184
183
  def to_ttf(output_path)
185
184
  unless truetype?
186
- raise InvalidFontError, "Cannot convert to TTF: font is not TrueType flavored"
185
+ raise InvalidFontError,
186
+ "Cannot convert to TTF: font is not TrueType flavored"
187
187
  end
188
188
 
189
189
  # Build SFNT and create TrueTypeFont
190
- sfnt_data = self.class.build_sfnt_in_memory(@header, @table_entries, @decompressed_tables)
190
+ sfnt_data = self.class.build_sfnt_in_memory(@header, @table_entries,
191
+ @decompressed_tables)
191
192
  sfnt_io = StringIO.new(sfnt_data)
192
193
 
193
194
  # Create actual TrueTypeFont and save for table delegation
@@ -201,11 +202,13 @@ module Fontisan
201
202
  # Convert to OTF
202
203
  def to_otf(output_path)
203
204
  unless cff?
204
- raise InvalidFontError, "Cannot convert to OTF: font is not CFF flavored"
205
+ raise InvalidFontError,
206
+ "Cannot convert to OTF: font is not CFF flavored"
205
207
  end
206
208
 
207
209
  # Build SFNT and create OpenTypeFont
208
- sfnt_data = self.class.build_sfnt_in_memory(@header, @table_entries, @decompressed_tables)
210
+ sfnt_data = self.class.build_sfnt_in_memory(@header, @table_entries,
211
+ @decompressed_tables)
209
212
  sfnt_io = StringIO.new(sfnt_data)
210
213
 
211
214
  # Create actual OpenTypeFont and save for table delegation
@@ -310,13 +313,15 @@ module Fontisan
310
313
  woff2.table_entries = read_table_directory_from_io(io, woff2.header)
311
314
 
312
315
  # Decompress table data
313
- woff2.decompressed_tables = decompress_tables(io, woff2.header, woff2.table_entries)
316
+ woff2.decompressed_tables = decompress_tables(io, woff2.header,
317
+ woff2.table_entries)
314
318
 
315
319
  # Apply table transformations if present
316
320
  apply_transformations!(woff2.table_entries, woff2.decompressed_tables)
317
321
 
318
322
  # Build SFNT structure in memory
319
- sfnt_data = build_sfnt_in_memory(woff2.header, woff2.table_entries, woff2.decompressed_tables)
323
+ sfnt_data = build_sfnt_in_memory(woff2.header, woff2.table_entries,
324
+ woff2.decompressed_tables)
320
325
 
321
326
  # Create StringIO for reading
322
327
  sfnt_io = StringIO.new(sfnt_data)
@@ -373,7 +378,10 @@ module Fontisan
373
378
 
374
379
  # Read flags byte with nil check
375
380
  flags_data = io.read(1)
376
- raise EOFError, "Unexpected EOF while reading table directory flags" if flags_data.nil?
381
+ if flags_data.nil?
382
+ raise EOFError,
383
+ "Unexpected EOF while reading table directory flags"
384
+ end
377
385
 
378
386
  flags = flags_data.unpack1("C")
379
387
  entry.flags = flags
@@ -383,7 +391,11 @@ module Fontisan
383
391
  if tag_index == 0x3F
384
392
  # Custom tag (4 bytes)
385
393
  tag_data = io.read(4)
386
- raise EOFError, "Unexpected EOF while reading custom tag" if tag_data.nil? || tag_data.bytesize < 4
394
+ if tag_data.nil? || tag_data.bytesize < 4
395
+ raise EOFError,
396
+ "Unexpected EOF while reading custom tag"
397
+ end
398
+
387
399
  entry.tag = tag_data.force_encoding("UTF-8")
388
400
  else
389
401
  # Known tag from table
@@ -402,7 +414,8 @@ module Fontisan
402
414
  # - hmtx with non-zero version: TRANSFORMED (transformLength present)
403
415
  # - all other tables: transformation version is 0 (no transformLength)
404
416
  transform_version = (flags >> 6) & 0x03
405
- has_transform_length = if ["glyf", "loca"].include?(entry.tag) && transform_version.zero?
417
+ has_transform_length = if ["glyf",
418
+ "loca"].include?(entry.tag) && transform_version.zero?
406
419
  true
407
420
  elsif entry.tag == "hmtx" && transform_version != 0
408
421
  true
@@ -429,7 +442,10 @@ module Fontisan
429
442
  result = 0
430
443
  5.times do
431
444
  byte_data = io.read(1)
432
- raise EOFError, "Unexpected EOF while reading UIntBase128" if byte_data.nil?
445
+ if byte_data.nil?
446
+ raise EOFError,
447
+ "Unexpected EOF while reading UIntBase128"
448
+ end
433
449
 
434
450
  byte = byte_data.unpack1("C")
435
451
 
@@ -512,7 +528,7 @@ module Fontisan
512
528
  result = Woff2::GlyfTransformer.reconstruct(
513
529
  transformed_glyf,
514
530
  num_glyphs,
515
- variable_font: variable_font
531
+ variable_font: variable_font,
516
532
  )
517
533
  decompressed_tables["glyf"] = result[:glyf]
518
534
  decompressed_tables["loca"] = result[:loca]
data/lib/fontisan.rb CHANGED
@@ -107,6 +107,7 @@ require_relative "fontisan/models/validation_report"
107
107
  require_relative "fontisan/models/font_export"
108
108
  require_relative "fontisan/models/collection_font_summary"
109
109
  require_relative "fontisan/models/collection_info"
110
+ require_relative "fontisan/models/collection_brief_info"
110
111
  require_relative "fontisan/models/collection_list_info"
111
112
  require_relative "fontisan/models/font_summary"
112
113
  require_relative "fontisan/models/table_sharing_info"
@@ -149,8 +150,6 @@ require_relative "fontisan/variation/interpolator"
149
150
  require_relative "fontisan/variation/region_matcher"
150
151
  require_relative "fontisan/variation/data_extractor"
151
152
  require_relative "fontisan/variation/instance_generator"
152
- require_relative "fontisan/variation/interpolator"
153
- require_relative "fontisan/variation/region_matcher"
154
153
  require_relative "fontisan/variation/metrics_adjuster"
155
154
  require_relative "fontisan/variation/converter"
156
155
  require_relative "fontisan/variation/variation_preserver"
@@ -172,6 +171,17 @@ require_relative "fontisan/optimizers/charstring_rewriter"
172
171
  require_relative "fontisan/optimizers/subroutine_optimizer"
173
172
  require_relative "fontisan/optimizers/subroutine_generator"
174
173
 
174
+ # Hints infrastructure
175
+ require_relative "fontisan/models/hint"
176
+ require_relative "fontisan/hints/truetype_instruction_analyzer"
177
+ require_relative "fontisan/hints/truetype_instruction_generator"
178
+ require_relative "fontisan/hints/truetype_hint_extractor"
179
+ require_relative "fontisan/hints/truetype_hint_applier"
180
+ require_relative "fontisan/hints/postscript_hint_extractor"
181
+ require_relative "fontisan/hints/postscript_hint_applier"
182
+ require_relative "fontisan/hints/hint_converter"
183
+ require_relative "fontisan/hints/hint_validator"
184
+
175
185
  # Commands
176
186
  require_relative "fontisan/commands/base_command"
177
187
  require_relative "fontisan/commands/info_command"
@@ -209,4 +219,34 @@ module Fontisan
209
219
  self.logger = Logger.new($stdout).tap do |log|
210
220
  log.level = Logger::WARN
211
221
  end
222
+
223
+ # Get font information.
224
+ #
225
+ # Supports both full and brief modes. Brief mode uses metadata loading for
226
+ # 5x faster parsing by loading only essential tables (name, head, hhea,
227
+ # maxp, OS/2, post). Returns FontInfo with 13 essential fields in brief mode
228
+ # or all 38 fields in full mode.
229
+ #
230
+ # @param path [String] Path to font file
231
+ # @param brief [Boolean] Use brief mode for fast identification (default: false)
232
+ # @param font_index [Integer] Index for TTC/OTC files (default: 0)
233
+ # @return [Models::FontInfo, Models::CollectionInfo, Models::CollectionBriefInfo] Font information
234
+ #
235
+ # @example Get full info
236
+ # info = Fontisan.info("font.ttf")
237
+ # puts info.family_name
238
+ # puts info.copyright # populated in full mode
239
+ #
240
+ # @example Get brief info (5x faster)
241
+ # info = Fontisan.info("font.ttf", brief: true)
242
+ # puts info.family_name # populated
243
+ # puts info.postscript_name # populated
244
+ # puts info.copyright # nil (not populated in brief mode)
245
+ #
246
+ # @example Serialize to JSON
247
+ # info = Fontisan.info("font.ttf", brief: true)
248
+ # puts info.to_json
249
+ def self.info(path, brief: false, font_index: 0)
250
+ Commands::InfoCommand.new(path, brief: brief, font_index: font_index).run
251
+ end
212
252
  end