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,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "private_dict_writer"
4
+ require_relative "offset_recalculator"
5
+ require_relative "index_builder"
6
+ require_relative "dict_builder"
7
+ require_relative "charstring_rebuilder"
8
+ require_relative "hint_operation_injector"
9
+ require "stringio"
10
+
11
+ module Fontisan
12
+ module Tables
13
+ class Cff
14
+ # Rebuilds CFF table with modifications
15
+ #
16
+ # This builder extracts sections from a source CFF table, applies
17
+ # modifications (e.g., hint parameters to Private DICT), recalculates
18
+ # offsets, and assembles a new CFF table.
19
+ #
20
+ # Process:
21
+ # 1. Extract all CFF sections (header, indexes, dicts)
22
+ # 2. Apply modifications to Private DICT
23
+ # 3. Recalculate offsets (charstrings, private)
24
+ # 4. Rebuild Top DICT INDEX with new offsets
25
+ # 5. Reassemble all sections into new CFF table
26
+ #
27
+ # @example Rebuild with hints
28
+ # new_cff = TableBuilder.rebuild(source_cff, {
29
+ # private_dict_hints: { blue_values: [-15, 0], std_hw: 70 }
30
+ # })
31
+ class TableBuilder
32
+ # Rebuild CFF table with modifications
33
+ #
34
+ # @param source_cff [Cff] Source CFF table
35
+ # @param modifications [Hash] Modifications to apply
36
+ # @return [String] Binary CFF table data
37
+ def self.rebuild(source_cff, modifications = {})
38
+ new(source_cff).tap do |builder|
39
+ builder.apply_modifications(modifications)
40
+ end.serialize
41
+ end
42
+
43
+ # Initialize with source CFF
44
+ #
45
+ # @param source_cff [Cff] Source CFF table
46
+ def initialize(source_cff)
47
+ @source = source_cff
48
+ @sections = extract_sections
49
+ end
50
+
51
+ # Apply modifications to CFF structure
52
+ #
53
+ # @param mods [Hash] Modifications hash
54
+ def apply_modifications(mods)
55
+ update_private_dict(mods[:private_dict_hints]) if mods[:private_dict_hints]
56
+ update_charstrings(mods[:per_glyph_hints]) if mods[:per_glyph_hints]
57
+ end
58
+
59
+ # Serialize to binary CFF table
60
+ #
61
+ # @return [String] Binary CFF data
62
+ def serialize
63
+ # Calculate initial offsets
64
+ offsets = OffsetRecalculator.calculate_offsets(@sections)
65
+ top_dict = extract_top_dict_data
66
+ updated = OffsetRecalculator.update_top_dict(top_dict, offsets)
67
+ rebuild_top_dict_index(updated)
68
+
69
+ # Recalculate after Top DICT rebuild (size may change)
70
+ offsets = OffsetRecalculator.calculate_offsets(@sections)
71
+ updated = OffsetRecalculator.update_top_dict(top_dict, offsets)
72
+ rebuild_top_dict_index(updated)
73
+
74
+ assemble
75
+ end
76
+
77
+ private
78
+
79
+ # Extract all CFF sections from source
80
+ #
81
+ # @return [Hash] Hash of section_name => binary_data
82
+ def extract_sections
83
+ {
84
+ header: extract_header,
85
+ name_index: extract_index(@source.name_index),
86
+ top_dict_index: extract_index(@source.top_dict_index),
87
+ string_index: extract_index(@source.string_index),
88
+ global_subr_index: extract_index(@source.global_subr_index),
89
+ charstrings_index: extract_index(@source.charstrings_index(0)),
90
+ private_dict: extract_private_dict,
91
+ }
92
+ end
93
+
94
+ # Extract header bytes
95
+ #
96
+ # @return [String] Binary header data
97
+ def extract_header
98
+ @source.raw_data[0, @source.header.hdr_size]
99
+ end
100
+
101
+ # Extract INDEX as binary data
102
+ #
103
+ # @param index [Index] INDEX object
104
+ # @return [String] Binary INDEX data
105
+ def extract_index(index)
106
+ return [0].pack("n") if index.nil? || index.count.zero?
107
+
108
+ start = index.instance_variable_get(:@start_offset)
109
+ io = StringIO.new(@source.raw_data)
110
+ io.seek(start)
111
+
112
+ count = io.read(2).unpack1("n")
113
+ return [0].pack("n") if count.zero?
114
+
115
+ off_size = io.read(1).unpack1("C")
116
+ offset_array_size = (count + 1) * off_size
117
+
118
+ # Read last offset to determine data size
119
+ io.seek(start + 3 + count * off_size)
120
+ last_offset = read_offset(io, off_size)
121
+ data_size = last_offset - 1
122
+
123
+ # Read entire INDEX
124
+ io.seek(start)
125
+ io.read(3 + offset_array_size + data_size)
126
+ end
127
+
128
+ # Extract Private DICT bytes
129
+ #
130
+ # @return [String] Binary Private DICT data
131
+ def extract_private_dict
132
+ priv_info = @source.top_dict(0).private
133
+ return "".b unless priv_info
134
+
135
+ size, offset = priv_info
136
+ @source.raw_data[offset, size]
137
+ end
138
+
139
+ # Update Private DICT with hints
140
+ #
141
+ # @param hints [Hash] Hint parameters
142
+ def update_private_dict(hints)
143
+ source_priv = @source.private_dict(0)
144
+ writer = PrivateDictWriter.new(source_priv)
145
+ writer.update_hints(hints)
146
+ @sections[:private_dict] = writer.serialize
147
+ end
148
+
149
+ # Update CharStrings with per-glyph hints
150
+ #
151
+ # @param per_glyph_hints [Hash] Hash of glyph_id => Array<Hint>
152
+ def update_charstrings(per_glyph_hints)
153
+ return if per_glyph_hints.nil? || per_glyph_hints.empty?
154
+
155
+ # Create CharStringRebuilder
156
+ charstrings_index = @source.charstrings_index(0)
157
+ rebuilder = CharStringRebuilder.new(charstrings_index)
158
+
159
+ # Inject hints for each glyph
160
+ per_glyph_hints.each do |glyph_id, hints|
161
+ injector = HintOperationInjector.new
162
+
163
+ rebuilder.modify_charstring(glyph_id) do |operations|
164
+ # Inject hint operations
165
+ injector.inject(hints, operations)
166
+ end
167
+ end
168
+
169
+ # Rebuild CharStrings INDEX
170
+ @sections[:charstrings_index] = rebuilder.rebuild
171
+ end
172
+
173
+ # Extract Top DICT data as hash
174
+ #
175
+ # @return [Hash] Top DICT parameters
176
+ def extract_top_dict_data
177
+ @source.top_dict(0).to_h
178
+ end
179
+
180
+ # Rebuild Top DICT INDEX with updated data
181
+ #
182
+ # @param data [Hash] Top DICT parameters
183
+ def rebuild_top_dict_index(data)
184
+ dict_bytes = DictBuilder.build(data)
185
+ @sections[:top_dict_index] = IndexBuilder.build([dict_bytes])
186
+ end
187
+
188
+ # Assemble all sections into CFF table
189
+ #
190
+ # @return [String] Binary CFF table
191
+ def assemble
192
+ output = StringIO.new("".b)
193
+ output.write(@sections[:header])
194
+ output.write(@sections[:name_index])
195
+ output.write(@sections[:top_dict_index])
196
+ output.write(@sections[:string_index])
197
+ output.write(@sections[:global_subr_index])
198
+ output.write(@sections[:charstrings_index])
199
+ output.write(@sections[:private_dict])
200
+ output.string
201
+ end
202
+
203
+ # Read offset of specified size
204
+ #
205
+ # @param io [IO] IO object
206
+ # @param size [Integer] Offset size (1-4 bytes)
207
+ # @return [Integer] Offset value
208
+ def read_offset(io, size)
209
+ case size
210
+ when 1 then io.read(1).unpack1("C")
211
+ when 2 then io.read(2).unpack1("n")
212
+ when 3
213
+ bytes = io.read(3).unpack("C*")
214
+ (bytes[0] << 16) | (bytes[1] << 8) | bytes[2]
215
+ when 4 then io.read(4).unpack1("N")
216
+ end
217
+ end
218
+ end
219
+ end
220
+ end
221
+ end
@@ -479,6 +479,8 @@ module Fontisan
479
479
  require_relative "cff/top_dict"
480
480
  require_relative "cff/private_dict"
481
481
  require_relative "cff/charstring"
482
+ require_relative "cff/charstring_parser"
483
+ require_relative "cff/charstring_rebuilder"
482
484
  require_relative "cff/charstrings_index"
483
485
  require_relative "cff/charset"
484
486
  require_relative "cff/encoding"
@@ -68,7 +68,8 @@ module Fontisan
68
68
  # @param global_subrs [Cff::Index, nil] Global subroutines INDEX
69
69
  # @param local_subrs [Cff::Index, nil] Local subroutines INDEX
70
70
  # @param vsindex [Integer] Variation store index (default 0)
71
- def initialize(data, num_axes = 0, global_subrs = nil, local_subrs = nil, vsindex = 0)
71
+ def initialize(data, num_axes = 0, global_subrs = nil,
72
+ local_subrs = nil, vsindex = 0)
72
73
  @data = data
73
74
  @num_axes = num_axes
74
75
  @global_subrs = global_subrs
@@ -123,7 +124,8 @@ module Fontisan
123
124
  when :line_to
124
125
  [:line_to, cmd[:x], cmd[:y]]
125
126
  when :curve_to
126
- [:curve_to, cmd[:x1], cmd[:y1], cmd[:x2], cmd[:y2], cmd[:x], cmd[:y]]
127
+ [:curve_to, cmd[:x1], cmd[:y1], cmd[:x2], cmd[:y2], cmd[:x],
128
+ cmd[:y]]
127
129
  end
128
130
  end
129
131
  end
@@ -146,7 +148,8 @@ module Fontisan
146
148
  end
147
149
  end
148
150
  rescue StandardError => e
149
- raise CorruptedTableError, "Failed to parse CFF2 CharString: #{e.message}"
151
+ raise CorruptedTableError,
152
+ "Failed to parse CFF2 CharString: #{e.message}"
150
153
  end
151
154
 
152
155
  # Check if byte is an operator
@@ -165,7 +168,10 @@ module Fontisan
165
168
  if first_byte == 12
166
169
  # Two-byte operator
167
170
  second_byte = @io.getbyte
168
- raise CorruptedTableError, "Unexpected end of CharString" if second_byte.nil?
171
+ if second_byte.nil?
172
+ raise CorruptedTableError,
173
+ "Unexpected end of CharString"
174
+ end
169
175
 
170
176
  [12, second_byte]
171
177
  else
@@ -305,7 +311,7 @@ module Fontisan
305
311
  # @param blend_op [Hash] Blend operation data
306
312
  # @param coordinates [Hash<String, Float>] Axis coordinates
307
313
  # @return [Array<Float>] Blended values
308
- def apply_blend(blend_op, coordinates)
314
+ def apply_blend(blend_op, _coordinates)
309
315
  blend_op[:blends].map do |blend|
310
316
  base = blend[:base]
311
317
  deltas = blend[:deltas]
@@ -313,7 +319,7 @@ module Fontisan
313
319
  # Apply deltas based on coordinates
314
320
  # This will be enhanced when we have proper coordinate interpolation
315
321
  blended_value = base
316
- deltas.each_with_index do |delta, axis_index|
322
+ deltas.each_with_index do |delta, _axis_index|
317
323
  # Placeholder: use normalized coordinate (will be replaced with proper interpolation)
318
324
  scalar = 0.0 # Will be calculated by interpolator
319
325
  blended_value += delta * scalar
@@ -573,7 +579,7 @@ module Fontisan
573
579
  def callsubr
574
580
  return if @local_subrs.nil? || @stack.empty?
575
581
 
576
- subr_index = @stack.pop
582
+ @stack.pop
577
583
  # Implement subroutine call (placeholder)
578
584
  @stack.clear
579
585
  end
@@ -581,7 +587,7 @@ module Fontisan
581
587
  def callgsubr
582
588
  return if @global_subrs.nil? || @stack.empty?
583
589
 
584
- subr_index = @stack.pop
590
+ @stack.pop
585
591
  # Implement global subroutine call (placeholder)
586
592
  @stack.clear
587
593
  end
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Tables
5
+ class Cff2
6
+ # Private DICT blend handler for CFF2
7
+ #
8
+ # Handles blend operators in Private DICT which allow hint parameters
9
+ # to vary across the design space in variable fonts.
10
+ #
11
+ # Blend in Private DICT format:
12
+ # base_value delta1 delta2 ... deltaN num_axes blend
13
+ #
14
+ # Example for BlueValues with 2 axes:
15
+ # -10 2 1 0 1 0 500 10 5 510 12 6 2 blend
16
+ # This creates BlueValues that vary across the design space.
17
+ #
18
+ # Reference: Adobe Technical Note #5177 (CFF2)
19
+ #
20
+ # @example Parsing blend in Private DICT
21
+ # handler = PrivateDictBlendHandler.new(private_dict)
22
+ # blue_values = handler.parse_blend_array(:blue_values, num_axes: 2)
23
+ class PrivateDictBlendHandler
24
+ # @return [Hash] Private DICT data
25
+ attr_reader :private_dict
26
+
27
+ # Initialize handler with Private DICT data
28
+ #
29
+ # @param private_dict [Hash] Parsed Private DICT
30
+ def initialize(private_dict)
31
+ @private_dict = private_dict
32
+ end
33
+
34
+ # Check if Private DICT contains blend data
35
+ #
36
+ # @return [Boolean] True if blend operators are present
37
+ def has_blend?
38
+ # In a DICT with blend, values are arrays with blend data
39
+ @private_dict.values.any? { |v| blend_value?(v) }
40
+ end
41
+
42
+ # Parse blended array (like BlueValues)
43
+ #
44
+ # @param key [Symbol, Integer] DICT operator key
45
+ # @param num_axes [Integer] Number of variation axes
46
+ # @return [Hash, nil] Parsed blend data or nil if not present
47
+ def parse_blend_array(key, num_axes:)
48
+ value = @private_dict[key]
49
+ return nil unless value.is_a?(Array)
50
+
51
+ # Check if this is blend data
52
+ # Format: base1 delta1_1 ... delta1_N base2 delta2_1 ... delta2_N ...
53
+ # The array must be divisible by (num_axes + 1)
54
+ return nil unless value.size % (num_axes + 1) == 0
55
+
56
+ num_values = value.size / (num_axes + 1)
57
+ blends = []
58
+
59
+ num_values.times do |i|
60
+ offset = i * (num_axes + 1)
61
+ base = value[offset]
62
+ deltas = value[offset + 1, num_axes] || []
63
+
64
+ blends << {
65
+ base: base,
66
+ deltas: deltas,
67
+ }
68
+ end
69
+
70
+ {
71
+ num_values: num_values,
72
+ num_axes: num_axes,
73
+ blends: blends,
74
+ }
75
+ end
76
+
77
+ # Parse single blended value
78
+ #
79
+ # @param key [Symbol, Integer] DICT operator key
80
+ # @param num_axes [Integer] Number of variation axes
81
+ # @return [Hash, nil] Parsed blend data or nil if not present
82
+ def parse_blend_value(key, num_axes:)
83
+ value = @private_dict[key]
84
+ return nil unless value.is_a?(Array)
85
+
86
+ # Single value format: base delta1 delta2 ... deltaN
87
+ expected_size = num_axes + 1
88
+ return nil unless value.size == expected_size
89
+
90
+ {
91
+ base: value[0],
92
+ deltas: value[1..num_axes],
93
+ num_axes: num_axes,
94
+ }
95
+ end
96
+
97
+ # Apply blend at specific coordinates
98
+ #
99
+ # @param blend_data [Hash] Parsed blend data
100
+ # @param scalars [Array<Float>] Region scalars for each axis
101
+ # @return [Array<Float>, Float] Blended values
102
+ def apply_blend(blend_data, scalars)
103
+ return nil unless blend_data
104
+
105
+ if blend_data.key?(:blends)
106
+ # Array of blended values
107
+ blend_data[:blends].map do |blend|
108
+ apply_single_blend(blend, scalars)
109
+ end
110
+ else
111
+ # Single blended value
112
+ apply_single_blend(blend_data, scalars)
113
+ end
114
+ end
115
+
116
+ # Apply blend to a single value
117
+ #
118
+ # @param blend [Hash] Single blend with :base and :deltas
119
+ # @param scalars [Array<Float>] Region scalars
120
+ # @return [Float] Blended value
121
+ def apply_single_blend(blend, scalars)
122
+ base = blend[:base].to_f
123
+ deltas = blend[:deltas]
124
+
125
+ # Apply formula: result = base + Σ(delta[i] * scalar[i])
126
+ result = base
127
+ deltas.each_with_index do |delta, i|
128
+ scalar = scalars[i] || 0.0
129
+ result += delta.to_f * scalar
130
+ end
131
+
132
+ result
133
+ end
134
+
135
+ # Get blended Private DICT values at coordinates
136
+ #
137
+ # @param num_axes [Integer] Number of variation axes
138
+ # @param scalars [Array<Float>] Region scalars
139
+ # @return [Hash] Private DICT with blended values
140
+ def blended_dict(num_axes:, scalars:)
141
+ result = {}
142
+
143
+ @private_dict.each do |key, value|
144
+ if value.is_a?(Array) && blend_value?(value)
145
+ # Try parsing as blend array
146
+ blend_data = parse_blend_array(key, num_axes: num_axes)
147
+ if blend_data
148
+ result[key] = apply_blend(blend_data, scalars)
149
+ else
150
+ # Try as single blend value
151
+ blend_data = parse_blend_value(key, num_axes: num_axes)
152
+ result[key] =
153
+ blend_data ? apply_blend(blend_data, scalars) : value
154
+ end
155
+ else
156
+ # Non-blend value, copy as-is
157
+ result[key] = value
158
+ end
159
+ end
160
+
161
+ result
162
+ end
163
+
164
+ # Check if value looks like blend data
165
+ #
166
+ # @param value [Object] Value to check
167
+ # @return [Boolean] True if value could be blend data
168
+ def blend_value?(value)
169
+ # Blend values are arrays with multiple elements
170
+ value.is_a?(Array) && value.size > 1
171
+ end
172
+
173
+ # Rebuild Private DICT with hints injected
174
+ #
175
+ # This method prepares Private DICT for rebuilding, preserving
176
+ # blend operators while incorporating new hint values.
177
+ #
178
+ # @param hints [Hash] Hint values to inject
179
+ # @param num_axes [Integer] Number of variation axes
180
+ # @return [Hash] Modified Private DICT
181
+ def rebuild_with_hints(hints, num_axes:)
182
+ result = @private_dict.dup
183
+
184
+ # Inject hint values
185
+ hints.each do |key, value|
186
+ if value.is_a?(Hash) && (value.key?(:base) || value.key?("base")) && (value.key?(:deltas) || value.key?("deltas"))
187
+ # Hint with blend data - normalize and flatten for DICT storage
188
+ normalized_value = {
189
+ base: value[:base] || value["base"],
190
+ deltas: value[:deltas] || value["deltas"],
191
+ }
192
+ result[key] = flatten_blend(normalized_value, num_axes: num_axes)
193
+ else
194
+ # Simple hint value
195
+ result[key] = value
196
+ end
197
+ end
198
+
199
+ result
200
+ end
201
+
202
+ # Flatten blend data to array format
203
+ #
204
+ # @param blend_data [Hash] Blend data with :base and :deltas
205
+ # @param num_axes [Integer] Number of variation axes
206
+ # @return [Array] Flattened array
207
+ def flatten_blend(blend_data, num_axes:)
208
+ if blend_data.key?(:blends)
209
+ # Array of blends
210
+ blend_data[:blends].flat_map do |blend|
211
+ [blend[:base]] + blend[:deltas]
212
+ end
213
+ else
214
+ # Single blend
215
+ [blend_data[:base]] + blend_data[:deltas]
216
+ end
217
+ end
218
+
219
+ # Validate blend data structure
220
+ #
221
+ # @param num_axes [Integer] Expected number of axes
222
+ # @return [Array<String>] Validation errors (empty if valid)
223
+ def validate(num_axes:)
224
+ errors = []
225
+
226
+ @private_dict.each do |key, value|
227
+ next unless value.is_a?(Array)
228
+ next unless blend_value?(value)
229
+
230
+ # Try parsing as blend array
231
+ blend_data = parse_blend_array(key, num_axes: num_axes)
232
+ unless blend_data
233
+ # Try as single blend value
234
+ blend_data = parse_blend_value(key, num_axes: num_axes)
235
+ unless blend_data
236
+ errors << "Key #{key} has array value that doesn't match " \
237
+ "blend format for #{num_axes} axes"
238
+ end
239
+ end
240
+ end
241
+
242
+ errors
243
+ end
244
+ end
245
+ end
246
+ end
247
+ end