fontisan 0.1.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 (214) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +672 -69
  3. data/Gemfile +1 -0
  4. data/LICENSE +5 -1
  5. data/README.adoc +1477 -297
  6. data/Rakefile +63 -41
  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 +364 -4
  12. data/lib/fontisan/collection/builder.rb +341 -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 +317 -0
  16. data/lib/fontisan/collection/writer.rb +306 -0
  17. data/lib/fontisan/commands/base_command.rb +24 -1
  18. data/lib/fontisan/commands/convert_command.rb +218 -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 +286 -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 +203 -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 +79 -0
  37. data/lib/fontisan/converters/conversion_strategy.rb +96 -0
  38. data/lib/fontisan/converters/format_converter.rb +408 -0
  39. data/lib/fontisan/converters/outline_converter.rb +998 -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 +122 -15
  57. data/lib/fontisan/font_writer.rb +302 -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 +310 -0
  61. data/lib/fontisan/hints/postscript_hint_applier.rb +266 -0
  62. data/lib/fontisan/hints/postscript_hint_extractor.rb +354 -0
  63. data/lib/fontisan/hints/truetype_hint_applier.rb +117 -0
  64. data/lib/fontisan/hints/truetype_hint_extractor.rb +289 -0
  65. data/lib/fontisan/loading_modes.rb +115 -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 +405 -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 +321 -19
  88. data/lib/fontisan/open_type_font_extensions.rb +54 -0
  89. data/lib/fontisan/optimizers/charstring_rewriter.rb +161 -0
  90. data/lib/fontisan/optimizers/pattern_analyzer.rb +308 -0
  91. data/lib/fontisan/optimizers/stack_tracker.rb +246 -0
  92. data/lib/fontisan/optimizers/subroutine_builder.rb +134 -0
  93. data/lib/fontisan/optimizers/subroutine_generator.rb +207 -0
  94. data/lib/fontisan/optimizers/subroutine_optimizer.rb +107 -0
  95. data/lib/fontisan/outline_extractor.rb +423 -0
  96. data/lib/fontisan/pipeline/format_detector.rb +249 -0
  97. data/lib/fontisan/pipeline/output_writer.rb +154 -0
  98. data/lib/fontisan/pipeline/strategies/base_strategy.rb +75 -0
  99. data/lib/fontisan/pipeline/strategies/instance_strategy.rb +93 -0
  100. data/lib/fontisan/pipeline/strategies/named_strategy.rb +118 -0
  101. data/lib/fontisan/pipeline/strategies/preserve_strategy.rb +56 -0
  102. data/lib/fontisan/pipeline/transformation_pipeline.rb +411 -0
  103. data/lib/fontisan/pipeline/variation_resolver.rb +165 -0
  104. data/lib/fontisan/subset/builder.rb +268 -0
  105. data/lib/fontisan/subset/glyph_mapping.rb +215 -0
  106. data/lib/fontisan/subset/options.rb +142 -0
  107. data/lib/fontisan/subset/profile.rb +152 -0
  108. data/lib/fontisan/subset/table_subsetter.rb +461 -0
  109. data/lib/fontisan/svg/font_face_generator.rb +278 -0
  110. data/lib/fontisan/svg/font_generator.rb +264 -0
  111. data/lib/fontisan/svg/glyph_generator.rb +168 -0
  112. data/lib/fontisan/svg/view_box_calculator.rb +137 -0
  113. data/lib/fontisan/tables/cff/cff_glyph.rb +176 -0
  114. data/lib/fontisan/tables/cff/charset.rb +282 -0
  115. data/lib/fontisan/tables/cff/charstring.rb +934 -0
  116. data/lib/fontisan/tables/cff/charstring_builder.rb +356 -0
  117. data/lib/fontisan/tables/cff/charstring_parser.rb +237 -0
  118. data/lib/fontisan/tables/cff/charstring_rebuilder.rb +172 -0
  119. data/lib/fontisan/tables/cff/charstrings_index.rb +162 -0
  120. data/lib/fontisan/tables/cff/dict.rb +351 -0
  121. data/lib/fontisan/tables/cff/dict_builder.rb +257 -0
  122. data/lib/fontisan/tables/cff/encoding.rb +274 -0
  123. data/lib/fontisan/tables/cff/header.rb +102 -0
  124. data/lib/fontisan/tables/cff/hint_operation_injector.rb +207 -0
  125. data/lib/fontisan/tables/cff/index.rb +237 -0
  126. data/lib/fontisan/tables/cff/index_builder.rb +170 -0
  127. data/lib/fontisan/tables/cff/offset_recalculator.rb +70 -0
  128. data/lib/fontisan/tables/cff/private_dict.rb +284 -0
  129. data/lib/fontisan/tables/cff/private_dict_writer.rb +125 -0
  130. data/lib/fontisan/tables/cff/table_builder.rb +221 -0
  131. data/lib/fontisan/tables/cff/top_dict.rb +236 -0
  132. data/lib/fontisan/tables/cff.rb +489 -0
  133. data/lib/fontisan/tables/cff2/blend_operator.rb +240 -0
  134. data/lib/fontisan/tables/cff2/charstring_parser.rb +591 -0
  135. data/lib/fontisan/tables/cff2/operand_stack.rb +232 -0
  136. data/lib/fontisan/tables/cff2/private_dict_blend_handler.rb +246 -0
  137. data/lib/fontisan/tables/cff2/region_matcher.rb +200 -0
  138. data/lib/fontisan/tables/cff2/table_builder.rb +574 -0
  139. data/lib/fontisan/tables/cff2/table_reader.rb +419 -0
  140. data/lib/fontisan/tables/cff2/variation_data_extractor.rb +212 -0
  141. data/lib/fontisan/tables/cff2.rb +346 -0
  142. data/lib/fontisan/tables/cvar.rb +203 -0
  143. data/lib/fontisan/tables/fvar.rb +2 -2
  144. data/lib/fontisan/tables/glyf/compound_glyph.rb +483 -0
  145. data/lib/fontisan/tables/glyf/compound_glyph_resolver.rb +136 -0
  146. data/lib/fontisan/tables/glyf/curve_converter.rb +343 -0
  147. data/lib/fontisan/tables/glyf/glyph_builder.rb +450 -0
  148. data/lib/fontisan/tables/glyf/simple_glyph.rb +382 -0
  149. data/lib/fontisan/tables/glyf.rb +235 -0
  150. data/lib/fontisan/tables/gvar.rb +231 -0
  151. data/lib/fontisan/tables/hhea.rb +124 -0
  152. data/lib/fontisan/tables/hmtx.rb +287 -0
  153. data/lib/fontisan/tables/hvar.rb +191 -0
  154. data/lib/fontisan/tables/loca.rb +322 -0
  155. data/lib/fontisan/tables/maxp.rb +192 -0
  156. data/lib/fontisan/tables/mvar.rb +185 -0
  157. data/lib/fontisan/tables/name.rb +99 -30
  158. data/lib/fontisan/tables/variation_common.rb +346 -0
  159. data/lib/fontisan/tables/vvar.rb +234 -0
  160. data/lib/fontisan/true_type_collection.rb +156 -2
  161. data/lib/fontisan/true_type_font.rb +321 -20
  162. data/lib/fontisan/true_type_font_extensions.rb +54 -0
  163. data/lib/fontisan/utilities/brotli_wrapper.rb +159 -0
  164. data/lib/fontisan/utilities/checksum_calculator.rb +60 -0
  165. data/lib/fontisan/utils/thread_pool.rb +134 -0
  166. data/lib/fontisan/validation/checksum_validator.rb +170 -0
  167. data/lib/fontisan/validation/consistency_validator.rb +197 -0
  168. data/lib/fontisan/validation/structure_validator.rb +198 -0
  169. data/lib/fontisan/validation/table_validator.rb +158 -0
  170. data/lib/fontisan/validation/validator.rb +152 -0
  171. data/lib/fontisan/validation/variable_font_validator.rb +218 -0
  172. data/lib/fontisan/variable/axis_normalizer.rb +215 -0
  173. data/lib/fontisan/variable/delta_applicator.rb +313 -0
  174. data/lib/fontisan/variable/glyph_delta_processor.rb +218 -0
  175. data/lib/fontisan/variable/instancer.rb +344 -0
  176. data/lib/fontisan/variable/metric_delta_processor.rb +282 -0
  177. data/lib/fontisan/variable/region_matcher.rb +208 -0
  178. data/lib/fontisan/variable/static_font_builder.rb +213 -0
  179. data/lib/fontisan/variable/table_updater.rb +219 -0
  180. data/lib/fontisan/variation/blend_applier.rb +199 -0
  181. data/lib/fontisan/variation/cache.rb +298 -0
  182. data/lib/fontisan/variation/cache_key_builder.rb +162 -0
  183. data/lib/fontisan/variation/converter.rb +375 -0
  184. data/lib/fontisan/variation/data_extractor.rb +86 -0
  185. data/lib/fontisan/variation/delta_applier.rb +266 -0
  186. data/lib/fontisan/variation/delta_parser.rb +228 -0
  187. data/lib/fontisan/variation/inspector.rb +275 -0
  188. data/lib/fontisan/variation/instance_generator.rb +273 -0
  189. data/lib/fontisan/variation/instance_writer.rb +341 -0
  190. data/lib/fontisan/variation/interpolator.rb +231 -0
  191. data/lib/fontisan/variation/metrics_adjuster.rb +318 -0
  192. data/lib/fontisan/variation/optimizer.rb +418 -0
  193. data/lib/fontisan/variation/parallel_generator.rb +150 -0
  194. data/lib/fontisan/variation/region_matcher.rb +221 -0
  195. data/lib/fontisan/variation/subsetter.rb +463 -0
  196. data/lib/fontisan/variation/table_accessor.rb +105 -0
  197. data/lib/fontisan/variation/tuple_variation_header.rb +51 -0
  198. data/lib/fontisan/variation/validator.rb +345 -0
  199. data/lib/fontisan/variation/variable_svg_generator.rb +268 -0
  200. data/lib/fontisan/variation/variation_context.rb +211 -0
  201. data/lib/fontisan/variation/variation_preserver.rb +288 -0
  202. data/lib/fontisan/version.rb +1 -1
  203. data/lib/fontisan/version.rb.orig +9 -0
  204. data/lib/fontisan/woff2/directory.rb +257 -0
  205. data/lib/fontisan/woff2/glyf_transformer.rb +666 -0
  206. data/lib/fontisan/woff2/header.rb +101 -0
  207. data/lib/fontisan/woff2/hmtx_transformer.rb +164 -0
  208. data/lib/fontisan/woff2/table_transformer.rb +163 -0
  209. data/lib/fontisan/woff2_font.rb +717 -0
  210. data/lib/fontisan/woff_font.rb +488 -0
  211. data/lib/fontisan.rb +132 -0
  212. data/scripts/compare_stack_aware.rb +187 -0
  213. data/scripts/measure_optimization.rb +141 -0
  214. metadata +234 -4
@@ -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
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Fontisan
6
+ module Variable
7
+ # Normalizes user coordinates to design space
8
+ #
9
+ # Converts user-provided axis coordinates (e.g., wght=700) to normalized
10
+ # values in the range -1.0 to 1.0 based on axis definitions from the fvar table.
11
+ #
12
+ # The normalization algorithm follows the OpenType specification:
13
+ # - For values below default: normalized = (value - default) / (default - min)
14
+ # - For values above default: normalized = (value - default) / (max - default)
15
+ # - Values are clamped to the -1.0 to 1.0 range
16
+ #
17
+ # @example Normalize coordinates
18
+ # normalizer = AxisNormalizer.new(fvar_table)
19
+ # normalized = normalizer.normalize({ "wght" => 700, "wdth" => 100 })
20
+ # # => { "wght" => 0.5, "wdth" => 0.0 }
21
+ class AxisNormalizer
22
+ # @return [Hash] Configuration settings
23
+ attr_reader :config
24
+
25
+ # @return [Hash] Axis definitions from fvar table
26
+ attr_reader :axes
27
+
28
+ # Initialize the normalizer
29
+ #
30
+ # @param fvar [Fontisan::Tables::Fvar] Font variations table
31
+ # @param config [Hash] Optional configuration overrides
32
+ def initialize(fvar, config = {})
33
+ @fvar = fvar
34
+ @config = load_config.merge(config)
35
+ @axes = build_axis_map
36
+ end
37
+
38
+ # Normalize user coordinates to design space
39
+ #
40
+ # @param user_coords [Hash<String, Numeric>] User coordinates by axis tag
41
+ # @return [Hash<String, Float>] Normalized coordinates (-1.0 to 1.0)
42
+ def normalize(user_coords)
43
+ result = {}
44
+
45
+ @axes.each do |tag, axis_info|
46
+ user_value = user_coords[tag] || user_coords[tag.to_sym]
47
+
48
+ # Use default if not provided and config allows
49
+ if user_value.nil?
50
+ user_value = if @config.dig(:coordinate_normalization,
51
+ :use_axis_defaults)
52
+ axis_info[:default]
53
+ else
54
+ next
55
+ end
56
+ end
57
+
58
+ # Validate and clamp if configured
59
+ validated_value = validate_coordinate(user_value, axis_info)
60
+
61
+ # Normalize the value
62
+ normalized = normalize_value(validated_value, axis_info)
63
+
64
+ result[tag] = normalized
65
+ end
66
+
67
+ result
68
+ end
69
+
70
+ # Normalize a single axis value
71
+ #
72
+ # @param value [Numeric] User coordinate value
73
+ # @param axis_tag [String] Axis tag
74
+ # @return [Float] Normalized value (-1.0 to 1.0)
75
+ def normalize_axis(value, axis_tag)
76
+ axis_info = @axes[axis_tag]
77
+ raise ArgumentError, "Unknown axis: #{axis_tag}" unless axis_info
78
+
79
+ validated_value = validate_coordinate(value, axis_info)
80
+ normalize_value(validated_value, axis_info)
81
+ end
82
+
83
+ # Get axis information
84
+ #
85
+ # @param axis_tag [String] Axis tag
86
+ # @return [Hash, nil] Axis information or nil
87
+ def axis_info(axis_tag)
88
+ @axes[axis_tag]
89
+ end
90
+
91
+ # Get all axis tags
92
+ #
93
+ # @return [Array<String>] Array of axis tags
94
+ def axis_tags
95
+ @axes.keys
96
+ end
97
+
98
+ private
99
+
100
+ # Load configuration from YAML file
101
+ #
102
+ # @return [Hash] Configuration hash
103
+ def load_config
104
+ config_path = File.join(__dir__, "..", "config",
105
+ "variable_settings.yml")
106
+ loaded = YAML.load_file(config_path)
107
+ # Convert string keys to symbol keys for consistency
108
+ deep_symbolize_keys(loaded)
109
+ rescue StandardError
110
+ # Return default config if file doesn't exist
111
+ {
112
+ coordinate_normalization: {
113
+ normalize: true,
114
+ use_axis_defaults: true,
115
+ normalized_precision: 6,
116
+ },
117
+ delta_application: {
118
+ validate_coordinates: true,
119
+ clamp_coordinates: true,
120
+ },
121
+ }
122
+ end
123
+
124
+ # Recursively convert hash keys to symbols
125
+ #
126
+ # @param hash [Hash] Hash with string keys
127
+ # @return [Hash] Hash with symbol keys
128
+ def deep_symbolize_keys(hash)
129
+ hash.each_with_object({}) do |(key, value), result|
130
+ new_key = key.to_sym
131
+ new_value = value.is_a?(Hash) ? deep_symbolize_keys(value) : value
132
+ result[new_key] = new_value
133
+ end
134
+ end
135
+
136
+ # Build axis information map from fvar table
137
+ #
138
+ # @return [Hash<String, Hash>] Map of axis tag to axis info
139
+ def build_axis_map
140
+ return {} unless @fvar
141
+
142
+ @fvar.axes.each_with_object({}) do |axis, hash|
143
+ # Convert BinData::String to regular Ruby String for proper Hash key behavior
144
+ tag = axis.axis_tag.to_s
145
+ hash[tag] = {
146
+ min: axis.min_value,
147
+ default: axis.default_value,
148
+ max: axis.max_value,
149
+ name_id: axis.axis_name_id,
150
+ }
151
+ end
152
+ end
153
+
154
+ # Validate and optionally clamp coordinate value
155
+ #
156
+ # @param value [Numeric] User coordinate value
157
+ # @param axis_info [Hash] Axis information
158
+ # @return [Float] Validated value
159
+ def validate_coordinate(value, axis_info)
160
+ value = value.to_f
161
+
162
+ # Check if validation is enabled
163
+ if @config.dig(:delta_application, :validate_coordinates)
164
+ min = axis_info[:min]
165
+ max = axis_info[:max]
166
+
167
+ # Clamp if configured
168
+ if @config.dig(:delta_application, :clamp_coordinates)
169
+ value = [[value, min].max, max].min
170
+ elsif value < min || value > max
171
+ raise ArgumentError,
172
+ "Coordinate #{value} out of range [#{min}, #{max}]"
173
+ end
174
+ end
175
+
176
+ value
177
+ end
178
+
179
+ # Normalize a value to -1.0 to 1.0 range
180
+ #
181
+ # @param value [Float] User coordinate value
182
+ # @param axis_info [Hash] Axis information
183
+ # @return [Float] Normalized value
184
+ def normalize_value(value, axis_info)
185
+ default = axis_info[:default]
186
+
187
+ # Value at default is always 0.0
188
+ return 0.0 if (value - default).abs < Float::EPSILON
189
+
190
+ if value < default
191
+ # Below default: negative range
192
+ min = axis_info[:min]
193
+ range = default - min
194
+
195
+ else
196
+ # Above default: positive range
197
+ max = axis_info[:max]
198
+ range = max - default
199
+
200
+ end
201
+ return 0.0 if range.abs < Float::EPSILON
202
+
203
+ normalized = (value - default) / range
204
+
205
+ # Clamp to -1.0 to 1.0
206
+ normalized = [[-1.0, normalized].max, 1.0].min
207
+
208
+ # Apply precision
209
+ precision = @config.dig(:coordinate_normalization,
210
+ :normalized_precision) || 6
211
+ normalized.round(precision)
212
+ end
213
+ end
214
+ end
215
+ end