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,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Tables
5
+ class Cff2
6
+ # Operand stack manager for CFF2 CharStrings
7
+ #
8
+ # This class manages the operand stack for CFF2 CharStrings, with special
9
+ # handling for blend operations that mix base values and deltas.
10
+ #
11
+ # In CFF2, the blend operator takes operands in the format:
12
+ # [base1, delta1_axis1, delta1_axis2, ..., base2, delta2_axis1, ..., K, N]
13
+ #
14
+ # Where:
15
+ # - K = number of values to blend
16
+ # - N = number of variation axes
17
+ #
18
+ # The stack manager separates base values from deltas and applies blend
19
+ # operations to produce final values based on variation coordinates.
20
+ #
21
+ # @example Managing a blend operation
22
+ # stack = OperandStack.new(num_axes: 2)
23
+ # stack.push(100, 10, 5) # base=100, deltas=[10, 5]
24
+ # stack.push(200, 20, 10) # base=200, deltas=[20, 10]
25
+ # blended = stack.apply_blend(k: 2, coordinates: { "wght" => 0.5, "wdth" => 0.3 })
26
+ # # => [105.0, 206.0] # base + (delta * scalar)
27
+ class OperandStack
28
+ # @return [Array<Numeric>] The operand stack
29
+ attr_reader :stack
30
+
31
+ # @return [Integer] Number of variation axes
32
+ attr_reader :num_axes
33
+
34
+ # @return [Array<Hash>] Blend values (base + deltas)
35
+ attr_reader :blend_values
36
+
37
+ # Initialize operand stack
38
+ #
39
+ # @param num_axes [Integer] Number of variation axes (default 0)
40
+ def initialize(num_axes: 0)
41
+ @stack = []
42
+ @num_axes = num_axes
43
+ @blend_values = []
44
+ end
45
+
46
+ # Push a value onto the stack
47
+ #
48
+ # @param values [Numeric] Values to push
49
+ def push(*values)
50
+ @stack.concat(values)
51
+ end
52
+
53
+ # Pop a value from the stack
54
+ #
55
+ # @return [Numeric, nil] Popped value or nil if empty
56
+ def pop
57
+ @stack.pop
58
+ end
59
+
60
+ # Pop multiple values from the stack
61
+ #
62
+ # @param count [Integer] Number of values to pop
63
+ # @return [Array<Numeric>] Popped values
64
+ def pop_many(count)
65
+ return [] if count <= 0 || @stack.empty?
66
+
67
+ @stack.pop(count)
68
+ end
69
+
70
+ # Shift a value from the front of the stack
71
+ #
72
+ # @return [Numeric, nil] Shifted value or nil if empty
73
+ def shift
74
+ @stack.shift
75
+ end
76
+
77
+ # Get the top value without popping
78
+ #
79
+ # @return [Numeric, nil] Top value or nil if empty
80
+ def peek
81
+ @stack.last
82
+ end
83
+
84
+ # Get stack size
85
+ #
86
+ # @return [Integer] Number of values on stack
87
+ def size
88
+ @stack.size
89
+ end
90
+
91
+ # Check if stack is empty
92
+ #
93
+ # @return [Boolean] True if empty
94
+ def empty?
95
+ @stack.empty?
96
+ end
97
+
98
+ # Clear the stack
99
+ def clear
100
+ @stack.clear
101
+ @blend_values.clear
102
+ end
103
+
104
+ # Apply blend operation
105
+ #
106
+ # This pops K * (N + 1) + 2 operands from the stack, where:
107
+ # - K = number of values to blend
108
+ # - N = number of axes
109
+ # - Last 2 values are K and N themselves
110
+ #
111
+ # @param scalars [Array<Float>] Variation scalars for each axis
112
+ # @return [Array<Float>] Blended values
113
+ def apply_blend(scalars = [])
114
+ # Pop N and K
115
+ n = pop.to_i
116
+ k = pop.to_i
117
+
118
+ # Validate
119
+ required_operands = k * (n + 1)
120
+ if size < required_operands
121
+ warn "Blend requires #{required_operands} operands, got #{size}"
122
+ clear
123
+ return []
124
+ end
125
+
126
+ # Extract operands (base + deltas for each value)
127
+ blend_operands = pop_many(required_operands).reverse
128
+
129
+ # Process each value to blend
130
+ blended_values = []
131
+ k.times do |i|
132
+ offset = i * (n + 1)
133
+ base = blend_operands[offset]
134
+ deltas = blend_operands[offset + 1, n] || []
135
+
136
+ # Apply blend: result = base + sum(delta[i] * scalar[i])
137
+ blended = base.to_f
138
+ deltas.each_with_index do |delta, axis_index|
139
+ scalar = scalars[axis_index] || 0.0
140
+ blended += delta.to_f * scalar
141
+ end
142
+
143
+ # Store blend info for debugging/inspection
144
+ @blend_values << {
145
+ base: base,
146
+ deltas: deltas,
147
+ blended: blended,
148
+ }
149
+
150
+ blended_values << blended
151
+ end
152
+
153
+ # Push blended values back onto stack
154
+ push(*blended_values)
155
+
156
+ blended_values
157
+ end
158
+
159
+ # Extract blend data without applying
160
+ #
161
+ # This is used when we need to store blend operations for later
162
+ # application with specific coordinates.
163
+ #
164
+ # @return [Hash] Blend operation data
165
+ def extract_blend_data
166
+ # Pop N and K
167
+ n = pop.to_i
168
+ k = pop.to_i
169
+
170
+ # Validate
171
+ required_operands = k * (n + 1)
172
+ if size < required_operands
173
+ warn "Blend requires #{required_operands} operands, got #{size}"
174
+ clear
175
+ return nil
176
+ end
177
+
178
+ # Extract operands
179
+ blend_operands = pop_many(required_operands).reverse
180
+
181
+ # Parse into base + deltas structure
182
+ blends = []
183
+ k.times do |i|
184
+ offset = i * (n + 1)
185
+ base = blend_operands[offset]
186
+ deltas = blend_operands[offset + 1, n] || []
187
+
188
+ blends << {
189
+ base: base,
190
+ deltas: deltas,
191
+ }
192
+
193
+ # Push base value back (will be blended later)
194
+ push(base)
195
+ end
196
+
197
+ {
198
+ num_values: k,
199
+ num_axes: n,
200
+ blends: blends,
201
+ }
202
+ end
203
+
204
+ # Get all values on the stack
205
+ #
206
+ # @return [Array<Numeric>] Stack contents
207
+ def to_a
208
+ @stack.dup
209
+ end
210
+
211
+ # Get string representation for debugging
212
+ #
213
+ # @return [String] Stack contents as string
214
+ def inspect
215
+ "#<OperandStack size=#{size} values=#{@stack.inspect}>"
216
+ end
217
+
218
+ # Get blend value history
219
+ #
220
+ # @return [Array<Hash>] Blend values that have been calculated
221
+ def blend_history
222
+ @blend_values.dup
223
+ end
224
+
225
+ # Reset blend history
226
+ def reset_blend_history
227
+ @blend_values.clear
228
+ end
229
+ end
230
+ end
231
+ end
232
+ end
@@ -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