fontisan 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +270 -131
  3. data/README.adoc +158 -4
  4. data/Rakefile +44 -47
  5. data/lib/fontisan/cli.rb +84 -33
  6. data/lib/fontisan/collection/builder.rb +81 -0
  7. data/lib/fontisan/collection/table_deduplicator.rb +76 -0
  8. data/lib/fontisan/commands/base_command.rb +16 -0
  9. data/lib/fontisan/commands/convert_command.rb +97 -170
  10. data/lib/fontisan/commands/instance_command.rb +71 -80
  11. data/lib/fontisan/commands/validate_command.rb +25 -0
  12. data/lib/fontisan/config/validation_rules.yml +1 -1
  13. data/lib/fontisan/constants.rb +10 -0
  14. data/lib/fontisan/converters/format_converter.rb +150 -1
  15. data/lib/fontisan/converters/outline_converter.rb +80 -18
  16. data/lib/fontisan/converters/woff_writer.rb +1 -1
  17. data/lib/fontisan/font_loader.rb +3 -5
  18. data/lib/fontisan/font_writer.rb +7 -6
  19. data/lib/fontisan/hints/hint_converter.rb +133 -0
  20. data/lib/fontisan/hints/postscript_hint_applier.rb +221 -140
  21. data/lib/fontisan/hints/postscript_hint_extractor.rb +100 -0
  22. data/lib/fontisan/hints/truetype_hint_applier.rb +90 -44
  23. data/lib/fontisan/hints/truetype_hint_extractor.rb +127 -0
  24. data/lib/fontisan/loading_modes.rb +2 -0
  25. data/lib/fontisan/models/font_export.rb +2 -2
  26. data/lib/fontisan/models/hint.rb +173 -1
  27. data/lib/fontisan/models/validation_report.rb +1 -1
  28. data/lib/fontisan/open_type_font.rb +25 -9
  29. data/lib/fontisan/open_type_font_extensions.rb +54 -0
  30. data/lib/fontisan/pipeline/format_detector.rb +249 -0
  31. data/lib/fontisan/pipeline/output_writer.rb +154 -0
  32. data/lib/fontisan/pipeline/strategies/base_strategy.rb +75 -0
  33. data/lib/fontisan/pipeline/strategies/instance_strategy.rb +93 -0
  34. data/lib/fontisan/pipeline/strategies/named_strategy.rb +118 -0
  35. data/lib/fontisan/pipeline/strategies/preserve_strategy.rb +56 -0
  36. data/lib/fontisan/pipeline/transformation_pipeline.rb +411 -0
  37. data/lib/fontisan/pipeline/variation_resolver.rb +165 -0
  38. data/lib/fontisan/tables/cff/charstring.rb +33 -4
  39. data/lib/fontisan/tables/cff/charstring_builder.rb +34 -0
  40. data/lib/fontisan/tables/cff/charstring_parser.rb +237 -0
  41. data/lib/fontisan/tables/cff/charstring_rebuilder.rb +172 -0
  42. data/lib/fontisan/tables/cff/dict_builder.rb +15 -0
  43. data/lib/fontisan/tables/cff/hint_operation_injector.rb +207 -0
  44. data/lib/fontisan/tables/cff/offset_recalculator.rb +70 -0
  45. data/lib/fontisan/tables/cff/private_dict_writer.rb +125 -0
  46. data/lib/fontisan/tables/cff/table_builder.rb +221 -0
  47. data/lib/fontisan/tables/cff.rb +2 -0
  48. data/lib/fontisan/tables/cff2/private_dict_blend_handler.rb +246 -0
  49. data/lib/fontisan/tables/cff2/region_matcher.rb +200 -0
  50. data/lib/fontisan/tables/cff2/table_builder.rb +574 -0
  51. data/lib/fontisan/tables/cff2/table_reader.rb +419 -0
  52. data/lib/fontisan/tables/cff2/variation_data_extractor.rb +212 -0
  53. data/lib/fontisan/tables/cff2.rb +9 -4
  54. data/lib/fontisan/tables/cvar.rb +2 -41
  55. data/lib/fontisan/tables/gvar.rb +2 -41
  56. data/lib/fontisan/true_type_font.rb +24 -9
  57. data/lib/fontisan/true_type_font_extensions.rb +54 -0
  58. data/lib/fontisan/utilities/checksum_calculator.rb +42 -0
  59. data/lib/fontisan/validation/checksum_validator.rb +2 -2
  60. data/lib/fontisan/validation/table_validator.rb +1 -1
  61. data/lib/fontisan/validation/variable_font_validator.rb +218 -0
  62. data/lib/fontisan/variation/converter.rb +120 -13
  63. data/lib/fontisan/variation/instance_writer.rb +341 -0
  64. data/lib/fontisan/variation/tuple_variation_header.rb +51 -0
  65. data/lib/fontisan/variation/variable_svg_generator.rb +268 -0
  66. data/lib/fontisan/variation/variation_preserver.rb +288 -0
  67. data/lib/fontisan/version.rb +1 -1
  68. data/lib/fontisan/version.rb.orig +9 -0
  69. data/lib/fontisan/woff2/glyf_transformer.rb +666 -0
  70. data/lib/fontisan/woff2/hmtx_transformer.rb +164 -0
  71. data/lib/fontisan/woff2_font.rb +475 -470
  72. data/lib/fontisan/woff_font.rb +16 -11
  73. data/lib/fontisan.rb +12 -0
  74. metadata +31 -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"
@@ -0,0 +1,246 @@
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] = blend_data ? apply_blend(blend_data, scalars) : value
153
+ end
154
+ else
155
+ # Non-blend value, copy as-is
156
+ result[key] = value
157
+ end
158
+ end
159
+
160
+ result
161
+ end
162
+
163
+ # Check if value looks like blend data
164
+ #
165
+ # @param value [Object] Value to check
166
+ # @return [Boolean] True if value could be blend data
167
+ def blend_value?(value)
168
+ # Blend values are arrays with multiple elements
169
+ value.is_a?(Array) && value.size > 1
170
+ end
171
+
172
+ # Rebuild Private DICT with hints injected
173
+ #
174
+ # This method prepares Private DICT for rebuilding, preserving
175
+ # blend operators while incorporating new hint values.
176
+ #
177
+ # @param hints [Hash] Hint values to inject
178
+ # @param num_axes [Integer] Number of variation axes
179
+ # @return [Hash] Modified Private DICT
180
+ def rebuild_with_hints(hints, num_axes:)
181
+ result = @private_dict.dup
182
+
183
+ # Inject hint values
184
+ hints.each do |key, value|
185
+ if value.is_a?(Hash) && (value.key?(:base) || value.key?("base")) && (value.key?(:deltas) || value.key?("deltas"))
186
+ # Hint with blend data - normalize and flatten for DICT storage
187
+ normalized_value = {
188
+ base: value[:base] || value["base"],
189
+ deltas: value[:deltas] || value["deltas"]
190
+ }
191
+ result[key] = flatten_blend(normalized_value, num_axes: num_axes)
192
+ else
193
+ # Simple hint value
194
+ result[key] = value
195
+ end
196
+ end
197
+
198
+ result
199
+ end
200
+
201
+ # Flatten blend data to array format
202
+ #
203
+ # @param blend_data [Hash] Blend data with :base and :deltas
204
+ # @param num_axes [Integer] Number of variation axes
205
+ # @return [Array] Flattened array
206
+ def flatten_blend(blend_data, num_axes:)
207
+ if blend_data.key?(:blends)
208
+ # Array of blends
209
+ blend_data[:blends].flat_map do |blend|
210
+ [blend[:base]] + blend[:deltas]
211
+ end
212
+ else
213
+ # Single blend
214
+ [blend_data[:base]] + blend_data[:deltas]
215
+ end
216
+ end
217
+
218
+ # Validate blend data structure
219
+ #
220
+ # @param num_axes [Integer] Expected number of axes
221
+ # @return [Array<String>] Validation errors (empty if valid)
222
+ def validate(num_axes:)
223
+ errors = []
224
+
225
+ @private_dict.each do |key, value|
226
+ next unless value.is_a?(Array)
227
+ next unless blend_value?(value)
228
+
229
+ # Try parsing as blend array
230
+ blend_data = parse_blend_array(key, num_axes: num_axes)
231
+ unless blend_data
232
+ # Try as single blend value
233
+ blend_data = parse_blend_value(key, num_axes: num_axes)
234
+ unless blend_data
235
+ errors << "Key #{key} has array value that doesn't match " \
236
+ "blend format for #{num_axes} axes"
237
+ end
238
+ end
239
+ end
240
+
241
+ errors
242
+ end
243
+ end
244
+ end
245
+ end
246
+ end
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Tables
5
+ class Cff2
6
+ # Region matcher for calculating variation scalars
7
+ #
8
+ # Maps design space coordinates to region scalars based on
9
+ # the Variable Store region definitions. Each region defines
10
+ # a range (start, peak, end) for each variation axis.
11
+ #
12
+ # Scalar Calculation:
13
+ # - If coordinate is at peak: scalar = 1.0
14
+ # - If coordinate is between start and peak: linear interpolation
15
+ # - If coordinate is between peak and end: linear interpolation
16
+ # - If coordinate is outside [start, end]: scalar = 0.0
17
+ #
18
+ # Reference: OpenType Font Variations Overview
19
+ # Reference: Adobe Technical Note #5177 (CFF2)
20
+ #
21
+ # @example Calculating scalars
22
+ # matcher = RegionMatcher.new(regions)
23
+ # scalars = matcher.calculate_scalars({ "wght" => 0.5, "wdth" => 0.3 })
24
+ class RegionMatcher
25
+ # @return [Array<Hash>] Regions from Variable Store
26
+ attr_reader :regions
27
+
28
+ # Initialize matcher with regions
29
+ #
30
+ # @param regions [Array<Hash>] Region definitions from Variable Store
31
+ def initialize(regions)
32
+ @regions = regions
33
+ end
34
+
35
+ # Calculate scalars for all regions at given coordinates
36
+ #
37
+ # Coordinates are normalized values in the range [-1.0, 1.0]
38
+ # where 0.0 represents the default/regular style.
39
+ #
40
+ # @param coordinates [Array<Float>] Normalized coordinates per axis
41
+ # @return [Array<Float>] Scalars for each region
42
+ def calculate_scalars(coordinates)
43
+ @regions.map do |region|
44
+ calculate_region_scalar(region, coordinates)
45
+ end
46
+ end
47
+
48
+ # Calculate scalar for a single region
49
+ #
50
+ # The scalar is the product of scalars for all axes in the region.
51
+ # If any axis has scalar 0.0, the entire region scalar is 0.0.
52
+ #
53
+ # @param region [Hash] Region definition
54
+ # @param coordinates [Array<Float>] Normalized coordinates per axis
55
+ # @return [Float] Scalar for the region (0.0 to 1.0)
56
+ def calculate_region_scalar(region, coordinates)
57
+ axes = region[:axes]
58
+
59
+ # Multiply scalars for all axes
60
+ scalar = 1.0
61
+ axes.each_with_index do |axis, i|
62
+ coord = coordinates[i] || 0.0
63
+ axis_scalar = calculate_axis_scalar(axis, coord)
64
+ scalar *= axis_scalar
65
+
66
+ # Early exit if any axis is out of range
67
+ return 0.0 if axis_scalar.zero?
68
+ end
69
+
70
+ scalar
71
+ end
72
+
73
+ # Calculate scalar for a single axis
74
+ #
75
+ # @param axis [Hash] Axis definition with :start_coord, :peak_coord, :end_coord
76
+ # @param coordinate [Float] Normalized coordinate for this axis
77
+ # @return [Float] Scalar for this axis (0.0 to 1.0)
78
+ def calculate_axis_scalar(axis, coordinate)
79
+ start_coord = axis[:start_coord]
80
+ peak_coord = axis[:peak_coord]
81
+ end_coord = axis[:end_coord]
82
+
83
+ # Outside the region
84
+ return 0.0 if coordinate < start_coord || coordinate > end_coord
85
+
86
+ # At or beyond peak
87
+ return 1.0 if coordinate == peak_coord
88
+
89
+ # Between start and peak
90
+ if coordinate < peak_coord
91
+ # Linear interpolation: (coord - start) / (peak - start)
92
+ range = peak_coord - start_coord
93
+ return 1.0 if range.zero? # Avoid division by zero
94
+
95
+ (coordinate - start_coord) / range
96
+ else
97
+ # Between peak and end
98
+ # Linear interpolation: (end - coord) / (end - peak)
99
+ range = end_coord - peak_coord
100
+ return 1.0 if range.zero? # Avoid division by zero
101
+
102
+ (end_coord - coordinate) / range
103
+ end
104
+ end
105
+
106
+ # Check if coordinates are within any region
107
+ #
108
+ # @param coordinates [Array<Float>] Normalized coordinates
109
+ # @return [Boolean] True if coordinates activate any region
110
+ def coordinates_active?(coordinates)
111
+ scalars = calculate_scalars(coordinates)
112
+ scalars.any?(&:positive?)
113
+ end
114
+
115
+ # Get active regions for coordinates
116
+ #
117
+ # Returns indices of regions that have non-zero scalars
118
+ #
119
+ # @param coordinates [Array<Float>] Normalized coordinates
120
+ # @return [Array<Integer>] Indices of active regions
121
+ def active_regions(coordinates)
122
+ scalars = calculate_scalars(coordinates)
123
+ scalars.each_with_index.select { |scalar, _| scalar.positive? }
124
+ .map(&:last)
125
+ end
126
+
127
+ # Get scalar for specific region index
128
+ #
129
+ # @param region_index [Integer] Region index
130
+ # @param coordinates [Array<Float>] Normalized coordinates
131
+ # @return [Float, nil] Scalar for the region, or nil if index invalid
132
+ def scalar_for_region(region_index, coordinates)
133
+ return nil if region_index >= @regions.size
134
+
135
+ region = @regions[region_index]
136
+ calculate_region_scalar(region, coordinates)
137
+ end
138
+
139
+ # Validate region structure
140
+ #
141
+ # @return [Array<String>] Array of validation errors (empty if valid)
142
+ def validate
143
+ errors = []
144
+
145
+ @regions.each_with_index do |region, i|
146
+ axes = region[:axes]
147
+ unless axes.is_a?(Array)
148
+ errors << "Region #{i} has invalid axes (not an array)"
149
+ next
150
+ end
151
+
152
+ axes.each_with_index do |axis, j|
153
+ unless axis.is_a?(Hash)
154
+ errors << "Region #{i}, axis #{j} is not a hash"
155
+ next
156
+ end
157
+
158
+ # Check required keys
159
+ %i[start_coord peak_coord end_coord].each do |key|
160
+ unless axis.key?(key)
161
+ errors << "Region #{i}, axis #{j} missing #{key}"
162
+ end
163
+ end
164
+
165
+ # Validate coordinate ordering
166
+ if axis[:start_coord] && axis[:peak_coord] && axis[:end_coord]
167
+ start = axis[:start_coord]
168
+ peak = axis[:peak_coord]
169
+ ending = axis[:end_coord]
170
+
171
+ unless start <= peak && peak <= ending
172
+ errors << "Region #{i}, axis #{j} has invalid ordering: " \
173
+ "#{start} > #{peak} > #{ending}"
174
+ end
175
+ end
176
+ end
177
+ end
178
+
179
+ errors
180
+ end
181
+
182
+ # Get number of axes from first region
183
+ #
184
+ # @return [Integer] Number of axes
185
+ def axis_count
186
+ return 0 if @regions.empty?
187
+
188
+ @regions.first[:axis_count] || @regions.first[:axes]&.size || 0
189
+ end
190
+
191
+ # Check if matcher has regions
192
+ #
193
+ # @return [Boolean] True if regions are present
194
+ def has_regions?
195
+ !@regions.empty?
196
+ end
197
+ end
198
+ end
199
+ end
200
+ end