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,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
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module Fontisan
6
+ module Variation
7
+ # Parses variation deltas from gvar tuple data
8
+ #
9
+ # The gvar table stores deltas in various compression formats to minimize
10
+ # file size. This parser handles all delta formats and decompresses them
11
+ # into usable point delta arrays.
12
+ #
13
+ # Delta Formats:
14
+ # - DELTAS_ARE_ZERO: All deltas are zero (no data stored)
15
+ # - DELTAS_ARE_WORDS: Deltas stored as signed 16-bit words
16
+ # - DELTAS_ARE_BYTES: Deltas stored as signed 8-bit bytes
17
+ # - Point number runs: Compressed sequences of affected points
18
+ #
19
+ # Reference: OpenType specification, gvar table delta encoding
20
+ #
21
+ # @example Parsing delta data
22
+ # parser = Fontisan::Variation::DeltaParser.new
23
+ # deltas = parser.parse(tuple_data, point_count)
24
+ # # Returns: [{ x: 10, y: 5 }, { x: -3, y: 2 }, ...]
25
+ class DeltaParser
26
+ # Delta format flags (from tuple variation header flags)
27
+ DELTAS_ARE_ZERO = 0x80
28
+ DELTAS_ARE_WORDS = 0x40
29
+
30
+ # Point number flags
31
+ POINTS_ARE_WORDS = 0x80
32
+ POINT_RUN_COUNT_MASK = 0x7F
33
+
34
+ # Parse delta data from tuple variation
35
+ #
36
+ # @param data [String] Binary delta data
37
+ # @param point_count [Integer] Total number of points in glyph
38
+ # @param private_points [Boolean] Whether tuple has private point numbers
39
+ # @param shared_points [Array<Integer>, nil] Shared point numbers if applicable
40
+ # @return [Array<Hash>] Array of point deltas { x:, y: }
41
+ # @raise [VariationDataCorruptedError] If delta data is corrupted or cannot be parsed
42
+ def parse(data, point_count, private_points: false, shared_points: nil)
43
+ return zero_deltas(point_count) if data.nil? || data.empty?
44
+
45
+ io = StringIO.new(data)
46
+ io.set_encoding(Encoding::BINARY)
47
+
48
+ # Parse point numbers if present
49
+ points = if private_points
50
+ parse_point_numbers(io)
51
+ elsif shared_points
52
+ shared_points
53
+ else
54
+ # All points affected
55
+ (0...point_count).to_a
56
+ end
57
+
58
+ # Determine delta format from first byte (if present)
59
+ format_byte = io.getbyte
60
+ return zero_deltas(point_count) if format_byte.nil?
61
+
62
+ io.pos -= 1 # Put byte back
63
+
64
+ # Parse X deltas
65
+ x_deltas = parse_delta_array(io, points.length)
66
+
67
+ # Parse Y deltas
68
+ y_deltas = parse_delta_array(io, points.length)
69
+
70
+ # Build full delta array (zero for untouched points)
71
+ build_full_deltas(points, x_deltas, y_deltas, point_count)
72
+ rescue StandardError => e
73
+ raise VariationDataCorruptedError.new(
74
+ message: "Failed to parse delta data: #{e.message}",
75
+ details: {
76
+ point_count: point_count,
77
+ private_points: private_points,
78
+ error_class: e.class.name,
79
+ },
80
+ )
81
+ end
82
+
83
+ # Parse delta data with explicit format flag
84
+ #
85
+ # @param data [String] Binary delta data
86
+ # @param point_count [Integer] Total number of points
87
+ # @param flags [Integer] Tuple variation flags
88
+ # @return [Array<Hash>] Array of point deltas
89
+ def parse_with_flags(data, point_count, flags)
90
+ if (flags & DELTAS_ARE_ZERO).zero?
91
+ parse(data, point_count)
92
+ else
93
+ zero_deltas(point_count)
94
+ end
95
+ end
96
+
97
+ private
98
+
99
+ # Parse point numbers from packed format
100
+ #
101
+ # Point numbers indicate which points have deltas. Uses run-length
102
+ # encoding to compress sequences of point numbers.
103
+ #
104
+ # @param io [StringIO] Input stream
105
+ # @return [Array<Integer>] Array of point numbers
106
+ def parse_point_numbers(io)
107
+ points = []
108
+ first_byte = io.getbyte
109
+ return points if first_byte.nil?
110
+
111
+ # First byte indicates total number of point numbers
112
+ total_points = first_byte
113
+
114
+ # Parse all point number runs
115
+ point_index = 0
116
+ remaining = total_points
117
+
118
+ while remaining.positive?
119
+ control = io.getbyte
120
+ return points if control.nil?
121
+
122
+ # Number of points in this run
123
+ run_count = (control & POINT_RUN_COUNT_MASK) + 1
124
+
125
+ # Limit run_count to remaining points
126
+ run_count = [run_count, remaining].min
127
+
128
+ if (control & POINTS_ARE_WORDS).zero?
129
+ # Points stored as 8-bit bytes (deltas from previous)
130
+ run_count.times do
131
+ byte = io.getbyte
132
+ return points if byte.nil?
133
+
134
+ point_index += byte
135
+ points << point_index
136
+ remaining -= 1
137
+ end
138
+ else
139
+ # Points stored as 16-bit words
140
+ run_count.times do
141
+ bytes = io.read(2)
142
+ return points if bytes.nil? || bytes.bytesize < 2
143
+
144
+ point = bytes.unpack1("n")
145
+ points << point
146
+ point_index = point
147
+ remaining -= 1
148
+ end
149
+ end
150
+ end
151
+
152
+ points
153
+ end
154
+
155
+ # Parse an array of delta values
156
+ #
157
+ # Deltas can be stored as bytes or words depending on value range.
158
+ # The format is determined by inspecting the first byte.
159
+ #
160
+ # @param io [StringIO] Input stream
161
+ # @param count [Integer] Number of deltas to parse
162
+ # @return [Array<Integer>] Array of delta values
163
+ def parse_delta_array(io, count)
164
+ return [] if count.zero?
165
+
166
+ deltas = []
167
+
168
+ # Read control byte to determine format
169
+ control = io.getbyte
170
+ return deltas if control.nil?
171
+
172
+ if (control & DELTAS_ARE_WORDS).zero?
173
+ # Deltas stored as 8-bit signed bytes
174
+ count.times do
175
+ byte = io.getbyte
176
+ return deltas if byte.nil?
177
+
178
+ signed = byte > 0x7F ? byte - 0x100 : byte
179
+ deltas << signed
180
+ end
181
+ else
182
+ # Deltas stored as 16-bit signed words
183
+ count.times do
184
+ bytes = io.read(2)
185
+ return deltas if bytes.nil? || bytes.bytesize < 2
186
+
187
+ value = bytes.unpack1("n")
188
+ signed = value > 0x7FFF ? value - 0x10000 : value
189
+ deltas << signed
190
+ end
191
+ end
192
+
193
+ deltas
194
+ end
195
+
196
+ # Build full delta array including untouched points
197
+ #
198
+ # @param points [Array<Integer>] Point numbers with deltas
199
+ # @param x_deltas [Array<Integer>] X deltas
200
+ # @param y_deltas [Array<Integer>] Y deltas
201
+ # @param point_count [Integer] Total points in glyph
202
+ # @return [Array<Hash>] Full delta array
203
+ def build_full_deltas(points, x_deltas, y_deltas, point_count)
204
+ full_deltas = Array.new(point_count) { { x: 0, y: 0 } }
205
+
206
+ points.each_with_index do |point_num, i|
207
+ next if point_num >= point_count
208
+ next if i >= x_deltas.length || i >= y_deltas.length
209
+
210
+ full_deltas[point_num] = {
211
+ x: x_deltas[i],
212
+ y: y_deltas[i],
213
+ }
214
+ end
215
+
216
+ full_deltas
217
+ end
218
+
219
+ # Create array of zero deltas
220
+ #
221
+ # @param count [Integer] Number of deltas
222
+ # @return [Array<Hash>] Array of zero deltas
223
+ def zero_deltas(count)
224
+ Array.new(count) { { x: 0, y: 0 } }
225
+ end
226
+ end
227
+ end
228
+ end