fontisan 0.1.0 → 0.2.0

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 (185) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +529 -65
  3. data/Gemfile +1 -0
  4. data/LICENSE +5 -1
  5. data/README.adoc +1301 -275
  6. data/Rakefile +27 -2
  7. data/benchmark/variation_quick_bench.rb +47 -0
  8. data/docs/EXTRACT_TTC_MIGRATION.md +549 -0
  9. data/fontisan.gemspec +4 -1
  10. data/lib/fontisan/binary/base_record.rb +22 -1
  11. data/lib/fontisan/cli.rb +309 -0
  12. data/lib/fontisan/collection/builder.rb +260 -0
  13. data/lib/fontisan/collection/offset_calculator.rb +227 -0
  14. data/lib/fontisan/collection/table_analyzer.rb +204 -0
  15. data/lib/fontisan/collection/table_deduplicator.rb +241 -0
  16. data/lib/fontisan/collection/writer.rb +306 -0
  17. data/lib/fontisan/commands/base_command.rb +8 -1
  18. data/lib/fontisan/commands/convert_command.rb +291 -0
  19. data/lib/fontisan/commands/export_command.rb +161 -0
  20. data/lib/fontisan/commands/info_command.rb +40 -6
  21. data/lib/fontisan/commands/instance_command.rb +295 -0
  22. data/lib/fontisan/commands/ls_command.rb +113 -0
  23. data/lib/fontisan/commands/pack_command.rb +241 -0
  24. data/lib/fontisan/commands/subset_command.rb +245 -0
  25. data/lib/fontisan/commands/unpack_command.rb +338 -0
  26. data/lib/fontisan/commands/validate_command.rb +178 -0
  27. data/lib/fontisan/commands/variable_command.rb +30 -1
  28. data/lib/fontisan/config/collection_settings.yml +56 -0
  29. data/lib/fontisan/config/conversion_matrix.yml +212 -0
  30. data/lib/fontisan/config/export_settings.yml +66 -0
  31. data/lib/fontisan/config/subset_profiles.yml +100 -0
  32. data/lib/fontisan/config/svg_settings.yml +60 -0
  33. data/lib/fontisan/config/validation_rules.yml +149 -0
  34. data/lib/fontisan/config/variable_settings.yml +99 -0
  35. data/lib/fontisan/config/woff2_settings.yml +77 -0
  36. data/lib/fontisan/constants.rb +69 -0
  37. data/lib/fontisan/converters/conversion_strategy.rb +96 -0
  38. data/lib/fontisan/converters/format_converter.rb +259 -0
  39. data/lib/fontisan/converters/outline_converter.rb +936 -0
  40. data/lib/fontisan/converters/svg_generator.rb +244 -0
  41. data/lib/fontisan/converters/table_copier.rb +117 -0
  42. data/lib/fontisan/converters/woff2_encoder.rb +416 -0
  43. data/lib/fontisan/converters/woff_writer.rb +391 -0
  44. data/lib/fontisan/error.rb +203 -0
  45. data/lib/fontisan/export/exporter.rb +262 -0
  46. data/lib/fontisan/export/table_serializer.rb +255 -0
  47. data/lib/fontisan/export/transformers/font_to_ttx.rb +172 -0
  48. data/lib/fontisan/export/transformers/head_transformer.rb +96 -0
  49. data/lib/fontisan/export/transformers/hhea_transformer.rb +59 -0
  50. data/lib/fontisan/export/transformers/maxp_transformer.rb +63 -0
  51. data/lib/fontisan/export/transformers/name_transformer.rb +63 -0
  52. data/lib/fontisan/export/transformers/os2_transformer.rb +121 -0
  53. data/lib/fontisan/export/transformers/post_transformer.rb +51 -0
  54. data/lib/fontisan/export/ttx_generator.rb +527 -0
  55. data/lib/fontisan/export/ttx_parser.rb +300 -0
  56. data/lib/fontisan/font_loader.rb +121 -12
  57. data/lib/fontisan/font_writer.rb +301 -0
  58. data/lib/fontisan/formatters/text_formatter.rb +102 -0
  59. data/lib/fontisan/glyph_accessor.rb +503 -0
  60. data/lib/fontisan/hints/hint_converter.rb +177 -0
  61. data/lib/fontisan/hints/postscript_hint_applier.rb +185 -0
  62. data/lib/fontisan/hints/postscript_hint_extractor.rb +254 -0
  63. data/lib/fontisan/hints/truetype_hint_applier.rb +71 -0
  64. data/lib/fontisan/hints/truetype_hint_extractor.rb +162 -0
  65. data/lib/fontisan/loading_modes.rb +113 -0
  66. data/lib/fontisan/metrics_calculator.rb +277 -0
  67. data/lib/fontisan/models/collection_font_summary.rb +52 -0
  68. data/lib/fontisan/models/collection_info.rb +76 -0
  69. data/lib/fontisan/models/collection_list_info.rb +37 -0
  70. data/lib/fontisan/models/font_export.rb +158 -0
  71. data/lib/fontisan/models/font_summary.rb +48 -0
  72. data/lib/fontisan/models/glyph_outline.rb +343 -0
  73. data/lib/fontisan/models/hint.rb +233 -0
  74. data/lib/fontisan/models/outline.rb +664 -0
  75. data/lib/fontisan/models/table_sharing_info.rb +40 -0
  76. data/lib/fontisan/models/ttx/glyph_order.rb +31 -0
  77. data/lib/fontisan/models/ttx/tables/binary_table.rb +67 -0
  78. data/lib/fontisan/models/ttx/tables/head_table.rb +74 -0
  79. data/lib/fontisan/models/ttx/tables/hhea_table.rb +74 -0
  80. data/lib/fontisan/models/ttx/tables/maxp_table.rb +55 -0
  81. data/lib/fontisan/models/ttx/tables/name_table.rb +45 -0
  82. data/lib/fontisan/models/ttx/tables/os2_table.rb +157 -0
  83. data/lib/fontisan/models/ttx/tables/post_table.rb +50 -0
  84. data/lib/fontisan/models/ttx/ttfont.rb +49 -0
  85. data/lib/fontisan/models/validation_report.rb +203 -0
  86. data/lib/fontisan/open_type_collection.rb +156 -2
  87. data/lib/fontisan/open_type_font.rb +296 -10
  88. data/lib/fontisan/optimizers/charstring_rewriter.rb +161 -0
  89. data/lib/fontisan/optimizers/pattern_analyzer.rb +308 -0
  90. data/lib/fontisan/optimizers/stack_tracker.rb +246 -0
  91. data/lib/fontisan/optimizers/subroutine_builder.rb +134 -0
  92. data/lib/fontisan/optimizers/subroutine_generator.rb +207 -0
  93. data/lib/fontisan/optimizers/subroutine_optimizer.rb +107 -0
  94. data/lib/fontisan/outline_extractor.rb +423 -0
  95. data/lib/fontisan/subset/builder.rb +268 -0
  96. data/lib/fontisan/subset/glyph_mapping.rb +215 -0
  97. data/lib/fontisan/subset/options.rb +142 -0
  98. data/lib/fontisan/subset/profile.rb +152 -0
  99. data/lib/fontisan/subset/table_subsetter.rb +461 -0
  100. data/lib/fontisan/svg/font_face_generator.rb +278 -0
  101. data/lib/fontisan/svg/font_generator.rb +264 -0
  102. data/lib/fontisan/svg/glyph_generator.rb +168 -0
  103. data/lib/fontisan/svg/view_box_calculator.rb +137 -0
  104. data/lib/fontisan/tables/cff/cff_glyph.rb +176 -0
  105. data/lib/fontisan/tables/cff/charset.rb +282 -0
  106. data/lib/fontisan/tables/cff/charstring.rb +905 -0
  107. data/lib/fontisan/tables/cff/charstring_builder.rb +322 -0
  108. data/lib/fontisan/tables/cff/charstrings_index.rb +162 -0
  109. data/lib/fontisan/tables/cff/dict.rb +351 -0
  110. data/lib/fontisan/tables/cff/dict_builder.rb +242 -0
  111. data/lib/fontisan/tables/cff/encoding.rb +274 -0
  112. data/lib/fontisan/tables/cff/header.rb +102 -0
  113. data/lib/fontisan/tables/cff/index.rb +237 -0
  114. data/lib/fontisan/tables/cff/index_builder.rb +170 -0
  115. data/lib/fontisan/tables/cff/private_dict.rb +284 -0
  116. data/lib/fontisan/tables/cff/top_dict.rb +236 -0
  117. data/lib/fontisan/tables/cff.rb +487 -0
  118. data/lib/fontisan/tables/cff2/blend_operator.rb +240 -0
  119. data/lib/fontisan/tables/cff2/charstring_parser.rb +591 -0
  120. data/lib/fontisan/tables/cff2/operand_stack.rb +232 -0
  121. data/lib/fontisan/tables/cff2.rb +341 -0
  122. data/lib/fontisan/tables/cvar.rb +242 -0
  123. data/lib/fontisan/tables/fvar.rb +2 -2
  124. data/lib/fontisan/tables/glyf/compound_glyph.rb +483 -0
  125. data/lib/fontisan/tables/glyf/compound_glyph_resolver.rb +136 -0
  126. data/lib/fontisan/tables/glyf/curve_converter.rb +343 -0
  127. data/lib/fontisan/tables/glyf/glyph_builder.rb +450 -0
  128. data/lib/fontisan/tables/glyf/simple_glyph.rb +382 -0
  129. data/lib/fontisan/tables/glyf.rb +235 -0
  130. data/lib/fontisan/tables/gvar.rb +270 -0
  131. data/lib/fontisan/tables/hhea.rb +124 -0
  132. data/lib/fontisan/tables/hmtx.rb +287 -0
  133. data/lib/fontisan/tables/hvar.rb +191 -0
  134. data/lib/fontisan/tables/loca.rb +322 -0
  135. data/lib/fontisan/tables/maxp.rb +192 -0
  136. data/lib/fontisan/tables/mvar.rb +185 -0
  137. data/lib/fontisan/tables/name.rb +99 -30
  138. data/lib/fontisan/tables/variation_common.rb +346 -0
  139. data/lib/fontisan/tables/vvar.rb +234 -0
  140. data/lib/fontisan/true_type_collection.rb +156 -2
  141. data/lib/fontisan/true_type_font.rb +297 -11
  142. data/lib/fontisan/utilities/brotli_wrapper.rb +159 -0
  143. data/lib/fontisan/utilities/checksum_calculator.rb +18 -0
  144. data/lib/fontisan/utils/thread_pool.rb +134 -0
  145. data/lib/fontisan/validation/checksum_validator.rb +170 -0
  146. data/lib/fontisan/validation/consistency_validator.rb +197 -0
  147. data/lib/fontisan/validation/structure_validator.rb +198 -0
  148. data/lib/fontisan/validation/table_validator.rb +158 -0
  149. data/lib/fontisan/validation/validator.rb +152 -0
  150. data/lib/fontisan/variable/axis_normalizer.rb +215 -0
  151. data/lib/fontisan/variable/delta_applicator.rb +313 -0
  152. data/lib/fontisan/variable/glyph_delta_processor.rb +218 -0
  153. data/lib/fontisan/variable/instancer.rb +344 -0
  154. data/lib/fontisan/variable/metric_delta_processor.rb +282 -0
  155. data/lib/fontisan/variable/region_matcher.rb +208 -0
  156. data/lib/fontisan/variable/static_font_builder.rb +213 -0
  157. data/lib/fontisan/variable/table_updater.rb +219 -0
  158. data/lib/fontisan/variation/blend_applier.rb +199 -0
  159. data/lib/fontisan/variation/cache.rb +298 -0
  160. data/lib/fontisan/variation/cache_key_builder.rb +162 -0
  161. data/lib/fontisan/variation/converter.rb +268 -0
  162. data/lib/fontisan/variation/data_extractor.rb +86 -0
  163. data/lib/fontisan/variation/delta_applier.rb +266 -0
  164. data/lib/fontisan/variation/delta_parser.rb +228 -0
  165. data/lib/fontisan/variation/inspector.rb +275 -0
  166. data/lib/fontisan/variation/instance_generator.rb +273 -0
  167. data/lib/fontisan/variation/interpolator.rb +231 -0
  168. data/lib/fontisan/variation/metrics_adjuster.rb +318 -0
  169. data/lib/fontisan/variation/optimizer.rb +418 -0
  170. data/lib/fontisan/variation/parallel_generator.rb +150 -0
  171. data/lib/fontisan/variation/region_matcher.rb +221 -0
  172. data/lib/fontisan/variation/subsetter.rb +463 -0
  173. data/lib/fontisan/variation/table_accessor.rb +105 -0
  174. data/lib/fontisan/variation/validator.rb +345 -0
  175. data/lib/fontisan/variation/variation_context.rb +211 -0
  176. data/lib/fontisan/version.rb +1 -1
  177. data/lib/fontisan/woff2/directory.rb +257 -0
  178. data/lib/fontisan/woff2/header.rb +101 -0
  179. data/lib/fontisan/woff2/table_transformer.rb +163 -0
  180. data/lib/fontisan/woff2_font.rb +712 -0
  181. data/lib/fontisan/woff_font.rb +483 -0
  182. data/lib/fontisan.rb +120 -0
  183. data/scripts/compare_stack_aware.rb +187 -0
  184. data/scripts/measure_optimization.rb +141 -0
  185. metadata +205 -4
@@ -0,0 +1,273 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "interpolator"
4
+ require_relative "region_matcher"
5
+ require_relative "metrics_adjuster"
6
+ require_relative "variation_context"
7
+ require_relative "table_accessor"
8
+
9
+ module Fontisan
10
+ module Variation
11
+ # Generates static font instances from variable fonts
12
+ #
13
+ # This class creates static font instances by applying variation deltas
14
+ # at specific design space coordinates. It supports both TrueType (gvar)
15
+ # and PostScript (CFF2) variable fonts.
16
+ #
17
+ # Process:
18
+ # 1. Extract variation data (axes, deltas, regions)
19
+ # 2. Calculate interpolation scalars for given coordinates
20
+ # 3. Apply deltas to outlines (gvar or CFF2)
21
+ # 4. Apply deltas to metrics (HVAR, VVAR, MVAR)
22
+ # 5. Remove variation tables to create static font
23
+ #
24
+ # @example Generating an instance at specific coordinates
25
+ # generator = Fontisan::Variation::InstanceGenerator.new(font, { "wght" => 700.0 })
26
+ # instance_tables = generator.generate
27
+ #
28
+ # @example Generating a named instance
29
+ # generator = Fontisan::Variation::InstanceGenerator.new(font)
30
+ # instance_tables = generator.generate_named_instance(0)
31
+ class InstanceGenerator
32
+ include TableAccessor
33
+
34
+ # @return [TrueTypeFont, OpenTypeFont] Variable font
35
+ attr_reader :font
36
+
37
+ # @return [Hash<String, Float>] Design space coordinates
38
+ attr_reader :coordinates
39
+
40
+ # @return [VariationContext] Variation context
41
+ attr_reader :context
42
+
43
+ # Initialize generator with font and optional coordinates
44
+ #
45
+ # @param font [TrueTypeFont, OpenTypeFont] Variable font
46
+ # @param coordinates [Hash<String, Float>] Design space coordinates (axis tag => value)
47
+ # @param options [Hash] Options
48
+ # @option options [Boolean] :skip_validation Skip context validation (default: false)
49
+ def initialize(font, coordinates = {}, options = {})
50
+ @font = font
51
+ @coordinates = coordinates
52
+
53
+ # Initialize variation context
54
+ @context = VariationContext.new(@font)
55
+ @context.validate! unless options[:skip_validation]
56
+
57
+ # Initialize table cache for lazy loading
58
+ @variation_tables = {}
59
+ end
60
+
61
+ # Generate static font instance
62
+ #
63
+ # Applies variation deltas and returns static font tables.
64
+ #
65
+ # @return [Hash<String, String>] Map of table tags to binary data
66
+ def generate
67
+ # Start with base font tables
68
+ tables = @font.table_data.dup
69
+
70
+ # Determine variation type
71
+ if has_variation_table?("gvar")
72
+ # TrueType outlines with gvar
73
+ apply_gvar_deltas(tables)
74
+ elsif has_variation_table?("CFF2")
75
+ # PostScript outlines with CFF2 blend
76
+ apply_cff2_blend(tables)
77
+ end
78
+
79
+ # Apply metrics variations if present
80
+ apply_metrics_deltas(tables) if @context.has_metrics_variations?
81
+
82
+ # Remove variation-specific tables to create static font
83
+ remove_variation_tables(tables)
84
+
85
+ tables
86
+ end
87
+
88
+ # Generate a named instance
89
+ #
90
+ # @param instance_index [Integer] Index of named instance in fvar table
91
+ # @return [Hash<String, String>] Map of table tags to binary data
92
+ def generate_named_instance(instance_index)
93
+ # Extract instance coordinates from fvar
94
+ return generate if instance_index.nil? || !@context.fvar
95
+
96
+ instances = @context.fvar.instances
97
+ return generate if instance_index >= instances.length
98
+
99
+ instance = instances[instance_index]
100
+ @coordinates = build_coordinates_from_instance(instance, @context.axes)
101
+
102
+ generate
103
+ end
104
+
105
+ # Apply gvar deltas to TrueType outlines
106
+ #
107
+ # @param tables [Hash<String, String>] Font tables
108
+ def apply_gvar_deltas(_tables)
109
+ gvar = variation_table("gvar")
110
+ glyf = @font.table("glyf")
111
+ return unless gvar && glyf
112
+
113
+ # Get glyph count
114
+ maxp = @font.table("maxp")
115
+ glyph_count = maxp ? maxp.num_glyphs : gvar.glyph_count
116
+
117
+ # Process each glyph
118
+ glyph_count.times do |glyph_id|
119
+ apply_glyph_deltas(glyph_id, gvar, glyf)
120
+ end
121
+
122
+ # Rebuild glyf and loca tables with adjusted outlines
123
+ # This is a placeholder - full implementation would reconstruct tables
124
+ end
125
+
126
+ # Apply deltas to a specific glyph
127
+ #
128
+ # @param glyph_id [Integer] Glyph ID
129
+ # @param gvar [Gvar] Gvar table
130
+ # @param glyf [Glyf] Glyf table
131
+ def apply_glyph_deltas(glyph_id, gvar, _glyf)
132
+ # Get tuple variations for this glyph
133
+ tuple_data = gvar.glyph_tuple_variations(glyph_id)
134
+ return unless tuple_data
135
+
136
+ # Match tuples to current coordinates
137
+ matches = @context.region_matcher.match_tuples(
138
+ coordinates: @coordinates,
139
+ tuples: tuple_data[:tuples],
140
+ )
141
+
142
+ nil if matches.empty?
143
+
144
+ # Get base glyph outline
145
+ # Apply matched deltas with their scalars
146
+ # This is a placeholder - full implementation would:
147
+ # 1. Parse glyph outline points
148
+ # 2. Parse delta data for each tuple
149
+ # 3. Apply: new_point = base_point + Σ(delta * scalar)
150
+ # 4. Update glyph outline
151
+ end
152
+
153
+ # Apply CFF2 blend operators
154
+ #
155
+ # @param tables [Hash<String, String>] Font tables
156
+ def apply_cff2_blend(_tables)
157
+ cff2 = variation_table("CFF2")
158
+ return unless cff2
159
+
160
+ # Set number of axes for CFF2
161
+ cff2.num_axes = @context.axis_count
162
+
163
+ # Process each glyph's CharString
164
+ glyph_count = cff2.glyph_count
165
+ return if glyph_count.zero?
166
+
167
+ # Calculate variation scalars once
168
+ calculate_variation_scalars
169
+
170
+ # Apply blend to each glyph
171
+ # This is a placeholder - full implementation would:
172
+ # 1. Parse CharString with blend operators
173
+ # 2. Apply scalars to blend operands
174
+ # 3. Rebuild CharStrings without blend operators
175
+ # 4. Update CFF2 table
176
+ end
177
+
178
+ # Calculate variation scalars for current coordinates
179
+ #
180
+ # @return [Array<Float>] Scalars for each axis
181
+ def calculate_variation_scalars
182
+ @context.axes.map do |axis|
183
+ coord = @coordinates[axis.axis_tag] || axis.default_value
184
+ @context.interpolator.normalize_coordinate(coord, axis.axis_tag)
185
+ end
186
+ end
187
+
188
+ # Apply metrics variations
189
+ #
190
+ # @param tables [Hash<String, String>] Font tables
191
+ def apply_metrics_deltas(tables)
192
+ # Apply HVAR (horizontal metrics)
193
+ apply_hvar_deltas(tables) if has_variation_table?("HVAR")
194
+
195
+ # Apply VVAR (vertical metrics)
196
+ apply_vvar_deltas(tables) if has_variation_table?("VVAR")
197
+
198
+ # Apply MVAR (font-wide metrics)
199
+ apply_mvar_deltas(tables) if has_variation_table?("MVAR")
200
+ end
201
+
202
+ # Apply HVAR deltas to horizontal metrics
203
+ #
204
+ # @param tables [Hash<String, String>] Font tables
205
+ def apply_hvar_deltas(_tables)
206
+ adjuster = MetricsAdjuster.new(@font, @context.interpolator)
207
+ adjuster.apply_hvar_deltas(@coordinates)
208
+ end
209
+
210
+ # Apply VVAR deltas to vertical metrics
211
+ #
212
+ # @param tables [Hash<String, String>] Font tables
213
+ def apply_vvar_deltas(_tables)
214
+ adjuster = MetricsAdjuster.new(@font, @context.interpolator)
215
+ adjuster.apply_vvar_deltas(@coordinates)
216
+ end
217
+
218
+ # Apply MVAR deltas to font-wide metrics
219
+ #
220
+ # @param tables [Hash<String, String>] Font tables
221
+ def apply_mvar_deltas(_tables)
222
+ adjuster = MetricsAdjuster.new(@font, @context.interpolator)
223
+ adjuster.apply_mvar_deltas(@coordinates)
224
+ end
225
+
226
+ # Remove variation tables from static font
227
+ #
228
+ # @param tables [Hash<String, String>] Font tables
229
+ def remove_variation_tables(tables)
230
+ variation_tables = %w[fvar gvar cvar HVAR VVAR MVAR avar STAT]
231
+ variation_tables.each { |tag| tables.delete(tag) }
232
+ end
233
+
234
+ # Interpolate a single value
235
+ #
236
+ # @param base_value [Numeric] Base value
237
+ # @param deltas [Array<Numeric>] Delta values
238
+ # @param scalars [Array<Float>] Region scalars
239
+ # @return [Float] Interpolated value
240
+ def interpolate_value(base_value, deltas, scalars)
241
+ @context.interpolator.interpolate_value(base_value, deltas, scalars)
242
+ end
243
+
244
+ # Interpolate a point
245
+ #
246
+ # @param base_point [Hash] Base point with :x and :y
247
+ # @param delta_points [Array<Hash>] Delta points
248
+ # @param scalars [Array<Float>] Region scalars
249
+ # @return [Hash] Interpolated point
250
+ def interpolate_point(base_point, delta_points, scalars)
251
+ @context.interpolator.interpolate_point(base_point, delta_points, scalars)
252
+ end
253
+
254
+ private
255
+
256
+ # Build coordinates hash from instance
257
+ #
258
+ # @param instance [Hash] Instance data from fvar
259
+ # @param axes [Array<VariationAxisRecord>] Variation axes
260
+ # @return [Hash<String, Float>] Coordinates hash
261
+ def build_coordinates_from_instance(instance, axes)
262
+ coordinates = {}
263
+ instance[:coordinates].each_with_index do |value, index|
264
+ next if index >= axes.length
265
+
266
+ axis = axes[index]
267
+ coordinates[axis.axis_tag] = value
268
+ end
269
+ coordinates
270
+ end
271
+ end
272
+ end
273
+ end
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Variation
5
+ # Coordinate interpolator for variable fonts
6
+ #
7
+ # This class interpolates values in the variation design space by
8
+ # calculating scalars based on the current coordinates and variation
9
+ # regions/tuples.
10
+ #
11
+ # Interpolation Process:
12
+ # 1. Normalize user coordinates to [-1, 1] range based on axis min/default/max
13
+ # 2. For each variation region, calculate a scalar that represents how much
14
+ # that region contributes at the current coordinates
15
+ # 3. Apply the scalars to deltas to get the final interpolated value
16
+ #
17
+ # Region Scalar Calculation:
18
+ # For each axis, given a region [start, peak, end] and coordinate c:
19
+ # - If c < start or c > end: scalar = 0 (outside region)
20
+ # - If c in [start, peak]: scalar = (c - start) / (peak - start)
21
+ # - If c in [peak, end]: scalar = (end - c) / (end - peak)
22
+ # - If c == peak: scalar = 1 (at peak)
23
+ #
24
+ # For multi-axis regions, multiply the per-axis scalars together.
25
+ #
26
+ # Reference: OpenType Font Variations specification
27
+ #
28
+ # @example Interpolating a coordinate
29
+ # interpolator = Interpolator.new(axes)
30
+ # scalar = interpolator.calculate_scalar(
31
+ # coordinates: { "wght" => 600.0 },
32
+ # region: { "wght" => { start: 400, peak: 700, end: 900 } }
33
+ # )
34
+ # # => 0.666... (normalized position between 400 and 700)
35
+ class Interpolator
36
+ # @return [Array<VariationAxisRecord>] Variation axes
37
+ attr_reader :axes
38
+
39
+ # Initialize interpolator
40
+ #
41
+ # @param axes [Array<VariationAxisRecord>] Variation axes from fvar table
42
+ def initialize(axes)
43
+ @axes = axes || []
44
+ end
45
+
46
+ # Normalize a coordinate value to [-1, 1] range
47
+ #
48
+ # @param value [Float] User-space coordinate value
49
+ # @param axis_tag [String] Axis tag (e.g., "wght", "wdth")
50
+ # @return [Float] Normalized coordinate in [-1, 1]
51
+ def normalize_coordinate(value, axis_tag)
52
+ axis = find_axis(axis_tag)
53
+ return 0.0 unless axis
54
+
55
+ # Clamp to axis range
56
+ value = [[value, axis.min_value].max, axis.max_value].min
57
+
58
+ # Normalize to [-1, 1]
59
+ if value < axis.default_value
60
+ # Normalize between min and default (maps to -1..0)
61
+ range = axis.default_value - axis.min_value
62
+ return -1.0 if range.zero?
63
+
64
+ (value - axis.default_value) / range
65
+ elsif value > axis.default_value
66
+ # Normalize between default and max (maps to 0..1)
67
+ range = axis.max_value - axis.default_value
68
+ return 1.0 if range.zero?
69
+
70
+ (value - axis.default_value) / range
71
+ else
72
+ # At default value
73
+ 0.0
74
+ end
75
+ end
76
+
77
+ # Normalize all coordinates
78
+ #
79
+ # @param coordinates [Hash<String, Float>] User-space coordinates
80
+ # @return [Hash<String, Float>] Normalized coordinates
81
+ def normalize_coordinates(coordinates)
82
+ result = {}
83
+ @axes.each do |axis|
84
+ tag = axis.axis_tag
85
+ value = coordinates[tag] || axis.default_value
86
+ result[tag] = normalize_coordinate(value, tag)
87
+ end
88
+ result
89
+ end
90
+
91
+ # Calculate scalar for a single axis region
92
+ #
93
+ # @param coord [Float] Normalized coordinate value [-1, 1]
94
+ # @param region [Hash] Region definition with :start, :peak, :end
95
+ # @return [Float] Scalar value [0, 1]
96
+ def calculate_axis_scalar(coord, region)
97
+ start_val = region[:start] || -1.0
98
+ peak = region[:peak] || 0.0
99
+ end_val = region[:end] || 1.0
100
+
101
+ # Outside region
102
+ return 0.0 if coord < start_val || coord > end_val
103
+
104
+ # At or beyond peak
105
+ return 1.0 if coord == peak
106
+
107
+ # Between start and peak
108
+ if coord < peak
109
+ range = peak - start_val
110
+ return 1.0 if range.zero?
111
+
112
+ (coord - start_val) / range
113
+ else
114
+ # Between peak and end
115
+ range = end_val - peak
116
+ return 1.0 if range.zero?
117
+
118
+ (end_val - coord) / range
119
+ end
120
+ end
121
+
122
+ # Calculate scalar for a multi-axis region
123
+ #
124
+ # For multi-axis regions, the final scalar is the product of per-axis scalars.
125
+ #
126
+ # @param coordinates [Hash<String, Float>] Normalized coordinates
127
+ # @param region [Hash<String, Hash>] Region definition per axis
128
+ # @return [Float] Combined scalar [0, 1]
129
+ def calculate_region_scalar(coordinates, region)
130
+ scalar = 1.0
131
+
132
+ region.each do |axis_tag, axis_region|
133
+ coord = coordinates[axis_tag] || 0.0
134
+ axis_scalar = calculate_axis_scalar(coord, axis_region)
135
+
136
+ # If any axis has zero scalar, entire region has zero contribution
137
+ return 0.0 if axis_scalar.zero?
138
+
139
+ scalar *= axis_scalar
140
+ end
141
+
142
+ scalar
143
+ end
144
+
145
+ # Calculate scalars for all regions
146
+ #
147
+ # @param coordinates [Hash<String, Float>] User-space coordinates
148
+ # @param regions [Array<Hash>] Array of region definitions
149
+ # @return [Array<Float>] Scalars for each region
150
+ def calculate_scalars(coordinates, regions)
151
+ # Normalize coordinates first
152
+ normalized = normalize_coordinates(coordinates)
153
+
154
+ # Calculate scalar for each region
155
+ regions.map do |region|
156
+ calculate_region_scalar(normalized, region)
157
+ end
158
+ end
159
+
160
+ # Interpolate a value using deltas
161
+ #
162
+ # @param base_value [Numeric] Base value
163
+ # @param deltas [Array<Numeric>] Delta values (one per region)
164
+ # @param scalars [Array<Float>] Region scalars (one per region)
165
+ # @return [Float] Interpolated value
166
+ def interpolate_value(base_value, deltas, scalars)
167
+ result = base_value.to_f
168
+
169
+ deltas.each_with_index do |delta, index|
170
+ scalar = scalars[index] || 0.0
171
+ result += delta.to_f * scalar
172
+ end
173
+
174
+ result
175
+ end
176
+
177
+ # Interpolate a point (x, y coordinates)
178
+ #
179
+ # @param base_point [Hash] Base point with :x and :y
180
+ # @param delta_points [Array<Hash>] Delta points (one per region)
181
+ # @param scalars [Array<Float>] Region scalars
182
+ # @return [Hash] Interpolated point with :x and :y
183
+ def interpolate_point(base_point, delta_points, scalars)
184
+ x = base_point[:x].to_f
185
+ y = base_point[:y].to_f
186
+
187
+ delta_points.each_with_index do |delta_point, index|
188
+ scalar = scalars[index] || 0.0
189
+ x += delta_point[:x].to_f * scalar
190
+ y += delta_point[:y].to_f * scalar
191
+ end
192
+
193
+ { x: x, y: y }
194
+ end
195
+
196
+ # Build region from tuple variation data
197
+ #
198
+ # Converts gvar tuple data to the region format used by interpolator
199
+ #
200
+ # @param tuple [Hash] Tuple variation data with :peak, :start, :end
201
+ # @return [Hash<String, Hash>] Region definition per axis
202
+ def build_region_from_tuple(tuple)
203
+ region = {}
204
+
205
+ @axes.each_with_index do |axis, axis_index|
206
+ peak = tuple[:peak] ? tuple[:peak][axis_index] : 0.0
207
+ start_val = tuple[:start] ? tuple[:start][axis_index] : -1.0
208
+ end_val = tuple[:end] ? tuple[:end][axis_index] : 1.0
209
+
210
+ region[axis.axis_tag] = {
211
+ start: start_val,
212
+ peak: peak,
213
+ end: end_val,
214
+ }
215
+ end
216
+
217
+ region
218
+ end
219
+
220
+ private
221
+
222
+ # Find axis by tag
223
+ #
224
+ # @param axis_tag [String] Axis tag
225
+ # @return [VariationAxisRecord, nil] Axis or nil
226
+ def find_axis(axis_tag)
227
+ @axes.find { |axis| axis.axis_tag == axis_tag }
228
+ end
229
+ end
230
+ end
231
+ end