fontisan 0.2.0 → 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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +119 -308
  3. data/README.adoc +1525 -1323
  4. data/Rakefile +45 -47
  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 +92 -34
  9. data/lib/fontisan/collection/builder.rb +82 -0
  10. data/lib/fontisan/collection/offset_calculator.rb +2 -0
  11. data/lib/fontisan/collection/table_deduplicator.rb +76 -0
  12. data/lib/fontisan/commands/base_command.rb +21 -2
  13. data/lib/fontisan/commands/convert_command.rb +96 -165
  14. data/lib/fontisan/commands/info_command.rb +111 -5
  15. data/lib/fontisan/commands/instance_command.rb +77 -85
  16. data/lib/fontisan/commands/validate_command.rb +28 -0
  17. data/lib/fontisan/config/validation_rules.yml +1 -1
  18. data/lib/fontisan/constants.rb +34 -24
  19. data/lib/fontisan/converters/format_converter.rb +154 -1
  20. data/lib/fontisan/converters/outline_converter.rb +101 -34
  21. data/lib/fontisan/converters/woff_writer.rb +9 -4
  22. data/lib/fontisan/font_loader.rb +14 -9
  23. data/lib/fontisan/font_writer.rb +9 -6
  24. data/lib/fontisan/formatters/text_formatter.rb +45 -1
  25. data/lib/fontisan/hints/hint_converter.rb +131 -2
  26. data/lib/fontisan/hints/hint_validator.rb +284 -0
  27. data/lib/fontisan/hints/postscript_hint_applier.rb +219 -140
  28. data/lib/fontisan/hints/postscript_hint_extractor.rb +151 -16
  29. data/lib/fontisan/hints/truetype_hint_applier.rb +90 -44
  30. data/lib/fontisan/hints/truetype_hint_extractor.rb +134 -11
  31. data/lib/fontisan/hints/truetype_instruction_analyzer.rb +261 -0
  32. data/lib/fontisan/hints/truetype_instruction_generator.rb +266 -0
  33. data/lib/fontisan/loading_modes.rb +6 -4
  34. data/lib/fontisan/models/collection_brief_info.rb +31 -0
  35. data/lib/fontisan/models/font_info.rb +3 -30
  36. data/lib/fontisan/models/hint.rb +183 -12
  37. data/lib/fontisan/models/outline.rb +4 -1
  38. data/lib/fontisan/open_type_font.rb +28 -10
  39. data/lib/fontisan/open_type_font_extensions.rb +54 -0
  40. data/lib/fontisan/optimizers/pattern_analyzer.rb +2 -1
  41. data/lib/fontisan/optimizers/subroutine_generator.rb +1 -1
  42. data/lib/fontisan/pipeline/format_detector.rb +249 -0
  43. data/lib/fontisan/pipeline/output_writer.rb +159 -0
  44. data/lib/fontisan/pipeline/strategies/base_strategy.rb +75 -0
  45. data/lib/fontisan/pipeline/strategies/instance_strategy.rb +93 -0
  46. data/lib/fontisan/pipeline/strategies/named_strategy.rb +118 -0
  47. data/lib/fontisan/pipeline/strategies/preserve_strategy.rb +56 -0
  48. data/lib/fontisan/pipeline/transformation_pipeline.rb +416 -0
  49. data/lib/fontisan/pipeline/variation_resolver.rb +165 -0
  50. data/lib/fontisan/subset/table_subsetter.rb +5 -5
  51. data/lib/fontisan/tables/cff/charstring.rb +58 -3
  52. data/lib/fontisan/tables/cff/charstring_builder.rb +34 -0
  53. data/lib/fontisan/tables/cff/charstring_parser.rb +249 -0
  54. data/lib/fontisan/tables/cff/charstring_rebuilder.rb +172 -0
  55. data/lib/fontisan/tables/cff/dict_builder.rb +19 -1
  56. data/lib/fontisan/tables/cff/hint_operation_injector.rb +209 -0
  57. data/lib/fontisan/tables/cff/offset_recalculator.rb +70 -0
  58. data/lib/fontisan/tables/cff/private_dict_writer.rb +131 -0
  59. data/lib/fontisan/tables/cff/table_builder.rb +221 -0
  60. data/lib/fontisan/tables/cff.rb +2 -0
  61. data/lib/fontisan/tables/cff2/charstring_parser.rb +14 -8
  62. data/lib/fontisan/tables/cff2/private_dict_blend_handler.rb +247 -0
  63. data/lib/fontisan/tables/cff2/region_matcher.rb +200 -0
  64. data/lib/fontisan/tables/cff2/table_builder.rb +580 -0
  65. data/lib/fontisan/tables/cff2/table_reader.rb +421 -0
  66. data/lib/fontisan/tables/cff2/variation_data_extractor.rb +212 -0
  67. data/lib/fontisan/tables/cff2.rb +10 -5
  68. data/lib/fontisan/tables/cvar.rb +2 -41
  69. data/lib/fontisan/tables/glyf/compound_glyph_resolver.rb +2 -1
  70. data/lib/fontisan/tables/glyf/curve_converter.rb +10 -4
  71. data/lib/fontisan/tables/glyf/glyph_builder.rb +27 -10
  72. data/lib/fontisan/tables/gvar.rb +2 -41
  73. data/lib/fontisan/tables/name.rb +4 -4
  74. data/lib/fontisan/true_type_font.rb +27 -10
  75. data/lib/fontisan/true_type_font_extensions.rb +54 -0
  76. data/lib/fontisan/utilities/checksum_calculator.rb +42 -0
  77. data/lib/fontisan/validation/checksum_validator.rb +2 -2
  78. data/lib/fontisan/validation/table_validator.rb +1 -1
  79. data/lib/fontisan/validation/variable_font_validator.rb +218 -0
  80. data/lib/fontisan/variation/cache.rb +3 -1
  81. data/lib/fontisan/variation/converter.rb +121 -13
  82. data/lib/fontisan/variation/delta_applier.rb +2 -1
  83. data/lib/fontisan/variation/inspector.rb +2 -1
  84. data/lib/fontisan/variation/instance_generator.rb +2 -1
  85. data/lib/fontisan/variation/instance_writer.rb +341 -0
  86. data/lib/fontisan/variation/optimizer.rb +6 -3
  87. data/lib/fontisan/variation/subsetter.rb +32 -10
  88. data/lib/fontisan/variation/tuple_variation_header.rb +51 -0
  89. data/lib/fontisan/variation/variable_svg_generator.rb +268 -0
  90. data/lib/fontisan/variation/variation_preserver.rb +291 -0
  91. data/lib/fontisan/version.rb +1 -1
  92. data/lib/fontisan/version.rb.orig +9 -0
  93. data/lib/fontisan/woff2/glyf_transformer.rb +693 -0
  94. data/lib/fontisan/woff2/hmtx_transformer.rb +164 -0
  95. data/lib/fontisan/woff2_font.rb +489 -468
  96. data/lib/fontisan/woff_font.rb +16 -11
  97. data/lib/fontisan.rb +54 -2
  98. data/scripts/measure_optimization.rb +15 -7
  99. metadata +37 -2
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Validation
5
+ # VariableFontValidator validates variable font structure
6
+ #
7
+ # Validates:
8
+ # - fvar table structure
9
+ # - Axis definitions and ranges
10
+ # - Instance definitions
11
+ # - Variation table consistency
12
+ # - Metrics variation tables
13
+ #
14
+ # @example Validate a variable font
15
+ # validator = VariableFontValidator.new(font)
16
+ # errors = validator.validate
17
+ # puts "Found #{errors.length} errors" if errors.any?
18
+ class VariableFontValidator
19
+ # Initialize validator with font
20
+ #
21
+ # @param font [TrueTypeFont, OpenTypeFont] Font to validate
22
+ def initialize(font)
23
+ @font = font
24
+ @errors = []
25
+ end
26
+
27
+ # Validate variable font
28
+ #
29
+ # @return [Array<String>] Array of error messages
30
+ def validate
31
+ return [] unless @font.has_table?("fvar")
32
+
33
+ validate_fvar_structure
34
+ validate_axes
35
+ validate_instances
36
+ validate_variation_tables
37
+ validate_metrics_variation
38
+
39
+ @errors
40
+ end
41
+
42
+ private
43
+
44
+ # Validate fvar table structure
45
+ #
46
+ # @return [void]
47
+ def validate_fvar_structure
48
+ fvar = @font.table("fvar")
49
+ return unless fvar
50
+
51
+ if !fvar.respond_to?(:axes) || fvar.axes.nil? || fvar.axes.empty?
52
+ @errors << "fvar: No axes defined"
53
+ return
54
+ end
55
+
56
+ if fvar.respond_to?(:axis_count) && fvar.axis_count != fvar.axes.length
57
+ @errors << "fvar: Axis count mismatch (expected #{fvar.axis_count}, got #{fvar.axes.length})"
58
+ end
59
+ end
60
+
61
+ # Validate all axes
62
+ #
63
+ # @return [void]
64
+ def validate_axes
65
+ fvar = @font.table("fvar")
66
+ return unless fvar.respond_to?(:axes)
67
+
68
+ fvar.axes.each_with_index do |axis, index|
69
+ validate_axis_range(axis, index)
70
+ validate_axis_tag(axis, index)
71
+ end
72
+ end
73
+
74
+ # Validate axis range values
75
+ #
76
+ # @param axis [Object] Axis object
77
+ # @param index [Integer] Axis index
78
+ # @return [void]
79
+ def validate_axis_range(axis, index)
80
+ return unless axis.respond_to?(:min_value) && axis.respond_to?(:max_value)
81
+
82
+ if axis.min_value > axis.max_value
83
+ tag = axis.respond_to?(:axis_tag) ? axis.axis_tag : "axis #{index}"
84
+ @errors << "Axis #{tag}: min_value (#{axis.min_value}) > max_value (#{axis.max_value})"
85
+ end
86
+
87
+ if axis.respond_to?(:default_value) && (axis.default_value < axis.min_value || axis.default_value > axis.max_value)
88
+ tag = axis.respond_to?(:axis_tag) ? axis.axis_tag : "axis #{index}"
89
+ @errors << "Axis #{tag}: default_value (#{axis.default_value}) out of range [#{axis.min_value}, #{axis.max_value}]"
90
+ end
91
+ end
92
+
93
+ # Validate axis tag format
94
+ #
95
+ # @param axis [Object] Axis object
96
+ # @param index [Integer] Axis index
97
+ # @return [void]
98
+ def validate_axis_tag(axis, index)
99
+ return unless axis.respond_to?(:axis_tag)
100
+
101
+ tag = axis.axis_tag
102
+ unless tag.is_a?(String) && tag.length == 4 && tag =~ /^[a-zA-Z]{4}$/
103
+ @errors << "Axis #{index}: invalid tag '#{tag}' (must be 4 ASCII letters)"
104
+ end
105
+ end
106
+
107
+ # Validate named instances
108
+ #
109
+ # @return [void]
110
+ def validate_instances
111
+ fvar = @font.table("fvar")
112
+ return unless fvar.respond_to?(:instances)
113
+ return unless fvar.instances
114
+
115
+ fvar.instances.each_with_index do |instance, idx|
116
+ validate_instance_coordinates(instance, idx, fvar)
117
+ end
118
+ end
119
+
120
+ # Validate instance coordinates
121
+ #
122
+ # @param instance [Object] Instance object
123
+ # @param idx [Integer] Instance index
124
+ # @param fvar [Object] fvar table
125
+ # @return [void]
126
+ def validate_instance_coordinates(instance, idx, fvar)
127
+ return unless instance.is_a?(Hash) && instance[:coordinates]
128
+
129
+ coords = instance[:coordinates]
130
+ axis_count = fvar.respond_to?(:axis_count) ? fvar.axis_count : fvar.axes.length
131
+
132
+ if coords.length != axis_count
133
+ @errors << "Instance #{idx}: coordinate count mismatch (expected #{axis_count}, got #{coords.length})"
134
+ end
135
+
136
+ coords.each_with_index do |value, axis_idx|
137
+ next if axis_idx >= fvar.axes.length
138
+
139
+ axis = fvar.axes[axis_idx]
140
+ next unless axis.respond_to?(:min_value) && axis.respond_to?(:max_value)
141
+
142
+ if value < axis.min_value || value > axis.max_value
143
+ tag = axis.respond_to?(:axis_tag) ? axis.axis_tag : "axis #{axis_idx}"
144
+ @errors << "Instance #{idx}: coordinate for #{tag} (#{value}) out of range [#{axis.min_value}, #{axis.max_value}]"
145
+ end
146
+ end
147
+ end
148
+
149
+ # Validate variation tables
150
+ #
151
+ # @return [void]
152
+ def validate_variation_tables
153
+ has_gvar = @font.has_table?("gvar")
154
+ has_cff2 = @font.has_table?("CFF2")
155
+ has_glyf = @font.has_table?("glyf")
156
+ has_cff = @font.has_table?("CFF ")
157
+
158
+ # TrueType variable fonts should have gvar
159
+ if has_glyf && !has_gvar
160
+ @errors << "TrueType variable font missing gvar table"
161
+ end
162
+
163
+ # CFF variable fonts should have CFF2
164
+ if has_cff && !has_cff2
165
+ @errors << "CFF variable font missing CFF2 table"
166
+ end
167
+
168
+ # Can't have both gvar and CFF2
169
+ if has_gvar && has_cff2
170
+ @errors << "Font has both gvar and CFF2 tables (incompatible)"
171
+ end
172
+ end
173
+
174
+ # Validate metrics variation tables
175
+ #
176
+ # @return [void]
177
+ def validate_metrics_variation
178
+ validate_hvar if @font.has_table?("HVAR")
179
+ validate_vvar if @font.has_table?("VVAR")
180
+ validate_mvar if @font.has_table?("MVAR")
181
+ end
182
+
183
+ # Validate HVAR table
184
+ #
185
+ # @return [void]
186
+ def validate_hvar
187
+ # HVAR validation would go here
188
+ # For now, just check it exists
189
+ hvar = @font.table_data["HVAR"]
190
+ if hvar.nil? || hvar.empty?
191
+ @errors << "HVAR table is empty"
192
+ end
193
+ end
194
+
195
+ # Validate VVAR table
196
+ #
197
+ # @return [void]
198
+ def validate_vvar
199
+ # VVAR validation would go here
200
+ vvar = @font.table_data["VVAR"]
201
+ if vvar.nil? || vvar.empty?
202
+ @errors << "VVAR table is empty"
203
+ end
204
+ end
205
+
206
+ # Validate MVAR table
207
+ #
208
+ # @return [void]
209
+ def validate_mvar
210
+ # MVAR validation would go here
211
+ mvar = @font.table_data["MVAR"]
212
+ if mvar.nil? || mvar.empty?
213
+ @errors << "MVAR table is empty"
214
+ end
215
+ end
216
+ end
217
+ end
218
+ end
@@ -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
@@ -24,13 +24,13 @@ module Fontisan
24
24
  # 4. Build gvar table structure
25
25
  #
26
26
  # @example Converting gvar to CFF2 blend
27
- # converter = VariationConverter.new(font, axes)
27
+ # converter = Converter.new(font, axes)
28
28
  # blend_data = converter.gvar_to_blend(glyph_id)
29
29
  #
30
30
  # @example Converting CFF2 blend to gvar
31
- # converter = VariationConverter.new(font, axes)
31
+ # converter = Converter.new(font, axes)
32
32
  # tuple_data = converter.blend_to_gvar(glyph_id)
33
- class VariationConverter
33
+ class Converter
34
34
  include TableAccessor
35
35
 
36
36
  # @return [TrueTypeFont, OpenTypeFont] Font instance
@@ -72,19 +72,49 @@ module Fontisan
72
72
  #
73
73
  # @param glyph_id [Integer] Glyph ID
74
74
  # @return [Hash, nil] Tuple data or nil
75
- def blend_to_gvar(_glyph_id)
75
+ def blend_to_gvar(glyph_id)
76
76
  return nil unless has_variation_table?("CFF2")
77
77
 
78
78
  cff2 = variation_table("CFF2")
79
79
  return nil unless cff2
80
80
 
81
81
  # Get CharString with blend operators
82
- # This is a placeholder - full implementation would parse CharString
83
- # and extract blend operator data
82
+ charstring = cff2.charstring_for_glyph(glyph_id)
83
+ return nil unless charstring
84
84
 
85
- # Convert blend data to tuples
86
- # Placeholder for full implementation
87
- nil
85
+ # Parse CharString to extract blend data
86
+ charstring.parse unless charstring.instance_variable_get(:@parsed)
87
+ blend_data = charstring.blend_data
88
+ return nil if blend_data.nil? || blend_data.empty?
89
+
90
+ # Convert blend data to tuple format
91
+ convert_blend_to_tuples_for_glyph(blend_data)
92
+ end
93
+
94
+ # Convert all glyphs from gvar to blend format
95
+ #
96
+ # @param glyph_count [Integer] Number of glyphs
97
+ # @return [Hash<Integer, Hash>] Map of glyph_id to blend data
98
+ def convert_all_gvar_to_blend(glyph_count)
99
+ return {} unless can_convert?
100
+
101
+ (0...glyph_count).each_with_object({}) do |glyph_id, result|
102
+ blend_data = gvar_to_blend(glyph_id)
103
+ result[glyph_id] = blend_data if blend_data
104
+ end
105
+ end
106
+
107
+ # Convert all glyphs from blend to gvar format
108
+ #
109
+ # @param glyph_count [Integer] Number of glyphs
110
+ # @return [Hash<Integer, Hash>] Map of glyph_id to tuple data
111
+ def convert_all_blend_to_gvar(glyph_count)
112
+ return {} unless can_convert?
113
+
114
+ (0...glyph_count).each_with_object({}) do |glyph_id, result|
115
+ tuple_data = blend_to_gvar(glyph_id)
116
+ result[glyph_id] = tuple_data if tuple_data
117
+ end
88
118
  end
89
119
 
90
120
  # Check if variation data can be converted
@@ -99,6 +129,77 @@ module Fontisan
99
129
 
100
130
  private
101
131
 
132
+ # Convert blend data from a glyph to tuple format
133
+ #
134
+ # @param blend_data [Array<Hash>] Array of blend operations
135
+ # @return [Hash] Tuple variation data
136
+ def convert_blend_to_tuples_for_glyph(blend_data)
137
+ # Each blend operation represents variation at different points
138
+ # We need to aggregate these into region-based tuples
139
+
140
+ # Extract all regions from blend operations
141
+ regions_map = {}
142
+ point_count = 0
143
+
144
+ blend_data.each_with_index do |blend_op, idx|
145
+ blend_op[:blends].each do |blend|
146
+ # Track the maximum point index we've seen
147
+ point_count = [point_count, idx + 1].max
148
+
149
+ # For each delta axis, we need to create or update a region
150
+ blend[:deltas].each_with_index do |delta, axis_index|
151
+ next if delta.zero? # Skip zero deltas
152
+
153
+ # Create region key based on unique delta pattern
154
+ region_key = "region_#{axis_index}"
155
+
156
+ regions_map[region_key] ||= {
157
+ axis_index: axis_index,
158
+ deltas_per_point: Array.new(point_count) { { x: 0, y: 0 } },
159
+ }
160
+
161
+ # Store this delta for this point
162
+ # Note: CFF2 blend deltas are per-coordinate, we need to map to x/y
163
+ # This is a simplified mapping - full implementation would track
164
+ # which coordinates are being varied
165
+ regions_map[region_key][:deltas_per_point][idx / 2] ||= { x: 0,
166
+ y: 0 }
167
+ if idx.even?
168
+ regions_map[region_key][:deltas_per_point][idx / 2][:x] = delta
169
+ else
170
+ regions_map[region_key][:deltas_per_point][idx / 2][:y] = delta
171
+ end
172
+ end
173
+ end
174
+ end
175
+
176
+ # Convert regions to tuples
177
+ tuples = []
178
+ regions_map.each_value do |region_data|
179
+ axis_index = region_data[:axis_index]
180
+
181
+ # Build peak coordinates (one per axis)
182
+ peak = Array.new(@axes.length, 0.0)
183
+ peak[axis_index] = 1.0 if axis_index < @axes.length
184
+
185
+ # Build start/end (default full range)
186
+ start_vals = Array.new(@axes.length, -1.0)
187
+ end_vals = Array.new(@axes.length, 1.0)
188
+
189
+ tuples << {
190
+ peak: peak,
191
+ start: start_vals,
192
+ end: end_vals,
193
+ deltas: region_data[:deltas_per_point],
194
+ }
195
+ end
196
+
197
+ {
198
+ tuples: tuples,
199
+ point_count: point_count,
200
+ }
201
+ end
202
+
102
203
  # Convert tuple variations to blend format
103
204
  #
104
205
  # @param tuple_data [Hash] Tuple variation data from gvar
@@ -172,12 +273,19 @@ module Fontisan
172
273
  # @param tuple [Hash] Tuple data
173
274
  # @param point_count [Integer] Number of points
174
275
  # @return [Array<Hash>] Deltas with :x and :y
175
- def parse_tuple_deltas(_tuple, point_count)
176
- # This is a placeholder - full implementation would:
177
- # 1. Parse delta data from tuple
276
+ def parse_tuple_deltas(tuple, point_count)
277
+ # If tuple has deltas array, use it
278
+ if tuple[:deltas].is_a?(Array)
279
+ return tuple[:deltas].map do |delta|
280
+ { x: delta[:x] || 0, y: delta[:y] || 0 }
281
+ end
282
+ end
283
+
284
+ # Otherwise return zeros (placeholder for parsing raw delta data)
285
+ # Full implementation would:
286
+ # 1. Parse delta data from tuple[:data]
178
287
  # 2. Decompress if needed
179
288
  # 3. Return array of { x: dx, y: dy } for each point
180
-
181
289
  Array.new(point_count) { { x: 0, y: 0 } }
182
290
  end
183
291
 
@@ -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