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,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "strategies/base_strategy"
4
+ require_relative "strategies/preserve_strategy"
5
+ require_relative "strategies/instance_strategy"
6
+ require_relative "strategies/named_strategy"
7
+
8
+ module Fontisan
9
+ module Pipeline
10
+ # Resolves variation data using strategy pattern
11
+ #
12
+ # This class orchestrates variation resolution during font conversion by
13
+ # selecting and executing the appropriate strategy based on user intent.
14
+ # It follows the Strategy pattern to allow different approaches to handling
15
+ # variable font data.
16
+ #
17
+ # Three strategies are available:
18
+ # - PreserveStrategy: Keep variation data intact (for compatible formats)
19
+ # - InstanceStrategy: Generate static instance at coordinates
20
+ # - NamedStrategy: Use named instance from fvar table
21
+ #
22
+ # Strategy selection is explicit through the :strategy option. Each strategy
23
+ # has its own required and optional parameters.
24
+ #
25
+ # @example Preserve variation data
26
+ # resolver = VariationResolver.new(font, strategy: :preserve)
27
+ # tables = resolver.resolve
28
+ #
29
+ # @example Generate instance at coordinates
30
+ # resolver = VariationResolver.new(
31
+ # font,
32
+ # strategy: :instance,
33
+ # coordinates: { "wght" => 700.0 }
34
+ # )
35
+ # tables = resolver.resolve
36
+ #
37
+ # @example Use named instance
38
+ # resolver = VariationResolver.new(
39
+ # font,
40
+ # strategy: :named,
41
+ # instance_index: 0
42
+ # )
43
+ # tables = resolver.resolve
44
+ class VariationResolver
45
+ # @return [TrueTypeFont, OpenTypeFont] Font to process
46
+ attr_reader :font
47
+
48
+ # @return [Strategies::BaseStrategy] Selected strategy
49
+ attr_reader :strategy
50
+
51
+ # Initialize resolver with font and strategy
52
+ #
53
+ # @param font [TrueTypeFont, OpenTypeFont] Font to process
54
+ # @param options [Hash] Resolution options
55
+ # @option options [Symbol] :strategy Strategy to use (:preserve, :instance, :named)
56
+ # @option options [Hash] :coordinates Design space coordinates (for :instance)
57
+ # @option options [Integer] :instance_index Named instance index (for :named)
58
+ # @raise [ArgumentError] If strategy is missing or invalid
59
+ def initialize(font, options = {})
60
+ @font = font
61
+
62
+ strategy_type = options[:strategy]
63
+ raise ArgumentError, "strategy is required" unless strategy_type
64
+
65
+ @strategy = build_strategy(strategy_type, options)
66
+
67
+ # Validate strategy-specific requirements
68
+ validate_strategy_requirements(strategy_type, options)
69
+ end
70
+
71
+ # Resolve variation data
72
+ #
73
+ # Delegates to the selected strategy to process the font and return
74
+ # the appropriate tables.
75
+ #
76
+ # @return [Hash<String, String>] Font tables after resolution
77
+ def resolve
78
+ @strategy.resolve(@font)
79
+ end
80
+
81
+ # Check if resolution preserves variation data
82
+ #
83
+ # @return [Boolean] True if variation is preserved
84
+ def preserves_variation?
85
+ @strategy.preserves_variation?
86
+ end
87
+
88
+ # Get strategy name
89
+ #
90
+ # @return [Symbol] Strategy identifier
91
+ def strategy_name
92
+ @strategy.strategy_name
93
+ end
94
+
95
+ private
96
+
97
+ # Build strategy instance based on type
98
+ #
99
+ # @param type [Symbol] Strategy type (:preserve, :instance, :named)
100
+ # @param options [Hash] Strategy options
101
+ # @return [Strategies::BaseStrategy] Strategy instance
102
+ # @raise [ArgumentError] If strategy type is unknown
103
+ def build_strategy(type, options)
104
+ case type
105
+ when :preserve
106
+ Strategies::PreserveStrategy.new(options)
107
+ when :instance
108
+ Strategies::InstanceStrategy.new(options)
109
+ when :named
110
+ Strategies::NamedStrategy.new(options)
111
+ else
112
+ raise ArgumentError,
113
+ "Unknown strategy: #{type}. " \
114
+ "Valid strategies: :preserve, :instance, :named"
115
+ end
116
+ end
117
+
118
+ # Validate strategy-specific requirements
119
+ #
120
+ # @param type [Symbol] Strategy type
121
+ # @param options [Hash] Strategy options
122
+ # @raise [ArgumentError, InvalidCoordinatesError] If validation fails
123
+ def validate_strategy_requirements(type, options)
124
+ case type
125
+ when :instance
126
+ validate_instance_coordinates(options[:coordinates]) if options[:coordinates]
127
+ when :named
128
+ validate_named_instance_index(options[:instance_index]) if options[:instance_index]
129
+ end
130
+ end
131
+
132
+ # Validate coordinates for instance strategy
133
+ #
134
+ # @param coordinates [Hash] Coordinates to validate
135
+ # @raise [InvalidCoordinatesError] If coordinates invalid
136
+ def validate_instance_coordinates(coordinates)
137
+ return if coordinates.empty?
138
+
139
+ require_relative "../variation/variation_context"
140
+ context = Variation::VariationContext.new(@font)
141
+ context.validate_coordinates(coordinates)
142
+ end
143
+
144
+ # Validate instance index for named strategy
145
+ #
146
+ # @param instance_index [Integer] Instance index to validate
147
+ # @raise [ArgumentError] If index invalid
148
+ def validate_named_instance_index(instance_index)
149
+ require_relative "../variation/variation_context"
150
+ context = Variation::VariationContext.new(@font)
151
+
152
+ unless context.fvar
153
+ raise ArgumentError, "Font is not a variable font (no fvar table)"
154
+ end
155
+
156
+ instances = context.fvar.instances
157
+ if instance_index.negative? || instance_index >= instances.length
158
+ raise ArgumentError,
159
+ "Invalid instance index #{instance_index}. " \
160
+ "Font has #{instances.length} named instances."
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end
@@ -122,11 +122,11 @@ module Fontisan
122
122
  data = table.to_binary_s.dup
123
123
 
124
124
  # Calculate new numberOfHMetrics
125
- new_num_h_metrics = if hmtx && hmtx.h_metrics
126
- hmtx.h_metrics.size
127
- else
128
- calculate_number_of_h_metrics
129
- end
125
+ new_num_h_metrics = if hmtx&.h_metrics
126
+ hmtx.h_metrics.size
127
+ else
128
+ calculate_number_of_h_metrics
129
+ end
130
130
 
131
131
  # Update numberOfHMetrics field (at offset 34, uint16)
132
132
  data[34, 2] = [new_num_h_metrics].pack("n")
@@ -239,6 +239,11 @@ module Fontisan
239
239
  # 3-byte signed integer (16-bit)
240
240
  b1 = io.getbyte
241
241
  b2 = io.getbyte
242
+ if b1.nil? || b2.nil?
243
+ raise CorruptedTableError,
244
+ "Unexpected end of CharString reading shortint"
245
+ end
246
+
242
247
  value = (b1 << 8) | b2
243
248
  value > 0x7FFF ? value - 0x10000 : value
244
249
  when 32..246
@@ -247,14 +252,29 @@ module Fontisan
247
252
  when 247..250
248
253
  # Positive 2-byte integer: +108 to +1131
249
254
  b2 = io.getbyte
255
+ if b2.nil?
256
+ raise CorruptedTableError,
257
+ "Unexpected end of CharString reading positive integer"
258
+ end
259
+
250
260
  (byte - 247) * 256 + b2 + 108
251
261
  when 251..254
252
262
  # Negative 2-byte integer: -108 to -1131
253
263
  b2 = io.getbyte
264
+ if b2.nil?
265
+ raise CorruptedTableError,
266
+ "Unexpected end of CharString reading negative integer"
267
+ end
268
+
254
269
  -(byte - 251) * 256 - b2 - 108
255
270
  when 255
256
271
  # 5-byte signed integer (32-bit) as fixed-point 16.16
257
272
  bytes = io.read(4)
273
+ if bytes.nil? || bytes.length < 4
274
+ raise CorruptedTableError,
275
+ "Unexpected end of CharString reading fixed-point"
276
+ end
277
+
258
278
  value = bytes.unpack1("l>") # Signed 32-bit big-endian
259
279
  value / 65536.0 # Convert to float
260
280
  else
@@ -360,6 +380,8 @@ module Fontisan
360
380
  # rmoveto takes 2 operands, so if stack has 3 and width not parsed,
361
381
  # first is width
362
382
  parse_width_for_operator(width_parsed, 2)
383
+ return if @stack.size < 2 # Need at least 2 values
384
+
363
385
  dy = @stack.pop
364
386
  dx = @stack.pop
365
387
  @x += dx
@@ -374,6 +396,8 @@ module Fontisan
374
396
  # hmoveto takes 1 operand, so if stack has 2 and width not parsed,
375
397
  # first is width
376
398
  parse_width_for_operator(width_parsed, 1)
399
+ return if @stack.empty? # Need at least 1 value
400
+
377
401
  dx = @stack.pop || 0
378
402
  @x += dx
379
403
  @path << { type: :move_to, x: @x, y: @y }
@@ -386,6 +410,8 @@ module Fontisan
386
410
  # vmoveto takes 1 operand, so if stack has 2 and width not parsed,
387
411
  # first is width
388
412
  parse_width_for_operator(width_parsed, 1)
413
+ return if @stack.empty? # Need at least 1 value
414
+
389
415
  dy = @stack.pop || 0
390
416
  @y += dy
391
417
  @path << { type: :move_to, x: @x, y: @y }
@@ -773,7 +799,12 @@ module Fontisan
773
799
 
774
800
  # Call local subroutine
775
801
  def callsubr
776
- subr_index = @stack.pop + @subroutine_bias
802
+ return if @stack.empty?
803
+
804
+ subr_num = @stack.pop
805
+ return unless subr_num # Guard against empty stack
806
+
807
+ subr_index = subr_num + @subroutine_bias
777
808
  if @local_subrs && subr_index >= 0 && subr_index < @local_subrs.count
778
809
  subr_data = @local_subrs[subr_index]
779
810
  execute_subroutine(subr_data)
@@ -782,7 +813,12 @@ module Fontisan
782
813
 
783
814
  # Call global subroutine
784
815
  def callgsubr
785
- subr_index = @stack.pop + @global_subroutine_bias
816
+ return if @stack.empty?
817
+
818
+ subr_num = @stack.pop
819
+ return unless subr_num # Guard against empty stack
820
+
821
+ subr_index = subr_num + @global_subroutine_bias
786
822
  if subr_index >= 0 && subr_index < @global_subrs.count
787
823
  subr_data = @global_subrs[subr_index]
788
824
  execute_subroutine(subr_data)
@@ -818,39 +854,58 @@ module Fontisan
818
854
 
819
855
  # Arithmetic operators
820
856
  def arithmetic_add
857
+ return if @stack.size < 2
858
+
821
859
  b = @stack.pop
822
860
  a = @stack.pop
823
861
  @stack << (a + b)
824
862
  end
825
863
 
826
864
  def arithmetic_sub
865
+ return if @stack.size < 2
866
+
827
867
  b = @stack.pop
828
868
  a = @stack.pop
829
869
  @stack << (a - b)
830
870
  end
831
871
 
832
872
  def arithmetic_mul
873
+ return if @stack.size < 2
874
+
833
875
  b = @stack.pop
834
876
  a = @stack.pop
835
877
  @stack << (a * b)
836
878
  end
837
879
 
838
880
  def arithmetic_div
881
+ return if @stack.size < 2
882
+
839
883
  b = @stack.pop
840
884
  a = @stack.pop
885
+ return if b.zero?
886
+
841
887
  @stack << (a / b.to_f)
842
888
  end
843
889
 
844
890
  def arithmetic_neg
891
+ return if @stack.empty?
892
+
845
893
  @stack << -@stack.pop
846
894
  end
847
895
 
848
896
  def arithmetic_abs
897
+ return if @stack.empty?
898
+
849
899
  @stack << @stack.pop.abs
850
900
  end
851
901
 
852
902
  def arithmetic_sqrt
853
- @stack << Math.sqrt(@stack.pop)
903
+ return if @stack.empty?
904
+
905
+ val = @stack.pop
906
+ return if val.negative?
907
+
908
+ @stack << Math.sqrt(val)
854
909
  end
855
910
 
856
911
  # Parse width for a specific operator
@@ -118,6 +118,40 @@ module Fontisan
118
118
  @output.string
119
119
  end
120
120
 
121
+ # Build a CharString from operation list
122
+ #
123
+ # This method takes operations from CharStringParser and encodes them
124
+ # back to binary CharString format. Useful for CharString modification.
125
+ #
126
+ # @param operations [Array<Hash>] Array of operation hashes from parser
127
+ # @return [String] Binary CharString data
128
+ def self.build_from_operations(operations)
129
+ new.build_from_operations(operations)
130
+ end
131
+
132
+ # Instance method for building from operations
133
+ #
134
+ # @param operations [Array<Hash>] Array of operation hashes
135
+ # @return [String] Binary CharString data
136
+ def build_from_operations(operations)
137
+ @output = StringIO.new("".b)
138
+
139
+ operations.each do |op|
140
+ # Write operands
141
+ op[:operands].each { |operand| write_number(operand) }
142
+
143
+ # Write operator
144
+ write_operator(op[:name])
145
+
146
+ # Write hint data if present (for hintmask/cntrmask)
147
+ if op[:hint_data]
148
+ @output.write(op[:hint_data])
149
+ end
150
+ end
151
+
152
+ @output.string
153
+ end
154
+
121
155
  # Build an empty CharString (for .notdef or empty glyphs)
122
156
  #
123
157
  # @param width [Integer, nil] Glyph width
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module Fontisan
6
+ module Tables
7
+ class Cff
8
+ # CharString parser that converts binary CharString data to operation list
9
+ #
10
+ # Unlike [`CharString`](lib/fontisan/tables/cff/charstring.rb) which
11
+ # interprets and executes CharStrings for rendering, CharStringParser
12
+ # parses CharStrings into a list of operations that can be modified and
13
+ # rebuilt. This enables CharString manipulation for hint injection,
14
+ # subroutine optimization, and other transformations.
15
+ #
16
+ # Operation Format:
17
+ # ```ruby
18
+ # {
19
+ # type: :operator,
20
+ # name: :rmoveto,
21
+ # operands: [100, 200]
22
+ # }
23
+ # ```
24
+ #
25
+ # Reference: Adobe Type 2 CharString Format
26
+ # https://adobe-type-tools.github.io/font-tech-notes/pdfs/5177.Type2.pdf
27
+ #
28
+ # @example Parse a CharString
29
+ # parser = CharStringParser.new(charstring_data)
30
+ # operations = parser.parse
31
+ # operations.each { |op| puts "#{op[:name]} #{op[:operands]}" }
32
+ class CharStringParser
33
+ # Type 2 CharString operators (from CharString class)
34
+ OPERATORS = {
35
+ # Path construction operators
36
+ 1 => :hstem,
37
+ 3 => :vstem,
38
+ 4 => :vmoveto,
39
+ 5 => :rlineto,
40
+ 6 => :hlineto,
41
+ 7 => :vlineto,
42
+ 8 => :rrcurveto,
43
+ 10 => :callsubr,
44
+ 11 => :return,
45
+ 14 => :endchar,
46
+ 18 => :hstemhm,
47
+ 19 => :hintmask,
48
+ 20 => :cntrmask,
49
+ 21 => :rmoveto,
50
+ 22 => :hmoveto,
51
+ 23 => :vstemhm,
52
+ 24 => :rcurveline,
53
+ 25 => :rlinecurve,
54
+ 26 => :vvcurveto,
55
+ 27 => :hhcurveto,
56
+ 28 => :shortint,
57
+ 29 => :callgsubr,
58
+ 30 => :vhcurveto,
59
+ 31 => :hvcurveto,
60
+ # 12 prefix for two-byte operators
61
+ [12, 3] => :and,
62
+ [12, 4] => :or,
63
+ [12, 5] => :not,
64
+ [12, 9] => :abs,
65
+ [12, 10] => :add,
66
+ [12, 11] => :sub,
67
+ [12, 12] => :div,
68
+ [12, 14] => :neg,
69
+ [12, 15] => :eq,
70
+ [12, 18] => :drop,
71
+ [12, 20] => :put,
72
+ [12, 21] => :get,
73
+ [12, 22] => :ifelse,
74
+ [12, 23] => :random,
75
+ [12, 24] => :mul,
76
+ [12, 26] => :sqrt,
77
+ [12, 27] => :dup,
78
+ [12, 28] => :exch,
79
+ [12, 29] => :index,
80
+ [12, 30] => :roll,
81
+ [12, 34] => :hflex,
82
+ [12, 35] => :flex,
83
+ [12, 36] => :hflex1,
84
+ [12, 37] => :flex1,
85
+ }.freeze
86
+
87
+ # Operators that require hint mask bytes
88
+ HINTMASK_OPERATORS = %i[hintmask cntrmask].freeze
89
+
90
+ # @return [String] Binary CharString data
91
+ attr_reader :data
92
+
93
+ # @return [Array<Hash>] Parsed operations
94
+ attr_reader :operations
95
+
96
+ # Initialize parser with CharString data
97
+ #
98
+ # @param data [String] Binary CharString data
99
+ # @param stem_count [Integer] Number of stem hints (for hintmask)
100
+ def initialize(data, stem_count: 0)
101
+ @data = data
102
+ @stem_count = stem_count
103
+ @operations = []
104
+ end
105
+
106
+ # Parse CharString to operation list
107
+ #
108
+ # @return [Array<Hash>] Array of operation hashes
109
+ def parse
110
+ @operations = []
111
+ io = StringIO.new(@data)
112
+ operand_stack = []
113
+
114
+ until io.eof?
115
+ byte = io.getbyte
116
+
117
+ if operator_byte?(byte)
118
+ # Operator byte - read operator and create operation
119
+ operator = read_operator(io, byte)
120
+
121
+ # Read hint mask data if needed
122
+ hint_data = nil
123
+ if HINTMASK_OPERATORS.include?(operator)
124
+ hint_bytes = (@stem_count + 7) / 8
125
+ hint_data = io.read(hint_bytes) if hint_bytes.positive?
126
+ end
127
+
128
+ # Create operation
129
+ @operations << {
130
+ type: :operator,
131
+ name: operator,
132
+ operands: operand_stack.dup,
133
+ hint_data: hint_data,
134
+ }
135
+
136
+ # Clear operand stack
137
+ operand_stack.clear
138
+ else
139
+ # Operand byte - read number and push to stack
140
+ io.pos -= 1
141
+ number = read_number(io)
142
+ operand_stack << number
143
+ end
144
+ end
145
+
146
+ @operations
147
+ rescue StandardError => e
148
+ raise CorruptedTableError,
149
+ "Failed to parse CharString: #{e.message}"
150
+ end
151
+
152
+ # Update stem count (needed for hintmask operations)
153
+ #
154
+ # @param count [Integer] Number of stem hints
155
+ def stem_count=(count)
156
+ @stem_count = count
157
+ end
158
+
159
+ private
160
+
161
+ # Check if byte is an operator
162
+ #
163
+ # @param byte [Integer] Byte value
164
+ # @return [Boolean] True if operator byte
165
+ def operator_byte?(byte)
166
+ byte <= 31 && byte != 28 # Operators are 0-31 except 28 (shortint)
167
+ end
168
+
169
+ # Read operator from CharString
170
+ #
171
+ # @param io [StringIO] Input stream
172
+ # @param first_byte [Integer] First operator byte
173
+ # @return [Symbol] Operator name
174
+ def read_operator(io, first_byte)
175
+ if first_byte == 12
176
+ # Two-byte operator
177
+ second_byte = io.getbyte
178
+ raise CorruptedTableError, "Unexpected end of CharString" if
179
+ second_byte.nil?
180
+
181
+ operator_key = [first_byte, second_byte]
182
+ OPERATORS[operator_key] || :unknown
183
+ else
184
+ # Single-byte operator
185
+ OPERATORS[first_byte] || :unknown
186
+ end
187
+ end
188
+
189
+ # Read a number (integer or real) from CharString
190
+ #
191
+ # @param io [StringIO] Input stream
192
+ # @return [Integer, Float] The number value
193
+ def read_number(io)
194
+ byte = io.getbyte
195
+ raise CorruptedTableError, "Unexpected end of CharString" if
196
+ byte.nil?
197
+
198
+ case byte
199
+ when 28
200
+ # 3-byte signed integer (16-bit)
201
+ b1 = io.getbyte
202
+ b2 = io.getbyte
203
+ if b1.nil? || b2.nil?
204
+ raise CorruptedTableError,
205
+ "Unexpected end of CharString reading shortint"
206
+ end
207
+
208
+ value = (b1 << 8) | b2
209
+ value > 0x7FFF ? value - 0x10000 : value
210
+ when 32..246
211
+ # Small integer: -107 to +107
212
+ byte - 139
213
+ when 247..250
214
+ # Positive 2-byte integer: +108 to +1131
215
+ b2 = io.getbyte
216
+ if b2.nil?
217
+ raise CorruptedTableError,
218
+ "Unexpected end of CharString reading positive integer"
219
+ end
220
+
221
+ (byte - 247) * 256 + b2 + 108
222
+ when 251..254
223
+ # Negative 2-byte integer: -108 to -1131
224
+ b2 = io.getbyte
225
+ if b2.nil?
226
+ raise CorruptedTableError,
227
+ "Unexpected end of CharString reading negative integer"
228
+ end
229
+
230
+ -(byte - 251) * 256 - b2 - 108
231
+ when 255
232
+ # 5-byte signed integer (32-bit) as fixed-point 16.16
233
+ bytes = io.read(4)
234
+ if bytes.nil? || bytes.length < 4
235
+ raise CorruptedTableError,
236
+ "Unexpected end of CharString reading fixed-point"
237
+ end
238
+
239
+ value = bytes.unpack1("l>") # Signed 32-bit big-endian
240
+ value / 65536.0 # Convert to float
241
+ else
242
+ raise CorruptedTableError,
243
+ "Invalid CharString number byte: #{byte}"
244
+ end
245
+ end
246
+ end
247
+ end
248
+ end
249
+ end