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,580 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../cff/table_builder"
4
+ require_relative "table_reader"
5
+ require_relative "private_dict_blend_handler"
6
+ require_relative "../cff/charstring_parser"
7
+ require_relative "../cff/charstring_builder"
8
+ require_relative "../cff/hint_operation_injector"
9
+ require_relative "../cff/dict_builder"
10
+ require "stringio"
11
+
12
+ module Fontisan
13
+ module Tables
14
+ class Cff2
15
+ # Rebuilds CFF2 table with modifications while preserving variation data
16
+ #
17
+ # CFF2TableBuilder extends CFF TableBuilder to handle CFF2-specific
18
+ # structures including Variable Store and blend operators in CharStrings.
19
+ # It preserves variation data while applying hints to variable fonts.
20
+ #
21
+ # Key Principles:
22
+ # - Variable Store is read-only and preserved unchanged
23
+ # - Blend operators in CharStrings are maintained
24
+ # - Blend in Private DICT is preserved
25
+ # - Reuses Phase 1+2 infrastructure for CharString modification
26
+ #
27
+ # Reference: Adobe Technical Note #5177 (CFF2)
28
+ #
29
+ # @example Rebuild CFF2 with hints
30
+ # reader = CFF2TableReader.new(cff2_data)
31
+ # builder = CFF2TableBuilder.new(reader, hint_set)
32
+ # new_cff2 = builder.build
33
+ class TableBuilder < Tables::Cff::TableBuilder
34
+ # CFF2-specific operators not supported by CFF DictBuilder
35
+ INVALID_CFF_KEYS = [24].freeze # operator 24 = vstore (CFF2 only)
36
+
37
+ # @return [CFF2TableReader] CFF2 table reader
38
+ attr_reader :reader
39
+
40
+ # @return [Hash, nil] Variable Store data
41
+ attr_reader :variable_store
42
+
43
+ # @return [Integer] Number of variation axes
44
+ attr_reader :num_axes
45
+
46
+ # Initialize builder with CFF2 table reader and hint set
47
+ #
48
+ # @param reader [CFF2TableReader] CFF2 table reader
49
+ # @param hint_set [Object] Hint set with font-level and per-glyph hints
50
+ def initialize(reader, hint_set = nil)
51
+ @reader = reader
52
+ @hint_set = hint_set
53
+
54
+ # Read CFF2 structures
55
+ @reader.read_header
56
+ @reader.read_top_dict
57
+ @variable_store = @reader.read_variable_store
58
+
59
+ # Determine number of axes from Variable Store
60
+ @num_axes = extract_num_axes
61
+
62
+ # Don't call super - CFF2 has different structure
63
+ end
64
+
65
+ # Build CFF2 table with hints applied
66
+ #
67
+ # @return [String] Binary CFF2 table data
68
+ def build
69
+ # Check if we need to modify anything
70
+ return @reader.data unless should_modify?
71
+
72
+ # Extract and modify sections
73
+ header_data = extract_header
74
+ top_dict_hash = @reader.top_dict
75
+ charstrings_data = extract_and_modify_charstrings
76
+ private_dict_data = extract_and_modify_private_dict
77
+ vstore_data = extract_variable_store
78
+
79
+ # Rebuild CFF2 table
80
+ rebuild_cff2_table(
81
+ header: header_data,
82
+ top_dict: top_dict_hash,
83
+ charstrings: charstrings_data,
84
+ private_dict: private_dict_data,
85
+ vstore: vstore_data,
86
+ )
87
+ end
88
+
89
+ # Check if table has variation data
90
+ #
91
+ # @return [Boolean] True if Variable Store present
92
+ def variable?
93
+ !@variable_store.nil?
94
+ end
95
+
96
+ private
97
+
98
+ # Extract number of variation axes from Variable Store
99
+ #
100
+ # @return [Integer] Number of axes
101
+ def extract_num_axes
102
+ return 0 unless @variable_store
103
+
104
+ # Get from first region's axis count
105
+ regions = @variable_store[:regions]
106
+ return 0 if regions.nil? || regions.empty?
107
+
108
+ regions.first[:axis_count] || 0
109
+ end
110
+
111
+ # Extract CharStrings offset from Top DICT
112
+ #
113
+ # CFF2 Top DICT operator 17 contains CharStrings offset.
114
+ #
115
+ # @return [Integer] CharStrings offset
116
+ def extract_charstrings_offset
117
+ top_dict = @reader.top_dict
118
+ return nil unless top_dict
119
+
120
+ # Operator 17 = CharStrings offset
121
+ top_dict[17]
122
+ end
123
+
124
+ # Modify CharStrings with per-glyph hints
125
+ #
126
+ # Uses Phase 1 CharStringRebuilder and Phase 2 HintOperationInjector
127
+ # to inject hints while preserving blend operators.
128
+ #
129
+ # @param charstrings_index [CharstringsIndex] Source CharStrings INDEX
130
+ # @return [String] Modified CharStrings INDEX binary data
131
+ def modify_charstrings(charstrings_index)
132
+ return nil unless @hint_set
133
+
134
+ # Get hinted glyph IDs from HintSet
135
+ hinted_glyph_ids = @hint_set.hinted_glyph_ids
136
+ return nil if hinted_glyph_ids.empty?
137
+
138
+ # Create rebuilder with stem count
139
+ stem_count = calculate_stem_count
140
+ rebuilder = Cff::CharStringRebuilder.new(charstrings_index,
141
+ stem_count: stem_count)
142
+
143
+ # Modify each glyph with hints
144
+ hinted_glyph_ids.each do |glyph_id|
145
+ # Get hints for this glyph
146
+ hints = @hint_set.get_glyph_hints(glyph_id)
147
+ next if hints.nil? || hints.empty?
148
+
149
+ # Convert glyph_id to integer if it's a string
150
+ glyph_index = glyph_id.to_i
151
+
152
+ rebuilder.modify_charstring(glyph_index) do |operations|
153
+ # Inject hints while preserving blend operators
154
+ injector = Cff::HintOperationInjector.new
155
+ injector.inject(hints, operations)
156
+ end
157
+ end
158
+
159
+ # Rebuild CharStrings INDEX
160
+ rebuilder.rebuild
161
+ end
162
+
163
+ # Calculate stem count from font-level hints
164
+ #
165
+ # Stem count is needed for hintmask/cntrmask parsing.
166
+ # Extracted from blue values and stem snap arrays.
167
+ #
168
+ # @return [Integer] Total stem count (hstem + vstem)
169
+ def calculate_stem_count
170
+ return 0 unless @hint_set
171
+
172
+ # Get font-level hints (from private_dict_hints JSON)
173
+ return 0 unless @hint_set.respond_to?(:private_dict_hints)
174
+
175
+ begin
176
+ font_hints = JSON.parse(@hint_set.private_dict_hints || "{}")
177
+ rescue JSON::ParserError
178
+ return 0
179
+ end
180
+
181
+ return 0 if font_hints.nil? || font_hints.empty?
182
+
183
+ # Count stems from blue zones (hstem)
184
+ hstem_count = 0
185
+ blue_values = font_hints["blue_values"] || font_hints[:blue_values]
186
+ if blue_values.is_a?(Array)
187
+ hstem_count = blue_values.size / 2
188
+ end
189
+
190
+ # Count stems from stem snap (vstem)
191
+ vstem_count = 0
192
+ stem_snap_h = font_hints["stem_snap_h"] || font_hints[:stem_snap_h]
193
+ if stem_snap_h.is_a?(Array)
194
+ vstem_count = stem_snap_h.size
195
+ end
196
+
197
+ hstem_count + vstem_count
198
+ end
199
+
200
+ # Check if font-level hints are present
201
+ #
202
+ # @return [Boolean] True if private_dict_hints are present
203
+ def has_font_level_hints?
204
+ return false unless @hint_set.respond_to?(:private_dict_hints)
205
+
206
+ hints = JSON.parse(@hint_set.private_dict_hints || "{}")
207
+ !hints.empty?
208
+ rescue JSON::ParserError
209
+ false
210
+ end
211
+
212
+ # Modify Private DICT with font-level hints
213
+ #
214
+ # Handles variable hint values using PrivateDictBlendHandler
215
+ # while preserving existing blend operators.
216
+ #
217
+ # @return [Hash, nil] Modified Private DICT data
218
+ def modify_private_dict
219
+ # Read original Private DICT
220
+ private_dict_info = extract_private_dict_info
221
+ return nil unless private_dict_info
222
+
223
+ size, offset = private_dict_info
224
+ private_dict = @reader.read_private_dict(size, offset)
225
+
226
+ # Create handler
227
+ handler = PrivateDictBlendHandler.new(private_dict)
228
+
229
+ # Get font-level hints
230
+ font_hints = JSON.parse(@hint_set.private_dict_hints)
231
+
232
+ # Rebuild with hints (preserving blend)
233
+ handler.rebuild_with_hints(font_hints, num_axes: @num_axes)
234
+ end
235
+
236
+ # Extract Private DICT information from Top DICT
237
+ #
238
+ # @return [Array<Integer>, nil] [size, offset] or nil if not present
239
+ def extract_private_dict_info
240
+ # Extract from Top DICT (operator 18)
241
+ private_info = @reader.top_dict[18]
242
+ return nil unless private_info
243
+
244
+ # Format: [size, offset]
245
+ private_info
246
+ end
247
+
248
+ # Preserve Variable Store unchanged
249
+ #
250
+ # Variable Store is read-only for hint application.
251
+ # We simply copy it to output without modification.
252
+ #
253
+ # @return [Hash, nil] Variable Store data
254
+ def preserve_variable_store
255
+ @variable_store
256
+ end
257
+
258
+ # Check if modification is needed
259
+ #
260
+ # @return [Boolean] True if hints should be applied
261
+ def should_modify?
262
+ return false unless @hint_set
263
+
264
+ has_per_glyph = !@hint_set.hinted_glyph_ids.empty?
265
+ has_font_level = has_font_level_hints?
266
+
267
+ has_per_glyph || has_font_level
268
+ end
269
+
270
+ # Extract CFF2 header bytes
271
+ #
272
+ # @return [String] Binary header data
273
+ def extract_header
274
+ header_size = @reader.header[:header_size]
275
+ @reader.data[0, header_size]
276
+ end
277
+
278
+ # Extract and optionally modify CharStrings
279
+ #
280
+ # @return [String] CharStrings INDEX binary data
281
+ def extract_and_modify_charstrings
282
+ charstrings_offset = extract_charstrings_offset
283
+ return nil unless charstrings_offset
284
+
285
+ charstrings_index = @reader.read_charstrings(charstrings_offset)
286
+
287
+ if @hint_set && !@hint_set.hinted_glyph_ids.empty?
288
+ modify_charstrings(charstrings_index)
289
+ else
290
+ # Return original CharStrings as binary
291
+ extract_charstrings_binary(charstrings_offset)
292
+ end
293
+ end
294
+
295
+ # Extract CharStrings INDEX as binary
296
+ #
297
+ # @param offset [Integer] CharStrings offset in table
298
+ # @return [String] Binary CharStrings INDEX data
299
+ def extract_charstrings_binary(offset)
300
+ io = StringIO.new(@reader.data)
301
+ io.seek(offset)
302
+
303
+ # Read INDEX structure: count (2 bytes)
304
+ count = io.read(2).unpack1("n")
305
+ return [0].pack("n") if count.zero?
306
+
307
+ # Read offSize (1 byte)
308
+ off_size = io.read(1).unpack1("C")
309
+
310
+ # Calculate INDEX size
311
+ # count + offSize + (count+1)*offSize + data_size
312
+ offset_array_size = (count + 1) * off_size
313
+
314
+ # Read offset array to get data size
315
+ offsets = []
316
+ (count + 1).times do
317
+ offset_bytes = io.read(off_size)
318
+ case off_size
319
+ when 1
320
+ offsets << offset_bytes.unpack1("C")
321
+ when 2
322
+ offsets << offset_bytes.unpack1("n")
323
+ when 3
324
+ offsets << (offset_bytes.bytes[0] << 16 | offset_bytes.bytes[1] << 8 | offset_bytes.bytes[2])
325
+ when 4
326
+ offsets << offset_bytes.unpack1("N")
327
+ end
328
+ end
329
+
330
+ data_size = offsets.last - 1 # Offsets are 1-based
331
+
332
+ # Calculate total INDEX size
333
+ index_size = 2 + 1 + offset_array_size + data_size
334
+
335
+ # Reset and extract full INDEX
336
+ io.seek(offset)
337
+ io.read(index_size)
338
+ end
339
+
340
+ # Extract and optionally modify Private DICT
341
+ #
342
+ # @return [String, nil] Binary Private DICT data
343
+ def extract_and_modify_private_dict
344
+ if @hint_set && has_font_level_hints?
345
+ # Modify and serialize
346
+ modified_dict = modify_private_dict
347
+ return nil unless modified_dict
348
+
349
+ serialize_private_dict(modified_dict)
350
+ else
351
+ # Return original Private DICT
352
+ private_dict_info = extract_private_dict_info
353
+ return nil unless private_dict_info
354
+
355
+ size, offset = private_dict_info
356
+ @reader.data[offset, size]
357
+ end
358
+ end
359
+
360
+ # Extract Variable Store as binary (unchanged)
361
+ #
362
+ # @return [String, nil] Binary Variable Store data
363
+ def extract_variable_store
364
+ return nil unless @variable_store
365
+
366
+ vstore_offset = @reader.top_dict[24] # operator 24 = vstore
367
+ return nil unless vstore_offset
368
+
369
+ # Extract Variable Store bytes unchanged
370
+ # For simplicity, extract from vstore_offset to end of table
371
+ # In production, we'd parse structure to get exact size
372
+ @reader.data[vstore_offset..]
373
+ end
374
+
375
+ # Rebuild complete CFF2 table
376
+ #
377
+ # @param header [String] CFF2 header
378
+ # @param top_dict [Hash] Top DICT hash
379
+ # @param charstrings [String] CharStrings INDEX
380
+ # @param private_dict [String, nil] Private DICT
381
+ # @param vstore [String, nil] Variable Store
382
+ # @return [String] Complete CFF2 table binary
383
+ def rebuild_cff2_table(header:, top_dict:, charstrings:, private_dict:,
384
+ vstore:)
385
+ output = StringIO.new("".b)
386
+
387
+ # 1. Write Header
388
+ output.write(header)
389
+
390
+ # 2. Calculate offsets for all sections
391
+ offsets = calculate_cff2_offsets(
392
+ header_size: header.size,
393
+ charstrings: charstrings,
394
+ private_dict: private_dict,
395
+ vstore: vstore,
396
+ )
397
+
398
+ # 3. Build Top DICT with updated offsets
399
+ updated_top_dict = update_top_dict_offsets(top_dict, offsets)
400
+ top_dict_binary = serialize_top_dict(updated_top_dict)
401
+
402
+ # Write Top DICT
403
+ output.write(top_dict_binary)
404
+
405
+ # 4. Write CharStrings
406
+ output.write(charstrings) if charstrings
407
+
408
+ # 5. Write Private DICT
409
+ output.write(private_dict) if private_dict
410
+
411
+ # 6. Write Variable Store (UNCHANGED)
412
+ output.write(vstore) if vstore
413
+
414
+ output.string
415
+ end
416
+
417
+ # Calculate offsets for CFF2 sections
418
+ #
419
+ # @param header_size [Integer] Header size
420
+ # @param charstrings [String] CharStrings data
421
+ # @param private_dict [String, nil] Private DICT data
422
+ # @param vstore [String, nil] Variable Store data
423
+ # @return [Hash] Section offsets
424
+ def calculate_cff2_offsets(header_size:, charstrings:, private_dict:,
425
+ vstore:)
426
+ # Start after header
427
+ offset = header_size
428
+
429
+ # Top DICT offset (immediately after header)
430
+ top_dict_offset = offset
431
+
432
+ # Estimate Top DICT size (will be recalculated)
433
+ # For now, use original Top DICT size from reader
434
+ top_dict_size = estimate_top_dict_size
435
+
436
+ offset += top_dict_size
437
+
438
+ # CharStrings offset
439
+ charstrings_offset = offset
440
+ offset += charstrings&.size || 0
441
+
442
+ # Private DICT offset
443
+ private_dict_offset = offset
444
+ private_dict_size = private_dict&.size || 0
445
+ offset += private_dict_size
446
+
447
+ # Variable Store offset
448
+ vstore_offset = vstore ? offset : nil
449
+
450
+ {
451
+ top_dict: top_dict_offset,
452
+ charstrings: charstrings_offset,
453
+ private_dict: private_dict_offset,
454
+ private_dict_size: private_dict_size,
455
+ vstore: vstore_offset,
456
+ }
457
+ end
458
+
459
+ # Estimate Top DICT size
460
+ #
461
+ # @return [Integer] Estimated size
462
+ def estimate_top_dict_size
463
+ # Use original Top DICT size from reader as estimate
464
+ # In CFF2, Top DICT size is in header
465
+ top_dict_length = @reader.header[:top_dict_length]
466
+ top_dict_length || 50 # Default estimate
467
+ end
468
+
469
+ # Update Top DICT with new offsets
470
+ #
471
+ # @param top_dict [Hash] Original Top DICT
472
+ # @param offsets [Hash] Calculated offsets
473
+ # @return [Hash] Updated Top DICT
474
+ def update_top_dict_offsets(top_dict, offsets)
475
+ updated = top_dict.dup
476
+
477
+ # Update CharStrings offset (operator 17)
478
+ updated[17] = offsets[:charstrings]
479
+
480
+ # Update Private DICT [size, offset] (operator 18)
481
+ if offsets[:private_dict_size]&.positive?
482
+ updated[18] = [offsets[:private_dict_size], offsets[:private_dict]]
483
+ end
484
+
485
+ # Update Variable Store offset (operator 24)
486
+ updated[24] = offsets[:vstore] if offsets[:vstore]
487
+
488
+ updated
489
+ end
490
+
491
+ # Serialize Top DICT to binary
492
+ #
493
+ # @param dict [Hash] Top DICT hash with integer operator keys
494
+ # @return [String] Binary DICT data
495
+ def serialize_top_dict(dict)
496
+ require_relative "../cff/dict_builder"
497
+
498
+ # Convert integer operator keys to symbol keys for DictBuilder
499
+ symbol_dict = convert_operators_to_symbols(dict)
500
+ Cff::DictBuilder.build(symbol_dict)
501
+ end
502
+
503
+ # Serialize Private DICT to binary
504
+ #
505
+ # @param dict [Hash] Private DICT hash
506
+ # @return [String] Binary DICT data
507
+ def serialize_private_dict(dict)
508
+ require_relative "../cff/dict_builder"
509
+
510
+ # Convert integer operator keys to symbol keys for DictBuilder
511
+ symbol_dict = convert_operators_to_symbols(dict)
512
+ Cff::DictBuilder.build(symbol_dict)
513
+ end
514
+
515
+ # Convert integer operator keys to symbol keys
516
+ #
517
+ # @param dict [Hash] Dictionary with integer or string keys
518
+ # @return [Hash] Dictionary with symbol keys
519
+ def convert_operators_to_symbols(dict)
520
+ # Operator mapping: integer => symbol
521
+ operator_map = {
522
+ 0 => :version,
523
+ 1 => :notice,
524
+ 2 => :full_name,
525
+ 3 => :family_name,
526
+ 4 => :weight,
527
+ 5 => :font_bbox,
528
+ 6 => :blue_values,
529
+ 7 => :other_blues,
530
+ 8 => :family_blues,
531
+ 9 => :family_other_blues,
532
+ 10 => :std_hw,
533
+ 11 => :std_vw,
534
+ 15 => :charset,
535
+ 16 => :encoding,
536
+ 17 => :charstrings,
537
+ 18 => :private,
538
+ 19 => :subrs,
539
+ 20 => :default_width_x,
540
+ 21 => :nominal_width_x,
541
+ # Note: operator 24 (vstore) is CFF2-specific and handled separately
542
+ }
543
+
544
+ result = {}
545
+ dict.each do |key, value|
546
+ # Skip vstore (operator 24) - CFF2 specific, not in CFF DictBuilder
547
+ next if INVALID_CFF_KEYS.include?(key)
548
+
549
+ # Convert string keys to symbols for DictBuilder
550
+ symbol_key = if key.is_a?(String)
551
+ key.to_sym
552
+ elsif key.is_a?(Integer)
553
+ operator_map[key] || key
554
+ else
555
+ key
556
+ end
557
+
558
+ result[symbol_key] = value
559
+ end
560
+ result
561
+ end
562
+
563
+ # Validate CFF2 structure
564
+ #
565
+ # @return [Array<String>] Validation errors (empty if valid)
566
+ def validate
567
+ errors = []
568
+
569
+ errors << "Not a valid CFF2 table" unless @reader.header[:major_version] == 2
570
+
571
+ if variable? && @num_axes.zero?
572
+ errors << "CFF2 has Variable Store but no axes defined"
573
+ end
574
+
575
+ errors
576
+ end
577
+ end
578
+ end
579
+ end
580
+ end