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,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Fontisan
6
+ module Validation
7
+ # TableValidator validates the presence and correctness of font tables
8
+ #
9
+ # This validator checks that all required tables are present in the font
10
+ # based on the font type (TrueType, OpenType/CFF, Variable) and validates
11
+ # table-specific properties like versioning.
12
+ #
13
+ # Single Responsibility: Table presence and table-level validation
14
+ #
15
+ # @example Validating tables
16
+ # validator = TableValidator.new(rules)
17
+ # issues = validator.validate(font)
18
+ class TableValidator
19
+ # Initialize table validator
20
+ #
21
+ # @param rules [Hash] Validation rules configuration
22
+ def initialize(rules)
23
+ @rules = rules
24
+ @required_tables = rules["required_tables"]
25
+ end
26
+
27
+ # Validate font tables
28
+ #
29
+ # @param font [TrueTypeFont, OpenTypeFont] The font to validate
30
+ # @return [Array<Hash>] Array of validation issues
31
+ def validate(font)
32
+ issues = []
33
+
34
+ # Determine font type
35
+ font_type = determine_font_type(font)
36
+
37
+ # Check required tables based on font type
38
+ issues.concat(check_required_tables(font, font_type))
39
+
40
+ # Check table-specific validations if tables exist
41
+ issues.concat(check_table_versions(font)) if @rules["check_table_versions"]
42
+
43
+ issues
44
+ end
45
+
46
+ private
47
+
48
+ # Determine the font type
49
+ #
50
+ # @param font [TrueTypeFont, OpenTypeFont] The font
51
+ # @return [Symbol] :truetype, :opentype_cff, or :variable
52
+ def determine_font_type(font)
53
+ if font.has_table?(Constants::FVAR_TAG)
54
+ :variable
55
+ elsif font.has_table?(Constants::CFF_TAG) || font.has_table?("CFF2")
56
+ :opentype_cff
57
+ else
58
+ :truetype
59
+ end
60
+ end
61
+
62
+ # Check that required tables are present
63
+ #
64
+ # @param font [TrueTypeFont, OpenTypeFont] The font
65
+ # @param font_type [Symbol] The font type
66
+ # @return [Array<Hash>] Array of missing table issues
67
+ def check_required_tables(font, font_type)
68
+ issues = []
69
+
70
+ # Get required tables for this font type
71
+ required = @required_tables["all"].dup
72
+
73
+ case font_type
74
+ when :truetype
75
+ required.concat(@required_tables["truetype"])
76
+ when :opentype_cff
77
+ required.concat(@required_tables["opentype_cff"])
78
+ when :variable
79
+ required.concat(@required_tables["variable"])
80
+ end
81
+
82
+ # Check each required table
83
+ required.each do |table_tag|
84
+ next if font.has_table?(table_tag)
85
+
86
+ # Special case: CFF or CFF2 are alternatives
87
+ if (table_tag == "CFF") && font.has_table?("CFF2")
88
+ next
89
+ end
90
+
91
+ issues << {
92
+ severity: "error",
93
+ category: "tables",
94
+ message: "Missing required table: #{table_tag}",
95
+ location: nil,
96
+ }
97
+ end
98
+
99
+ issues
100
+ end
101
+
102
+ # Check table version compatibility
103
+ #
104
+ # @param font [TrueTypeFont, OpenTypeFont] The font
105
+ # @return [Array<Hash>] Array of version issues
106
+ def check_table_versions(font)
107
+ issues = []
108
+
109
+ # Check head table version
110
+ if font.has_table?(Constants::HEAD_TAG)
111
+ head = font.table(Constants::HEAD_TAG)
112
+ unless valid_head_version?(head)
113
+ issues << {
114
+ severity: "warning",
115
+ category: "tables",
116
+ message: "Unsupported head table version: #{head.major_version}.#{head.minor_version}",
117
+ location: Constants::HEAD_TAG,
118
+ }
119
+ end
120
+ end
121
+
122
+ # Check maxp table version
123
+ if font.has_table?(Constants::MAXP_TAG)
124
+ maxp = font.table(Constants::MAXP_TAG)
125
+ unless valid_maxp_version?(maxp)
126
+ issues << {
127
+ severity: "warning",
128
+ category: "tables",
129
+ message: "Unsupported maxp table version: #{maxp.version}",
130
+ location: Constants::MAXP_TAG,
131
+ }
132
+ end
133
+ end
134
+
135
+ issues
136
+ end
137
+
138
+ # Check if head table version is valid
139
+ #
140
+ # @param head [Tables::Head] The head table
141
+ # @return [Boolean] true if version is valid
142
+ def valid_head_version?(head)
143
+ # Head table version should be 1.0
144
+ head.major_version == 1 && head.minor_version.zero?
145
+ end
146
+
147
+ # Check if maxp table version is valid
148
+ #
149
+ # @param maxp [Tables::Maxp] The maxp table
150
+ # @return [Boolean] true if version is valid
151
+ def valid_maxp_version?(maxp)
152
+ # Version 0.5 for CFF fonts, 1.0 for TrueType fonts
153
+ version = maxp.version
154
+ [0x00005000, 0x00010000].include?(version)
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require_relative "../models/validation_report"
5
+ require_relative "table_validator"
6
+ require_relative "structure_validator"
7
+ require_relative "consistency_validator"
8
+ require_relative "checksum_validator"
9
+
10
+ module Fontisan
11
+ module Validation
12
+ # Validator is the main orchestrator for font validation
13
+ #
14
+ # This class coordinates all validation checks (tables, structure,
15
+ # consistency, checksums) and produces a comprehensive ValidationReport.
16
+ #
17
+ # Single Responsibility: Orchestration of validation workflow
18
+ #
19
+ # @example Validating a font
20
+ # validator = Validator.new(level: :standard)
21
+ # report = validator.validate(font, font_path)
22
+ # puts report.text_summary
23
+ class Validator
24
+ # Validation levels
25
+ LEVELS = %i[strict standard lenient].freeze
26
+
27
+ # Initialize validator
28
+ #
29
+ # @param level [Symbol] Validation level (:strict, :standard, :lenient)
30
+ # @param rules_path [String, nil] Path to custom rules file
31
+ def initialize(level: :standard, rules_path: nil)
32
+ @level = level
33
+ validate_level!
34
+
35
+ @rules = load_rules(rules_path)
36
+ @table_validator = TableValidator.new(@rules)
37
+ @structure_validator = StructureValidator.new(@rules)
38
+ @consistency_validator = ConsistencyValidator.new(@rules)
39
+ @checksum_validator = ChecksumValidator.new(@rules)
40
+ end
41
+
42
+ # Validate a font
43
+ #
44
+ # @param font [TrueTypeFont, OpenTypeFont] The font to validate
45
+ # @param font_path [String] Path to the font file
46
+ # @return [Models::ValidationReport] Validation report
47
+ def validate(font, font_path)
48
+ report = Models::ValidationReport.new(
49
+ font_path: font_path,
50
+ valid: true,
51
+ )
52
+
53
+ begin
54
+ # Run all validation checks
55
+ all_issues = []
56
+
57
+ # 1. Table validation
58
+ all_issues.concat(@table_validator.validate(font))
59
+
60
+ # 2. Structure validation
61
+ all_issues.concat(@structure_validator.validate(font))
62
+
63
+ # 3. Consistency validation
64
+ all_issues.concat(@consistency_validator.validate(font))
65
+
66
+ # 4. Checksum validation (requires file path)
67
+ all_issues.concat(@checksum_validator.validate(font, font_path))
68
+
69
+ # Add issues to report
70
+ all_issues.each do |issue|
71
+ case issue[:severity]
72
+ when "error"
73
+ report.add_error(issue[:category], issue[:message],
74
+ issue[:location])
75
+ when "warning"
76
+ report.add_warning(issue[:category], issue[:message],
77
+ issue[:location])
78
+ when "info"
79
+ report.add_info(issue[:category], issue[:message],
80
+ issue[:location])
81
+ end
82
+ end
83
+
84
+ # Determine overall validity based on level
85
+ report.valid = determine_validity(report)
86
+ rescue StandardError => e
87
+ report.add_error("validation", "Validation failed: #{e.message}", nil)
88
+ report.valid = false
89
+ end
90
+
91
+ report
92
+ end
93
+
94
+ # Get the current validation level
95
+ #
96
+ # @return [Symbol] The validation level
97
+ attr_reader :level
98
+
99
+ private
100
+
101
+ # Validate that the level is supported
102
+ #
103
+ # @raise [ArgumentError] if level is invalid
104
+ # @return [void]
105
+ def validate_level!
106
+ unless LEVELS.include?(@level)
107
+ raise ArgumentError,
108
+ "Invalid validation level: #{@level}. Must be one of: #{LEVELS.join(', ')}"
109
+ end
110
+ end
111
+
112
+ # Load validation rules
113
+ #
114
+ # @param rules_path [String, nil] Path to custom rules file
115
+ # @return [Hash] The rules configuration
116
+ def load_rules(rules_path)
117
+ path = rules_path || default_rules_path
118
+ YAML.load_file(path)
119
+ rescue Errno::ENOENT
120
+ raise "Validation rules file not found: #{path}"
121
+ rescue Psych::SyntaxError => e
122
+ raise "Invalid validation rules YAML: #{e.message}"
123
+ end
124
+
125
+ # Get the default rules path
126
+ #
127
+ # @return [String] Path to default rules file
128
+ def default_rules_path
129
+ File.join(__dir__, "..", "config", "validation_rules.yml")
130
+ end
131
+
132
+ # Determine if font is valid based on validation level
133
+ #
134
+ # @param report [Models::ValidationReport] The validation report
135
+ # @return [Boolean] true if font is valid for the given level
136
+ def determine_validity(report)
137
+ case @level
138
+ when :strict
139
+ # Strict: no errors, no warnings
140
+ !report.has_errors? && !report.has_warnings?
141
+ when :standard
142
+ # Standard: no errors (warnings allowed)
143
+ !report.has_errors?
144
+ when :lenient
145
+ # Lenient: no critical errors (some errors may be acceptable)
146
+ # For now, treat lenient same as standard
147
+ !report.has_errors?
148
+ end
149
+ end
150
+ end
151
+ end
152
+ 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