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,268 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "interpolator"
4
+ require_relative "table_accessor"
5
+
6
+ module Fontisan
7
+ module Variation
8
+ # Converts variation data between TrueType (gvar) and CFF2 (blend) formats
9
+ #
10
+ # This class enables format conversion while preserving variation data:
11
+ # - gvar tuples → CFF2 blend operators
12
+ # - CFF2 blend operators → gvar tuples
13
+ #
14
+ # Process for gvar → blend:
15
+ # 1. Extract tuple variations from gvar
16
+ # 2. Map tuple regions to blend regions
17
+ # 3. Embed blend operators in CharStrings at control points
18
+ # 4. Encode delta values in blend format
19
+ #
20
+ # Process for blend → gvar:
21
+ # 1. Parse CharStrings with blend operators
22
+ # 2. Extract blend deltas and regions
23
+ # 3. Map to gvar tuple format
24
+ # 4. Build gvar table structure
25
+ #
26
+ # @example Converting gvar to CFF2 blend
27
+ # converter = VariationConverter.new(font, axes)
28
+ # blend_data = converter.gvar_to_blend(glyph_id)
29
+ #
30
+ # @example Converting CFF2 blend to gvar
31
+ # converter = VariationConverter.new(font, axes)
32
+ # tuple_data = converter.blend_to_gvar(glyph_id)
33
+ class VariationConverter
34
+ include TableAccessor
35
+
36
+ # @return [TrueTypeFont, OpenTypeFont] Font instance
37
+ attr_reader :font
38
+
39
+ # @return [Array<VariationAxisRecord>] Variation axes
40
+ attr_reader :axes
41
+
42
+ # Initialize converter
43
+ #
44
+ # @param font [TrueTypeFont, OpenTypeFont] Font instance
45
+ # @param axes [Array<VariationAxisRecord>] Variation axes from fvar
46
+ def initialize(font, axes)
47
+ @font = font
48
+ @axes = axes || []
49
+ @variation_tables = {}
50
+ end
51
+
52
+ # Convert gvar tuples to CFF2 blend format for a glyph
53
+ #
54
+ # @param glyph_id [Integer] Glyph ID
55
+ # @return [Hash, nil] Blend data or nil
56
+ def gvar_to_blend(glyph_id)
57
+ return nil unless has_variation_table?("gvar")
58
+ return nil unless has_variation_table?("glyf")
59
+
60
+ gvar = variation_table("gvar")
61
+ return nil unless gvar
62
+
63
+ # Get tuple variations for this glyph
64
+ tuple_data = gvar.glyph_tuple_variations(glyph_id)
65
+ return nil unless tuple_data
66
+
67
+ # Convert tuples to blend format
68
+ convert_tuples_to_blend(tuple_data)
69
+ end
70
+
71
+ # Convert CFF2 blend operators to gvar tuple format for a glyph
72
+ #
73
+ # @param glyph_id [Integer] Glyph ID
74
+ # @return [Hash, nil] Tuple data or nil
75
+ def blend_to_gvar(_glyph_id)
76
+ return nil unless has_variation_table?("CFF2")
77
+
78
+ cff2 = variation_table("CFF2")
79
+ return nil unless cff2
80
+
81
+ # Get CharString with blend operators
82
+ # This is a placeholder - full implementation would parse CharString
83
+ # and extract blend operator data
84
+
85
+ # Convert blend data to tuples
86
+ # Placeholder for full implementation
87
+ nil
88
+ end
89
+
90
+ # Check if variation data can be converted
91
+ #
92
+ # @return [Boolean] True if conversion possible
93
+ def can_convert?
94
+ !@axes.empty? && (
95
+ has_variation_table?("gvar") ||
96
+ has_variation_table?("CFF2")
97
+ )
98
+ end
99
+
100
+ private
101
+
102
+ # Convert tuple variations to blend format
103
+ #
104
+ # @param tuple_data [Hash] Tuple variation data from gvar
105
+ # @return [Hash] Blend format data
106
+ def convert_tuples_to_blend(tuple_data)
107
+ tuples = tuple_data[:tuples] || []
108
+ point_count = tuple_data[:point_count] || 0
109
+
110
+ # Build blend regions from tuples
111
+ regions = tuples.map { |tuple| build_region_from_tuple(tuple) }
112
+
113
+ # Extract deltas for each point
114
+ point_deltas = extract_point_deltas(tuples, point_count)
115
+
116
+ {
117
+ regions: regions,
118
+ point_deltas: point_deltas,
119
+ num_regions: regions.length,
120
+ num_axes: @axes.length,
121
+ }
122
+ end
123
+
124
+ # Build region from tuple peak/start/end coordinates
125
+ #
126
+ # @param tuple [Hash] Tuple data with :peak, :start, :end
127
+ # @return [Hash] Region definition
128
+ def build_region_from_tuple(tuple)
129
+ region = {}
130
+
131
+ @axes.each_with_index do |axis, axis_index|
132
+ # Extract coordinates for this axis
133
+ peak = tuple[:peak] ? tuple[:peak][axis_index] : 0.0
134
+ start_val = tuple[:start] ? tuple[:start][axis_index] : -1.0
135
+ end_val = tuple[:end] ? tuple[:end][axis_index] : 1.0
136
+
137
+ region[axis.axis_tag] = {
138
+ start: start_val,
139
+ peak: peak,
140
+ end: end_val,
141
+ }
142
+ end
143
+
144
+ region
145
+ end
146
+
147
+ # Extract point deltas from all tuples
148
+ #
149
+ # @param tuples [Array<Hash>] Tuple variations
150
+ # @param point_count [Integer] Number of points
151
+ # @return [Array<Array<Hash>>] Deltas per point per tuple
152
+ def extract_point_deltas(tuples, point_count)
153
+ return [] if point_count.zero?
154
+
155
+ # Initialize deltas array
156
+ point_deltas = Array.new(point_count) { [] }
157
+
158
+ # For each tuple, extract deltas for all points
159
+ tuples.each do |tuple|
160
+ deltas = parse_tuple_deltas(tuple, point_count)
161
+
162
+ deltas.each_with_index do |delta, point_index|
163
+ point_deltas[point_index] << delta
164
+ end
165
+ end
166
+
167
+ point_deltas
168
+ end
169
+
170
+ # Parse deltas from a tuple
171
+ #
172
+ # @param tuple [Hash] Tuple data
173
+ # @param point_count [Integer] Number of points
174
+ # @return [Array<Hash>] Deltas with :x and :y
175
+ def parse_tuple_deltas(_tuple, point_count)
176
+ # This is a placeholder - full implementation would:
177
+ # 1. Parse delta data from tuple
178
+ # 2. Decompress if needed
179
+ # 3. Return array of { x: dx, y: dy } for each point
180
+
181
+ Array.new(point_count) { { x: 0, y: 0 } }
182
+ end
183
+
184
+ # Convert blend data to tuple format
185
+ #
186
+ # @param blend_data [Hash] Blend format data
187
+ # @return [Hash] Tuple variation data
188
+ def convert_blend_to_tuples(blend_data)
189
+ regions = blend_data[:regions] || []
190
+ point_deltas = blend_data[:point_deltas] || []
191
+
192
+ # Build tuples from regions
193
+ tuples = regions.map.with_index do |region, region_index|
194
+ build_tuple_from_region(region, point_deltas, region_index)
195
+ end
196
+
197
+ {
198
+ tuples: tuples,
199
+ point_count: point_deltas.length,
200
+ }
201
+ end
202
+
203
+ # Build tuple from region and deltas
204
+ #
205
+ # @param region [Hash] Region definition
206
+ # @param point_deltas [Array<Array<Hash>>] Deltas per point
207
+ # @param region_index [Integer] Region index
208
+ # @return [Hash] Tuple data
209
+ def build_tuple_from_region(region, point_deltas, region_index)
210
+ # Extract peak, start, end for all axes
211
+ peak = Array.new(@axes.length, 0.0)
212
+ start_vals = Array.new(@axes.length, -1.0)
213
+ end_vals = Array.new(@axes.length, 1.0)
214
+
215
+ @axes.each_with_index do |axis, axis_index|
216
+ axis_region = region[axis.axis_tag]
217
+ next unless axis_region
218
+
219
+ peak[axis_index] = axis_region[:peak]
220
+ start_vals[axis_index] = axis_region[:start]
221
+ end_vals[axis_index] = axis_region[:end]
222
+ end
223
+
224
+ # Extract deltas for this region
225
+ deltas = point_deltas.map do |point_delta_set|
226
+ point_delta_set[region_index] || { x: 0, y: 0 }
227
+ end
228
+
229
+ {
230
+ peak: peak,
231
+ start: start_vals,
232
+ end: end_vals,
233
+ deltas: deltas,
234
+ }
235
+ end
236
+
237
+ # Encode deltas in CharString blend format
238
+ #
239
+ # @param base_value [Numeric] Base value
240
+ # @param deltas [Array<Numeric>] Delta values
241
+ # @return [Array<Numeric>] Blend operator arguments
242
+ def encode_blend_operator(base_value, deltas)
243
+ # CFF2 blend format: base_value delta1 delta2 ... K N blend
244
+ # Where K = number of deltas, N = number of blend operations
245
+ [base_value] + deltas + [deltas.length, 1]
246
+ end
247
+
248
+ # Decode blend operator arguments to base and deltas
249
+ #
250
+ # @param args [Array<Numeric>] Blend operator arguments
251
+ # @return [Hash] Base value and deltas
252
+ def decode_blend_operator(args)
253
+ return { base: 0, deltas: [] } if args.length < 3
254
+
255
+ # Last two values are K and N
256
+ k = args[-2]
257
+ _n = args[-1]
258
+
259
+ # Before K and N: base + deltas
260
+ values = args[0...-2]
261
+ base = values[0] || 0
262
+ deltas = values[1, k] || []
263
+
264
+ { base: base, deltas: deltas }
265
+ end
266
+ end
267
+ end
268
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "variation_context"
4
+
5
+ module Fontisan
6
+ module Variation
7
+ # Extracts variation data from OpenType variable fonts
8
+ #
9
+ # This class provides a unified interface to extract variation information
10
+ # from variable fonts, including:
11
+ # - Variation axes (from fvar table)
12
+ # - Named instances (from fvar table)
13
+ # - Variation type (TrueType gvar or PostScript CFF2)
14
+ #
15
+ # @example Extracting variation data
16
+ # extractor = Fontisan::Variation::DataExtractor.new(font)
17
+ # data = extractor.extract
18
+ # if data
19
+ # puts "Axes: #{data[:axes].map(&:axis_tag).join(', ')}"
20
+ # puts "Instances: #{data[:instances].length}"
21
+ # end
22
+ class DataExtractor
23
+ # Initialize extractor with a font
24
+ #
25
+ # @param font [TrueTypeFont, OpenTypeFont] Font to extract from
26
+ def initialize(font)
27
+ @font = font
28
+ @context = VariationContext.new(font)
29
+ end
30
+
31
+ # Extract variation data from the font
32
+ #
33
+ # @return [Hash, nil] Variation data or nil if not a variable font
34
+ def extract
35
+ return nil unless @context.variable_font?
36
+
37
+ {
38
+ axes: extract_axes,
39
+ instances: extract_instances,
40
+ has_gvar: @font.has_table?("gvar"),
41
+ has_cff2: @font.has_table?("CFF2"),
42
+ variation_type: @context.variation_type,
43
+ }
44
+ end
45
+
46
+ # Check if font is a variable font
47
+ #
48
+ # @return [Boolean] True if font has fvar table
49
+ def variable_font?
50
+ @context.variable_font?
51
+ end
52
+
53
+ private
54
+
55
+ # Extract variation axes from fvar table
56
+ #
57
+ # @return [Array<VariationAxisRecord>] Array of axis records
58
+ # @raise [VariationDataCorruptedError] If axes cannot be extracted
59
+ def extract_axes
60
+ return [] unless @context.fvar
61
+
62
+ @context.axes
63
+ rescue StandardError => e
64
+ raise VariationDataCorruptedError.new(
65
+ message: "Failed to extract variation axes: #{e.message}",
66
+ details: { error_class: e.class.name },
67
+ )
68
+ end
69
+
70
+ # Extract named instances from fvar table
71
+ #
72
+ # @return [Array<Hash>] Array of instance information
73
+ # @raise [VariationDataCorruptedError] If instances cannot be extracted
74
+ def extract_instances
75
+ return [] unless @context.fvar
76
+
77
+ @context.fvar.instances || []
78
+ rescue StandardError => e
79
+ raise VariationDataCorruptedError.new(
80
+ message: "Failed to extract instances: #{e.message}",
81
+ details: { error_class: e.class.name },
82
+ )
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,266 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "delta_parser"
4
+ require_relative "interpolator"
5
+ require_relative "region_matcher"
6
+ require_relative "table_accessor"
7
+
8
+ module Fontisan
9
+ module Variation
10
+ # Applies variation deltas to glyph outlines
11
+ #
12
+ # This class handles the complete delta application process for TrueType
13
+ # variable fonts using gvar table data:
14
+ # 1. Parse base glyph outline points
15
+ # 2. Match active tuple variations to coordinates
16
+ # 3. Parse and decompress deltas
17
+ # 4. Apply deltas: new_point = base + Σ(delta × scalar)
18
+ # 5. Expand IUP (Inferred Untouched Points)
19
+ #
20
+ # Reference: OpenType specification, gvar table
21
+ #
22
+ # @example Applying deltas to a glyph
23
+ # applier = Fontisan::Variation::DeltaApplier.new(font, interpolator, region_matcher)
24
+ # adjusted_points = applier.apply_deltas(glyph_id, coordinates)
25
+ class DeltaApplier
26
+ include TableAccessor
27
+
28
+ # @return [Font] Font object
29
+ attr_reader :font
30
+
31
+ # @return [Interpolator] Coordinate interpolator
32
+ attr_reader :interpolator
33
+
34
+ # @return [RegionMatcher] Region matcher
35
+ attr_reader :region_matcher
36
+
37
+ # @return [DeltaParser] Delta parser
38
+ attr_reader :delta_parser
39
+
40
+ # Initialize delta applier
41
+ #
42
+ # @param font [TrueTypeFont, OpenTypeFont] Font with gvar table
43
+ # @param interpolator [Interpolator] Coordinate interpolator
44
+ # @param region_matcher [RegionMatcher] Region matcher
45
+ def initialize(font, interpolator, region_matcher)
46
+ @font = font
47
+ @interpolator = interpolator
48
+ @region_matcher = region_matcher
49
+ @delta_parser = DeltaParser.new
50
+ @variation_tables = {}
51
+ end
52
+
53
+ # Apply deltas to a glyph at given coordinates
54
+ #
55
+ # @param glyph_id [Integer] Glyph ID
56
+ # @param coordinates [Hash<String, Float>] Design space coordinates
57
+ # @return [Array<Hash>, nil] Adjusted points or nil if not applicable
58
+ def apply_deltas(glyph_id, coordinates)
59
+ gvar = variation_table("gvar")
60
+ glyf = variation_table("glyf")
61
+ return nil unless gvar && glyf
62
+
63
+ # Get base glyph outline points
64
+ base_points = extract_glyph_points(glyph_id, glyf)
65
+ return nil if base_points.nil? || base_points.empty?
66
+
67
+ # Get tuple variations for this glyph
68
+ tuple_data = gvar.glyph_tuple_variations(glyph_id)
69
+ return base_points if tuple_data.nil? || tuple_data[:tuples].empty?
70
+
71
+ # Match active tuples to coordinates
72
+ matches = @region_matcher.match_tuples(
73
+ coordinates: coordinates,
74
+ tuples: tuple_data[:tuples],
75
+ )
76
+
77
+ return base_points if matches.empty?
78
+
79
+ # Apply each active tuple's deltas
80
+ adjusted_points = base_points.dup
81
+ matches.each do |match|
82
+ apply_tuple_deltas(adjusted_points, match, tuple_data, base_points.length)
83
+ end
84
+
85
+ adjusted_points
86
+ end
87
+
88
+ # Extract outline points from glyph
89
+ #
90
+ # @param glyph_id [Integer] Glyph ID
91
+ # @param glyf [Glyf] Glyf table
92
+ # @return [Array<Hash>, nil] Array of points with :x, :y, :on_curve
93
+ def extract_glyph_points(glyph_id, glyf)
94
+ # This is a simplified version - full implementation would parse
95
+ # complete glyf table data including composite glyphs
96
+ glyph_data = glyf.glyph_data(glyph_id)
97
+ return nil if glyph_data.nil?
98
+
99
+ # Parse glyph outline (simplified)
100
+ # Real implementation would fully parse SimpleGlyph or CompositeGlyph
101
+ []
102
+ end
103
+
104
+ private
105
+
106
+ # Apply a single tuple's deltas to points
107
+ #
108
+ # @param points [Array<Hash>] Points to adjust (modified in place)
109
+ # @param match [Hash] Matched tuple with :tuple and :scalar
110
+ # @param tuple_data [Hash] Complete tuple data from gvar
111
+ # @param point_count [Integer] Number of points
112
+ def apply_tuple_deltas(points, match, tuple_data, point_count)
113
+ tuple = match[:tuple]
114
+ scalar = match[:scalar]
115
+
116
+ return if scalar.zero?
117
+
118
+ # Parse deltas for this tuple
119
+ # Note: In real implementation, we'd need to extract the actual
120
+ # delta data from the gvar table at the correct offset
121
+ deltas = parse_tuple_deltas(tuple, point_count, tuple_data)
122
+ return if deltas.nil?
123
+
124
+ # Check if points need IUP expansion
125
+ if tuple[:private_points]
126
+ # Expand IUP for untouched points
127
+ deltas = expand_iup(deltas, point_count)
128
+ end
129
+
130
+ # Apply deltas with scalar
131
+ points.each_with_index do |point, i|
132
+ next if i >= deltas.length
133
+
134
+ delta = deltas[i]
135
+ point[:x] += delta[:x] * scalar
136
+ point[:y] += delta[:y] * scalar
137
+ end
138
+ end
139
+
140
+ # Parse deltas for a tuple variation
141
+ #
142
+ # @param tuple [Hash] Tuple variation info
143
+ # @param point_count [Integer] Number of points
144
+ # @param tuple_data [Hash] Complete tuple data
145
+ # @return [Array<Hash>, nil] Array of point deltas
146
+ def parse_tuple_deltas(tuple, point_count, tuple_data)
147
+ # In real implementation, this would:
148
+ # 1. Calculate offset to delta data
149
+ # 2. Extract raw delta bytes
150
+ # 3. Call delta_parser.parse with appropriate flags
151
+
152
+ # Placeholder - full implementation needs access to raw delta data
153
+ @delta_parser.parse(
154
+ "",
155
+ point_count,
156
+ private_points: tuple[:private_points],
157
+ shared_points: tuple_data[:has_shared_points] ? [] : nil,
158
+ )
159
+ end
160
+
161
+ # Expand IUP (Inferred Untouched Points)
162
+ #
163
+ # Points without explicit deltas have their deltas inferred through
164
+ # linear interpolation between surrounding touched points.
165
+ #
166
+ # @param deltas [Array<Hash>] Delta array (sparse)
167
+ # @param point_count [Integer] Total number of points
168
+ # @return [Array<Hash>] Expanded delta array
169
+ def expand_iup(deltas, point_count)
170
+ return deltas if deltas.length == point_count
171
+
172
+ expanded = Array.new(point_count) { { x: 0, y: 0 } }
173
+
174
+ # Copy explicit deltas
175
+ deltas.each_with_index do |delta, i|
176
+ next if i >= point_count
177
+
178
+ expanded[i] = delta if delta[:x] != 0 || delta[:y] != 0
179
+ end
180
+
181
+ # Find touched points
182
+ touched = []
183
+ deltas.each_with_index do |delta, i|
184
+ touched << i if delta[:x] != 0 || delta[:y] != 0
185
+ end
186
+
187
+ return expanded if touched.empty?
188
+
189
+ # Infer untouched points
190
+ point_count.times do |i|
191
+ next if touched.include?(i)
192
+
193
+ # Find previous and next touched points
194
+ prev_idx = find_previous_touched(touched, i)
195
+ next_idx = find_next_touched(touched, i, point_count)
196
+
197
+ # Interpolate delta
198
+ if prev_idx && next_idx
199
+ expanded[i] = interpolate_delta(
200
+ deltas[prev_idx],
201
+ deltas[next_idx],
202
+ i, prev_idx, next_idx
203
+ )
204
+ elsif prev_idx
205
+ # Use previous delta
206
+ expanded[i] = deltas[prev_idx].dup
207
+ elsif next_idx
208
+ # Use next delta
209
+ expanded[i] = deltas[next_idx].dup
210
+ end
211
+ end
212
+
213
+ expanded
214
+ end
215
+
216
+ # Find previous touched point
217
+ #
218
+ # @param touched [Array<Integer>] Touched point indices
219
+ # @param index [Integer] Current point index
220
+ # @return [Integer, nil] Previous touched index or nil
221
+ def find_previous_touched(touched, index)
222
+ touched.reverse_each do |t|
223
+ return t if t < index
224
+ end
225
+ nil
226
+ end
227
+
228
+ # Find next touched point
229
+ #
230
+ # @param touched [Array<Integer>] Touched point indices
231
+ # @param index [Integer] Current point index
232
+ # @param point_count [Integer] Total points (for wrapping)
233
+ # @return [Integer, nil] Next touched index or nil
234
+ def find_next_touched(touched, index, _point_count)
235
+ # Check forward
236
+ touched.each do |t|
237
+ return t if t > index
238
+ end
239
+
240
+ # Wrap around (contour is closed)
241
+ touched.first
242
+ end
243
+
244
+ # Interpolate delta between two touched points
245
+ #
246
+ # @param delta1 [Hash] First delta
247
+ # @param delta2 [Hash] Second delta
248
+ # @param current [Integer] Current point index
249
+ # @param idx1 [Integer] First point index
250
+ # @param idx2 [Integer] Second point index
251
+ # @return [Hash] Interpolated delta
252
+ def interpolate_delta(delta1, delta2, current, idx1, idx2)
253
+ # Linear interpolation
254
+ range = idx2 - idx1
255
+ return delta1.dup if range.zero?
256
+
257
+ ratio = (current - idx1).to_f / range
258
+
259
+ {
260
+ x: delta1[:x] + (delta2[:x] - delta1[:x]) * ratio,
261
+ y: delta1[:y] + (delta2[:y] - delta1[:y]) * ratio,
262
+ }
263
+ end
264
+ end
265
+ end
266
+ end