fontisan 0.2.0 → 0.2.2

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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +119 -308
  3. data/README.adoc +1525 -1323
  4. data/Rakefile +45 -47
  5. data/benchmark/variation_quick_bench.rb +4 -4
  6. data/docs/FONT_HINTING.adoc +562 -0
  7. data/docs/VARIABLE_FONT_OPERATIONS.adoc +599 -0
  8. data/lib/fontisan/cli.rb +92 -34
  9. data/lib/fontisan/collection/builder.rb +82 -0
  10. data/lib/fontisan/collection/offset_calculator.rb +2 -0
  11. data/lib/fontisan/collection/table_deduplicator.rb +76 -0
  12. data/lib/fontisan/commands/base_command.rb +21 -2
  13. data/lib/fontisan/commands/convert_command.rb +96 -165
  14. data/lib/fontisan/commands/info_command.rb +111 -5
  15. data/lib/fontisan/commands/instance_command.rb +77 -85
  16. data/lib/fontisan/commands/validate_command.rb +28 -0
  17. data/lib/fontisan/config/validation_rules.yml +1 -1
  18. data/lib/fontisan/constants.rb +34 -24
  19. data/lib/fontisan/converters/format_converter.rb +154 -1
  20. data/lib/fontisan/converters/outline_converter.rb +101 -34
  21. data/lib/fontisan/converters/woff_writer.rb +9 -4
  22. data/lib/fontisan/font_loader.rb +14 -9
  23. data/lib/fontisan/font_writer.rb +9 -6
  24. data/lib/fontisan/formatters/text_formatter.rb +45 -1
  25. data/lib/fontisan/hints/hint_converter.rb +131 -2
  26. data/lib/fontisan/hints/hint_validator.rb +284 -0
  27. data/lib/fontisan/hints/postscript_hint_applier.rb +219 -140
  28. data/lib/fontisan/hints/postscript_hint_extractor.rb +151 -16
  29. data/lib/fontisan/hints/truetype_hint_applier.rb +90 -44
  30. data/lib/fontisan/hints/truetype_hint_extractor.rb +134 -11
  31. data/lib/fontisan/hints/truetype_instruction_analyzer.rb +261 -0
  32. data/lib/fontisan/hints/truetype_instruction_generator.rb +266 -0
  33. data/lib/fontisan/loading_modes.rb +6 -4
  34. data/lib/fontisan/models/collection_brief_info.rb +31 -0
  35. data/lib/fontisan/models/font_info.rb +3 -30
  36. data/lib/fontisan/models/hint.rb +183 -12
  37. data/lib/fontisan/models/outline.rb +4 -1
  38. data/lib/fontisan/open_type_font.rb +28 -10
  39. data/lib/fontisan/open_type_font_extensions.rb +54 -0
  40. data/lib/fontisan/optimizers/pattern_analyzer.rb +2 -1
  41. data/lib/fontisan/optimizers/subroutine_generator.rb +1 -1
  42. data/lib/fontisan/pipeline/format_detector.rb +249 -0
  43. data/lib/fontisan/pipeline/output_writer.rb +159 -0
  44. data/lib/fontisan/pipeline/strategies/base_strategy.rb +75 -0
  45. data/lib/fontisan/pipeline/strategies/instance_strategy.rb +93 -0
  46. data/lib/fontisan/pipeline/strategies/named_strategy.rb +118 -0
  47. data/lib/fontisan/pipeline/strategies/preserve_strategy.rb +56 -0
  48. data/lib/fontisan/pipeline/transformation_pipeline.rb +416 -0
  49. data/lib/fontisan/pipeline/variation_resolver.rb +165 -0
  50. data/lib/fontisan/subset/table_subsetter.rb +5 -5
  51. data/lib/fontisan/tables/cff/charstring.rb +58 -3
  52. data/lib/fontisan/tables/cff/charstring_builder.rb +34 -0
  53. data/lib/fontisan/tables/cff/charstring_parser.rb +249 -0
  54. data/lib/fontisan/tables/cff/charstring_rebuilder.rb +172 -0
  55. data/lib/fontisan/tables/cff/dict_builder.rb +19 -1
  56. data/lib/fontisan/tables/cff/hint_operation_injector.rb +209 -0
  57. data/lib/fontisan/tables/cff/offset_recalculator.rb +70 -0
  58. data/lib/fontisan/tables/cff/private_dict_writer.rb +131 -0
  59. data/lib/fontisan/tables/cff/table_builder.rb +221 -0
  60. data/lib/fontisan/tables/cff.rb +2 -0
  61. data/lib/fontisan/tables/cff2/charstring_parser.rb +14 -8
  62. data/lib/fontisan/tables/cff2/private_dict_blend_handler.rb +247 -0
  63. data/lib/fontisan/tables/cff2/region_matcher.rb +200 -0
  64. data/lib/fontisan/tables/cff2/table_builder.rb +580 -0
  65. data/lib/fontisan/tables/cff2/table_reader.rb +421 -0
  66. data/lib/fontisan/tables/cff2/variation_data_extractor.rb +212 -0
  67. data/lib/fontisan/tables/cff2.rb +10 -5
  68. data/lib/fontisan/tables/cvar.rb +2 -41
  69. data/lib/fontisan/tables/glyf/compound_glyph_resolver.rb +2 -1
  70. data/lib/fontisan/tables/glyf/curve_converter.rb +10 -4
  71. data/lib/fontisan/tables/glyf/glyph_builder.rb +27 -10
  72. data/lib/fontisan/tables/gvar.rb +2 -41
  73. data/lib/fontisan/tables/name.rb +4 -4
  74. data/lib/fontisan/true_type_font.rb +27 -10
  75. data/lib/fontisan/true_type_font_extensions.rb +54 -0
  76. data/lib/fontisan/utilities/checksum_calculator.rb +42 -0
  77. data/lib/fontisan/validation/checksum_validator.rb +2 -2
  78. data/lib/fontisan/validation/table_validator.rb +1 -1
  79. data/lib/fontisan/validation/variable_font_validator.rb +218 -0
  80. data/lib/fontisan/variation/cache.rb +3 -1
  81. data/lib/fontisan/variation/converter.rb +121 -13
  82. data/lib/fontisan/variation/delta_applier.rb +2 -1
  83. data/lib/fontisan/variation/inspector.rb +2 -1
  84. data/lib/fontisan/variation/instance_generator.rb +2 -1
  85. data/lib/fontisan/variation/instance_writer.rb +341 -0
  86. data/lib/fontisan/variation/optimizer.rb +6 -3
  87. data/lib/fontisan/variation/subsetter.rb +32 -10
  88. data/lib/fontisan/variation/tuple_variation_header.rb +51 -0
  89. data/lib/fontisan/variation/variable_svg_generator.rb +268 -0
  90. data/lib/fontisan/variation/variation_preserver.rb +291 -0
  91. data/lib/fontisan/version.rb +1 -1
  92. data/lib/fontisan/version.rb.orig +9 -0
  93. data/lib/fontisan/woff2/glyf_transformer.rb +693 -0
  94. data/lib/fontisan/woff2/hmtx_transformer.rb +164 -0
  95. data/lib/fontisan/woff2_font.rb +489 -468
  96. data/lib/fontisan/woff_font.rb +16 -11
  97. data/lib/fontisan.rb +54 -2
  98. data/scripts/measure_optimization.rb +15 -7
  99. metadata +37 -2
@@ -0,0 +1,421 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require_relative "../../binary/base_record"
5
+
6
+ module Fontisan
7
+ module Tables
8
+ class Cff2
9
+ # Table reader for CFF2 with Variable Store support
10
+ #
11
+ # CFF2TableReader parses CFF2 tables and extracts variation data
12
+ # from the Variable Store, which is essential for applying hints
13
+ # to variable fonts with CFF2 outlines.
14
+ #
15
+ # Variable Store Structure:
16
+ # - RegionList: Defines variation regions (min/peak/max per axis)
17
+ # - ItemVariationData: Contains delta arrays per region
18
+ #
19
+ # Reference: Adobe Technical Note #5177 (CFF2)
20
+ # Reference: OpenType spec - Item Variation Store
21
+ #
22
+ # @example Reading CFF2 with Variable Store
23
+ # reader = CFF2TableReader.new(cff2_data)
24
+ # store = reader.read_variable_store
25
+ # regions = store[:regions]
26
+ # deltas = store[:deltas]
27
+ class TableReader
28
+ # @return [String] Binary CFF2 data
29
+ attr_reader :data
30
+
31
+ # @return [Hash] CFF2 header information
32
+ attr_reader :header
33
+
34
+ # @return [Hash] Top DICT data
35
+ attr_reader :top_dict
36
+
37
+ # @return [Hash, nil] Variable Store data
38
+ attr_reader :variable_store
39
+
40
+ # CFF2-specific operators
41
+ VSTORE_OPERATOR = 24
42
+
43
+ # Initialize reader with CFF2 data
44
+ #
45
+ # @param data [String] Binary CFF2 table data
46
+ def initialize(data)
47
+ @data = data
48
+ @io = StringIO.new(data)
49
+ @io.set_encoding(Encoding::BINARY)
50
+ @header = nil
51
+ @top_dict = nil
52
+ @variable_store = nil
53
+ end
54
+
55
+ # Read CFF2 header
56
+ #
57
+ # @return [Hash] Header information
58
+ def read_header
59
+ @io.rewind
60
+ @header = {
61
+ major_version: read_uint8,
62
+ minor_version: read_uint8,
63
+ header_size: read_uint8,
64
+ top_dict_length: read_uint16,
65
+ }
66
+
67
+ # Validate CFF2 version
68
+ unless @header[:major_version] == 2 && @header[:minor_version].zero?
69
+ raise CorruptedTableError,
70
+ "Invalid CFF2 version: #{@header[:major_version]}.#{@header[:minor_version]}"
71
+ end
72
+
73
+ @header
74
+ end
75
+
76
+ # Read Top DICT
77
+ #
78
+ # @return [Hash] Top DICT operators and values
79
+ def read_top_dict
80
+ read_header unless @header
81
+
82
+ # Seek to Top DICT (after header)
83
+ @io.seek(@header[:header_size])
84
+
85
+ top_dict_data = @io.read(@header[:top_dict_length])
86
+ @top_dict = parse_dict(top_dict_data)
87
+ end
88
+
89
+ # Read Variable Store from Top DICT
90
+ #
91
+ # The Variable Store is referenced by the vstore operator (24)
92
+ # in the Top DICT. It contains regions and deltas for variation.
93
+ #
94
+ # @return [Hash, nil] Variable Store data with :regions and :deltas
95
+ def read_variable_store
96
+ read_top_dict unless @top_dict
97
+
98
+ # Check if Variable Store is present (operator 24)
99
+ vstore_offset = @top_dict[VSTORE_OPERATOR]
100
+ return nil unless vstore_offset
101
+
102
+ # Seek to Variable Store
103
+ @io.seek(vstore_offset)
104
+
105
+ # Parse Variable Store structure
106
+ @variable_store = {
107
+ regions: read_region_list,
108
+ item_variation_data: read_item_variation_data,
109
+ }
110
+
111
+ @variable_store
112
+ end
113
+
114
+ # Read Region List from Variable Store
115
+ #
116
+ # Region List defines variation regions, where each region
117
+ # specifies min/peak/max values per axis.
118
+ #
119
+ # @return [Array<Hash>] Array of region definitions
120
+ def read_region_list
121
+ region_count = read_uint16
122
+ regions = []
123
+
124
+ region_count.times do
125
+ region = read_region
126
+ regions << region
127
+ end
128
+
129
+ regions
130
+ end
131
+
132
+ # Read a single region
133
+ #
134
+ # @return [Hash] Region with axis coordinates
135
+ def read_region
136
+ axis_count = read_uint16
137
+ axes = []
138
+
139
+ axis_count.times do
140
+ axes << {
141
+ start_coord: read_f2dot14,
142
+ peak_coord: read_f2dot14,
143
+ end_coord: read_f2dot14,
144
+ }
145
+ end
146
+
147
+ { axis_count: axis_count, axes: axes }
148
+ end
149
+
150
+ # Read Item Variation Data
151
+ #
152
+ # Contains delta arrays per region for varying values
153
+ #
154
+ # @return [Array<Hash>] Array of item variation data
155
+ def read_item_variation_data
156
+ data_count = read_uint16
157
+ return [] if data_count.zero?
158
+
159
+ item_variation_data = []
160
+
161
+ data_count.times do |_idx|
162
+ item_data = read_single_item_variation_data
163
+ item_variation_data << item_data
164
+ rescue EOFError
165
+ # break
166
+ end
167
+
168
+ item_variation_data
169
+ end
170
+
171
+ # Read a single Item Variation Data entry
172
+ #
173
+ # @return [Hash] Item variation data with region indices and deltas
174
+ def read_single_item_variation_data
175
+ item_count = read_uint16
176
+ short_delta_count = read_uint16
177
+ region_index_count = read_uint16
178
+
179
+ # Read region indices
180
+ region_indices = []
181
+ region_index_count.times do
182
+ region_indices << read_uint16
183
+ end
184
+
185
+ # Read delta sets
186
+ delta_sets = []
187
+ item_count.times do |_item_idx|
188
+ deltas = []
189
+
190
+ # Short deltas (16-bit)
191
+ short_delta_count.times do
192
+ break if @io.eof?
193
+
194
+ deltas << read_int16
195
+ end
196
+
197
+ # Long deltas (8-bit) for remaining regions
198
+ (region_index_count - short_delta_count).times do
199
+ break if @io.eof?
200
+
201
+ deltas << read_int8
202
+ end
203
+
204
+ delta_sets << deltas
205
+ rescue EOFError
206
+ # break
207
+ end
208
+
209
+ {
210
+ item_count: item_count,
211
+ region_indices: region_indices,
212
+ delta_sets: delta_sets,
213
+ }
214
+ end
215
+
216
+ # Read Private DICT with blend support
217
+ #
218
+ # Private DICT in CFF2 can contain blend operators for
219
+ # variable hint parameters.
220
+ #
221
+ # @param size [Integer] Private DICT size
222
+ # @param offset [Integer] Private DICT offset
223
+ # @return [Hash] Private DICT data
224
+ def read_private_dict(size, offset)
225
+ @io.seek(offset)
226
+ private_dict_data = @io.read(size)
227
+ parse_dict(private_dict_data)
228
+ end
229
+
230
+ # Read CharStrings INDEX
231
+ #
232
+ # @param offset [Integer] CharStrings offset from Top DICT
233
+ # @return [Cff::Index] CharStrings INDEX
234
+ def read_charstrings(offset)
235
+ @io.seek(offset)
236
+ require_relative "../cff/index"
237
+ Cff::Index.new(@io, start_offset: offset)
238
+ end
239
+
240
+ private
241
+
242
+ # Read bytes safely with EOF checking
243
+ #
244
+ # @param bytes [Integer] Number of bytes to read
245
+ # @param description [String] Description for error messages
246
+ # @return [String] Binary data
247
+ # @raise [EOFError] If not enough bytes available
248
+ def read_safely(bytes, description)
249
+ data = @io.read(bytes)
250
+ if data.nil? || data.bytesize < bytes
251
+ raise EOFError,
252
+ "Unexpected EOF while reading #{description}"
253
+ end
254
+
255
+ data
256
+ end
257
+
258
+ # Parse DICT structure
259
+ #
260
+ # @param data [String] DICT binary data
261
+ # @return [Hash] Parsed operators and values
262
+ def parse_dict(data)
263
+ dict = {}
264
+ io = StringIO.new(data)
265
+ io.set_encoding(Encoding::BINARY)
266
+ operands = []
267
+
268
+ until io.eof?
269
+ byte = io.getbyte
270
+
271
+ if operator_byte?(byte)
272
+ operator = read_dict_operator(io, byte)
273
+ dict[operator] =
274
+ operands.size == 1 ? operands.first : operands.dup
275
+ operands.clear
276
+ else
277
+ # Operand (number)
278
+ io.pos -= 1
279
+ operands << read_dict_number(io)
280
+ end
281
+ end
282
+
283
+ dict
284
+ end
285
+
286
+ # Check if byte is an operator
287
+ #
288
+ # CFF2 extends the operator range to include operator 24 (vstore)
289
+ #
290
+ # @param byte [Integer] Byte value
291
+ # @return [Boolean] True if operator
292
+ def operator_byte?(byte)
293
+ # Standard DICT operators (0-21, excluding number markers)
294
+ return true if byte <= 21 && ![12, 28, 29, 30, 31].include?(byte)
295
+
296
+ # CFF2-specific operators
297
+ return true if byte == VSTORE_OPERATOR
298
+
299
+ false
300
+ end
301
+
302
+ # Read DICT operator
303
+ #
304
+ # @param io [StringIO] Input stream
305
+ # @param first_byte [Integer] First operator byte
306
+ # @return [Integer, Array<Integer>] Operator code
307
+ def read_dict_operator(io, first_byte)
308
+ if first_byte == 12
309
+ # Two-byte operator
310
+ second_byte = io.getbyte
311
+ [12, second_byte]
312
+ else
313
+ first_byte
314
+ end
315
+ end
316
+
317
+ # Read number from DICT
318
+ #
319
+ # @param io [StringIO] Input stream
320
+ # @return [Integer, Float] Number value
321
+ def read_dict_number(io)
322
+ byte = io.getbyte
323
+
324
+ case byte
325
+ when 28
326
+ # 3-byte signed integer
327
+ b1 = io.getbyte
328
+ b2 = io.getbyte
329
+ value = (b1 << 8) | b2
330
+ value > 0x7FFF ? value - 0x10000 : value
331
+ when 29
332
+ # 5-byte signed integer
333
+ io.read(4).unpack1("l>")
334
+ when 30
335
+ # Real number
336
+ read_real_number(io)
337
+ when 32..246
338
+ byte - 139
339
+ when 247..250
340
+ b2 = io.getbyte
341
+ (byte - 247) * 256 + b2 + 108
342
+ when 251..254
343
+ b2 = io.getbyte
344
+ -(byte - 251) * 256 - b2 - 108
345
+ else
346
+ 0
347
+ end
348
+ end
349
+
350
+ # Read real number from DICT
351
+ #
352
+ # @param io [StringIO] Input stream
353
+ # @return [Float] Real number
354
+ def read_real_number(io)
355
+ nibbles = []
356
+ loop do
357
+ byte = io.getbyte
358
+ nibbles << ((byte >> 4) & 0x0F)
359
+ nibbles << (byte & 0x0F)
360
+ break if (byte & 0x0F) == 0x0F
361
+ end
362
+
363
+ str = ""
364
+ nibbles.each do |nibble|
365
+ case nibble
366
+ when 0..9 then str << nibble.to_s
367
+ when 0x0A then str << "."
368
+ when 0x0B then str << "E"
369
+ when 0x0C then str << "E-"
370
+ when 0x0E then str << "-"
371
+ when 0x0F then break
372
+ end
373
+ end
374
+
375
+ str.to_f
376
+ end
377
+
378
+ # Read unsigned 8-bit integer
379
+ #
380
+ # @return [Integer] Value
381
+ def read_uint8
382
+ read_safely(1, "uint8").unpack1("C")
383
+ end
384
+
385
+ # Read unsigned 16-bit integer (big-endian)
386
+ #
387
+ # @return [Integer] Value
388
+ def read_uint16
389
+ read_safely(2, "uint16").unpack1("n")
390
+ end
391
+
392
+ # Read signed 16-bit integer (big-endian)
393
+ #
394
+ # @return [Integer] Value
395
+ def read_int16
396
+ read_safely(2, "int16").unpack1("s>")
397
+ end
398
+
399
+ # Read signed 8-bit integer
400
+ #
401
+ # @return [Integer] Value
402
+ def read_int8
403
+ value = read_safely(1, "int8").unpack1("C")
404
+ value > 0x7F ? value - 0x100 : value
405
+ end
406
+
407
+ # Read F2DOT14 format (signed 16-bit fixed-point)
408
+ #
409
+ # F2DOT14 represents a number in 2.14 format:
410
+ # - 2 bits for integer part
411
+ # - 14 bits for fractional part
412
+ #
413
+ # @return [Float] Value
414
+ def read_f2dot14
415
+ value = read_int16
416
+ value / 16384.0
417
+ end
418
+ end
419
+ end
420
+ end
421
+ end
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Tables
5
+ class Cff2
6
+ # Variation data extractor for CFF2 Variable Store
7
+ #
8
+ # Extracts regions and deltas from the Variable Store and provides
9
+ # utilities for working with variation data.
10
+ #
11
+ # Reference: OpenType spec - Item Variation Store
12
+ # Reference: Adobe Technical Note #5177 (CFF2)
13
+ #
14
+ # @example Extracting variation data
15
+ # extractor = VariationDataExtractor.new(variable_store)
16
+ # regions = extractor.regions
17
+ # deltas = extractor.deltas_for_item(item_index)
18
+ class VariationDataExtractor
19
+ # @return [Hash] Variable Store data
20
+ attr_reader :variable_store
21
+
22
+ # @return [Array<Hash>] Extracted regions
23
+ attr_reader :regions
24
+
25
+ # @return [Array<Hash>] Item variation data
26
+ attr_reader :item_variation_data
27
+
28
+ # Initialize extractor with Variable Store data
29
+ #
30
+ # @param variable_store [Hash] Variable Store from CFF2TableReader
31
+ def initialize(variable_store)
32
+ @variable_store = variable_store
33
+ @regions = variable_store[:regions] || []
34
+ @item_variation_data = variable_store[:item_variation_data] || []
35
+ end
36
+
37
+ # Get deltas for a specific item
38
+ #
39
+ # @param item_index [Integer] Item index
40
+ # @param data_index [Integer] Item variation data index (default 0)
41
+ # @return [Array<Integer>, nil] Deltas for the item, or nil if not found
42
+ def deltas_for_item(item_index, data_index: 0)
43
+ return nil if data_index >= @item_variation_data.size
44
+
45
+ item_data = @item_variation_data[data_index]
46
+ return nil if item_index >= item_data[:delta_sets].size
47
+
48
+ item_data[:delta_sets][item_index]
49
+ end
50
+
51
+ # Get region indices for item variation data
52
+ #
53
+ # @param data_index [Integer] Item variation data index (default 0)
54
+ # @return [Array<Integer>] Region indices
55
+ def region_indices(data_index: 0)
56
+ return [] if data_index >= @item_variation_data.size
57
+
58
+ @item_variation_data[data_index][:region_indices] || []
59
+ end
60
+
61
+ # Get number of items in item variation data
62
+ #
63
+ # @param data_index [Integer] Item variation data index (default 0)
64
+ # @return [Integer] Number of items
65
+ def item_count(data_index: 0)
66
+ return 0 if data_index >= @item_variation_data.size
67
+
68
+ @item_variation_data[data_index][:item_count] || 0
69
+ end
70
+
71
+ # Get all deltas for all items
72
+ #
73
+ # @param data_index [Integer] Item variation data index (default 0)
74
+ # @return [Array<Array<Integer>>] Array of delta sets
75
+ def all_deltas(data_index: 0)
76
+ return [] if data_index >= @item_variation_data.size
77
+
78
+ @item_variation_data[data_index][:delta_sets] || []
79
+ end
80
+
81
+ # Calculate blended value for an item at specific coordinates
82
+ #
83
+ # @param item_index [Integer] Item index
84
+ # @param base_value [Numeric] Base value to blend
85
+ # @param scalars [Array<Float>] Region scalars for each region
86
+ # @param data_index [Integer] Item variation data index (default 0)
87
+ # @return [Float] Blended value
88
+ def blend_value(item_index, base_value, scalars, data_index: 0)
89
+ deltas = deltas_for_item(item_index, data_index: data_index)
90
+ return base_value.to_f unless deltas
91
+
92
+ indices = region_indices(data_index: data_index)
93
+
94
+ # Apply blend: result = base + Σ(delta[i] * scalar[region_index[i]])
95
+ result = base_value.to_f
96
+ deltas.each_with_index do |delta, i|
97
+ region_index = indices[i]
98
+ next unless region_index
99
+
100
+ scalar = scalars[region_index] || 0.0
101
+ result += delta.to_f * scalar
102
+ end
103
+
104
+ result
105
+ end
106
+
107
+ # Get region by index
108
+ #
109
+ # @param region_index [Integer] Region index
110
+ # @return [Hash, nil] Region data or nil if not found
111
+ def region(region_index)
112
+ return nil if region_index >= @regions.size
113
+
114
+ @regions[region_index]
115
+ end
116
+
117
+ # Get number of regions
118
+ #
119
+ # @return [Integer] Total number of regions
120
+ def region_count
121
+ @regions.size
122
+ end
123
+
124
+ # Get number of axes from first region
125
+ #
126
+ # @return [Integer] Number of axes
127
+ def axis_count
128
+ return 0 if @regions.empty?
129
+
130
+ @regions.first[:axis_count] || 0
131
+ end
132
+
133
+ # Check if Variable Store has data
134
+ #
135
+ # @return [Boolean] True if Variable Store contains data
136
+ def has_data?
137
+ !@regions.empty? && !@item_variation_data.empty?
138
+ end
139
+
140
+ # Extract all region coordinates as arrays
141
+ #
142
+ # Useful for debugging and validation
143
+ #
144
+ # @return [Array<Array<Hash>>] Array of regions with axis coordinates
145
+ def region_coordinates
146
+ @regions.map do |region|
147
+ region[:axes].map do |axis|
148
+ {
149
+ start: axis[:start_coord],
150
+ peak: axis[:peak_coord],
151
+ end: axis[:end_coord],
152
+ }
153
+ end
154
+ end
155
+ end
156
+
157
+ # Validate Variable Store structure
158
+ #
159
+ # @return [Array<String>] Array of validation errors (empty if valid)
160
+ def validate
161
+ errors = []
162
+
163
+ # Check regions consistency
164
+ if @regions.any?
165
+ expected_axes = @regions.first[:axis_count]
166
+ @regions.each_with_index do |region, i|
167
+ unless region[:axis_count] == expected_axes
168
+ errors << "Region #{i} has inconsistent axis_count: " \
169
+ "#{region[:axis_count]} vs #{expected_axes}"
170
+ end
171
+
172
+ unless region[:axes].size == expected_axes
173
+ errors << "Region #{i} has #{region[:axes].size} axes, " \
174
+ "expected #{expected_axes}"
175
+ end
176
+ end
177
+ end
178
+
179
+ # Check item variation data
180
+ @item_variation_data.each_with_index do |item_data, i|
181
+ item_count = item_data[:item_count]
182
+ delta_sets = item_data[:delta_sets]
183
+ region_indices = item_data[:region_indices]
184
+
185
+ unless delta_sets.size == item_count
186
+ errors << "Item variation data #{i} has #{delta_sets.size} " \
187
+ "delta sets, expected #{item_count}"
188
+ end
189
+
190
+ # Check each delta set has correct number of deltas
191
+ delta_sets.each_with_index do |deltas, j|
192
+ unless deltas.size == region_indices.size
193
+ errors << "Delta set #{j} in data #{i} has #{deltas.size} " \
194
+ "deltas, expected #{region_indices.size}"
195
+ end
196
+ end
197
+
198
+ # Check region indices are valid
199
+ region_indices.each_with_index do |idx, j|
200
+ if idx >= @regions.size
201
+ errors << "Region index #{idx} at position #{j} in data #{i} " \
202
+ "exceeds region count #{@regions.size}"
203
+ end
204
+ end
205
+ end
206
+
207
+ errors
208
+ end
209
+ end
210
+ end
211
+ end
212
+ end
@@ -22,7 +22,7 @@ module Fontisan
22
22
  # num_glyphs = cff2.glyph_count
23
23
  class Cff2 < Binary::BaseRecord
24
24
  # CFF2 header structure
25
- class Header < Binary::BaseRecord
25
+ class Cff2Header < Binary::BaseRecord
26
26
  uint8 :major_version
27
27
  uint8 :minor_version
28
28
  uint8 :header_size
@@ -53,7 +53,7 @@ module Fontisan
53
53
 
54
54
  # Get the CFF2 header
55
55
  #
56
- # @return [Header] Header structure
56
+ # @return [Cff2Header] Header structure
57
57
  def header
58
58
  parse unless @parsed
59
59
  @header
@@ -111,7 +111,7 @@ module Fontisan
111
111
  @num_axes,
112
112
  @global_subr_index,
113
113
  nil, # local subrs (CFF2 may not have them)
114
- 0 # vsindex
114
+ 0, # vsindex
115
115
  ).parse
116
116
  end
117
117
 
@@ -137,12 +137,12 @@ module Fontisan
137
137
 
138
138
  # Parse CFF2 header
139
139
  #
140
- # @return [Header] Parsed header
140
+ # @return [Cff2Header] Parsed header
141
141
  def parse_header
142
142
  data = raw_data
143
143
  return nil if data.nil? || data.bytesize < 5
144
144
 
145
- Header.read(data.byteslice(0, 5))
145
+ Cff2Header.read(data.byteslice(0, 5))
146
146
  end
147
147
 
148
148
  # Parse Global Subr INDEX
@@ -339,3 +339,8 @@ end
339
339
  require_relative "cff2/charstring_parser"
340
340
  require_relative "cff2/blend_operator"
341
341
  require_relative "cff2/operand_stack"
342
+ require_relative "cff2/table_reader"
343
+ require_relative "cff2/variation_data_extractor"
344
+ require_relative "cff2/region_matcher"
345
+ require_relative "cff2/private_dict_blend_handler"
346
+ require_relative "cff2/table_builder"