fontisan 0.2.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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +270 -131
  3. data/README.adoc +158 -4
  4. data/Rakefile +44 -47
  5. data/lib/fontisan/cli.rb +84 -33
  6. data/lib/fontisan/collection/builder.rb +81 -0
  7. data/lib/fontisan/collection/table_deduplicator.rb +76 -0
  8. data/lib/fontisan/commands/base_command.rb +16 -0
  9. data/lib/fontisan/commands/convert_command.rb +97 -170
  10. data/lib/fontisan/commands/instance_command.rb +71 -80
  11. data/lib/fontisan/commands/validate_command.rb +25 -0
  12. data/lib/fontisan/config/validation_rules.yml +1 -1
  13. data/lib/fontisan/constants.rb +10 -0
  14. data/lib/fontisan/converters/format_converter.rb +150 -1
  15. data/lib/fontisan/converters/outline_converter.rb +80 -18
  16. data/lib/fontisan/converters/woff_writer.rb +1 -1
  17. data/lib/fontisan/font_loader.rb +3 -5
  18. data/lib/fontisan/font_writer.rb +7 -6
  19. data/lib/fontisan/hints/hint_converter.rb +133 -0
  20. data/lib/fontisan/hints/postscript_hint_applier.rb +221 -140
  21. data/lib/fontisan/hints/postscript_hint_extractor.rb +100 -0
  22. data/lib/fontisan/hints/truetype_hint_applier.rb +90 -44
  23. data/lib/fontisan/hints/truetype_hint_extractor.rb +127 -0
  24. data/lib/fontisan/loading_modes.rb +2 -0
  25. data/lib/fontisan/models/font_export.rb +2 -2
  26. data/lib/fontisan/models/hint.rb +173 -1
  27. data/lib/fontisan/models/validation_report.rb +1 -1
  28. data/lib/fontisan/open_type_font.rb +25 -9
  29. data/lib/fontisan/open_type_font_extensions.rb +54 -0
  30. data/lib/fontisan/pipeline/format_detector.rb +249 -0
  31. data/lib/fontisan/pipeline/output_writer.rb +154 -0
  32. data/lib/fontisan/pipeline/strategies/base_strategy.rb +75 -0
  33. data/lib/fontisan/pipeline/strategies/instance_strategy.rb +93 -0
  34. data/lib/fontisan/pipeline/strategies/named_strategy.rb +118 -0
  35. data/lib/fontisan/pipeline/strategies/preserve_strategy.rb +56 -0
  36. data/lib/fontisan/pipeline/transformation_pipeline.rb +411 -0
  37. data/lib/fontisan/pipeline/variation_resolver.rb +165 -0
  38. data/lib/fontisan/tables/cff/charstring.rb +33 -4
  39. data/lib/fontisan/tables/cff/charstring_builder.rb +34 -0
  40. data/lib/fontisan/tables/cff/charstring_parser.rb +237 -0
  41. data/lib/fontisan/tables/cff/charstring_rebuilder.rb +172 -0
  42. data/lib/fontisan/tables/cff/dict_builder.rb +15 -0
  43. data/lib/fontisan/tables/cff/hint_operation_injector.rb +207 -0
  44. data/lib/fontisan/tables/cff/offset_recalculator.rb +70 -0
  45. data/lib/fontisan/tables/cff/private_dict_writer.rb +125 -0
  46. data/lib/fontisan/tables/cff/table_builder.rb +221 -0
  47. data/lib/fontisan/tables/cff.rb +2 -0
  48. data/lib/fontisan/tables/cff2/private_dict_blend_handler.rb +246 -0
  49. data/lib/fontisan/tables/cff2/region_matcher.rb +200 -0
  50. data/lib/fontisan/tables/cff2/table_builder.rb +574 -0
  51. data/lib/fontisan/tables/cff2/table_reader.rb +419 -0
  52. data/lib/fontisan/tables/cff2/variation_data_extractor.rb +212 -0
  53. data/lib/fontisan/tables/cff2.rb +9 -4
  54. data/lib/fontisan/tables/cvar.rb +2 -41
  55. data/lib/fontisan/tables/gvar.rb +2 -41
  56. data/lib/fontisan/true_type_font.rb +24 -9
  57. data/lib/fontisan/true_type_font_extensions.rb +54 -0
  58. data/lib/fontisan/utilities/checksum_calculator.rb +42 -0
  59. data/lib/fontisan/validation/checksum_validator.rb +2 -2
  60. data/lib/fontisan/validation/table_validator.rb +1 -1
  61. data/lib/fontisan/validation/variable_font_validator.rb +218 -0
  62. data/lib/fontisan/variation/converter.rb +120 -13
  63. data/lib/fontisan/variation/instance_writer.rb +341 -0
  64. data/lib/fontisan/variation/tuple_variation_header.rb +51 -0
  65. data/lib/fontisan/variation/variable_svg_generator.rb +268 -0
  66. data/lib/fontisan/variation/variation_preserver.rb +288 -0
  67. data/lib/fontisan/version.rb +1 -1
  68. data/lib/fontisan/version.rb.orig +9 -0
  69. data/lib/fontisan/woff2/glyf_transformer.rb +666 -0
  70. data/lib/fontisan/woff2/hmtx_transformer.rb +164 -0
  71. data/lib/fontisan/woff2_font.rb +475 -470
  72. data/lib/fontisan/woff_font.rb +16 -11
  73. data/lib/fontisan.rb +12 -0
  74. metadata +31 -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
@@ -239,6 +239,8 @@ module Fontisan
239
239
  # 3-byte signed integer (16-bit)
240
240
  b1 = io.getbyte
241
241
  b2 = io.getbyte
242
+ raise CorruptedTableError, "Unexpected end of CharString reading shortint" if
243
+ b1.nil? || b2.nil?
242
244
  value = (b1 << 8) | b2
243
245
  value > 0x7FFF ? value - 0x10000 : value
244
246
  when 32..246
@@ -247,14 +249,20 @@ module Fontisan
247
249
  when 247..250
248
250
  # Positive 2-byte integer: +108 to +1131
249
251
  b2 = io.getbyte
252
+ raise CorruptedTableError, "Unexpected end of CharString reading positive integer" if
253
+ b2.nil?
250
254
  (byte - 247) * 256 + b2 + 108
251
255
  when 251..254
252
256
  # Negative 2-byte integer: -108 to -1131
253
257
  b2 = io.getbyte
258
+ raise CorruptedTableError, "Unexpected end of CharString reading negative integer" if
259
+ b2.nil?
254
260
  -(byte - 251) * 256 - b2 - 108
255
261
  when 255
256
262
  # 5-byte signed integer (32-bit) as fixed-point 16.16
257
263
  bytes = io.read(4)
264
+ raise CorruptedTableError, "Unexpected end of CharString reading fixed-point" if
265
+ bytes.nil? || bytes.length < 4
258
266
  value = bytes.unpack1("l>") # Signed 32-bit big-endian
259
267
  value / 65536.0 # Convert to float
260
268
  else
@@ -360,6 +368,7 @@ module Fontisan
360
368
  # rmoveto takes 2 operands, so if stack has 3 and width not parsed,
361
369
  # first is width
362
370
  parse_width_for_operator(width_parsed, 2)
371
+ return if @stack.size < 2 # Need at least 2 values
363
372
  dy = @stack.pop
364
373
  dx = @stack.pop
365
374
  @x += dx
@@ -374,6 +383,7 @@ module Fontisan
374
383
  # hmoveto takes 1 operand, so if stack has 2 and width not parsed,
375
384
  # first is width
376
385
  parse_width_for_operator(width_parsed, 1)
386
+ return if @stack.empty? # Need at least 1 value
377
387
  dx = @stack.pop || 0
378
388
  @x += dx
379
389
  @path << { type: :move_to, x: @x, y: @y }
@@ -386,6 +396,7 @@ module Fontisan
386
396
  # vmoveto takes 1 operand, so if stack has 2 and width not parsed,
387
397
  # first is width
388
398
  parse_width_for_operator(width_parsed, 1)
399
+ return if @stack.empty? # Need at least 1 value
389
400
  dy = @stack.pop || 0
390
401
  @y += dy
391
402
  @path << { type: :move_to, x: @x, y: @y }
@@ -677,7 +688,7 @@ module Fontisan
677
688
  dy3 = @stack.shift
678
689
 
679
690
  x1 = @x + dx1
680
- y1 = @y + dy1
691
+ y1 = @y +dy1
681
692
  x2 = x1 + dx2
682
693
  y2 = y1 + dy2
683
694
  @x = x2 + dx3
@@ -773,7 +784,11 @@ module Fontisan
773
784
 
774
785
  # Call local subroutine
775
786
  def callsubr
776
- subr_index = @stack.pop + @subroutine_bias
787
+ return if @stack.empty?
788
+ subr_num = @stack.pop
789
+ return unless subr_num # Guard against empty stack
790
+
791
+ subr_index = subr_num + @subroutine_bias
777
792
  if @local_subrs && subr_index >= 0 && subr_index < @local_subrs.count
778
793
  subr_data = @local_subrs[subr_index]
779
794
  execute_subroutine(subr_data)
@@ -782,7 +797,11 @@ module Fontisan
782
797
 
783
798
  # Call global subroutine
784
799
  def callgsubr
785
- subr_index = @stack.pop + @global_subroutine_bias
800
+ return if @stack.empty?
801
+ subr_num = @stack.pop
802
+ return unless subr_num # Guard against empty stack
803
+
804
+ subr_index = subr_num + @global_subroutine_bias
786
805
  if subr_index >= 0 && subr_index < @global_subrs.count
787
806
  subr_data = @global_subrs[subr_index]
788
807
  execute_subroutine(subr_data)
@@ -818,39 +837,49 @@ module Fontisan
818
837
 
819
838
  # Arithmetic operators
820
839
  def arithmetic_add
840
+ return if @stack.size < 2
821
841
  b = @stack.pop
822
842
  a = @stack.pop
823
843
  @stack << (a + b)
824
844
  end
825
845
 
826
846
  def arithmetic_sub
847
+ return if @stack.size < 2
827
848
  b = @stack.pop
828
849
  a = @stack.pop
829
850
  @stack << (a - b)
830
851
  end
831
852
 
832
853
  def arithmetic_mul
854
+ return if @stack.size < 2
833
855
  b = @stack.pop
834
856
  a = @stack.pop
835
857
  @stack << (a * b)
836
858
  end
837
859
 
838
860
  def arithmetic_div
861
+ return if @stack.size < 2
839
862
  b = @stack.pop
840
863
  a = @stack.pop
864
+ return if b.zero?
841
865
  @stack << (a / b.to_f)
842
866
  end
843
867
 
844
868
  def arithmetic_neg
869
+ return if @stack.empty?
845
870
  @stack << -@stack.pop
846
871
  end
847
872
 
848
873
  def arithmetic_abs
874
+ return if @stack.empty?
849
875
  @stack << @stack.pop.abs
850
876
  end
851
877
 
852
878
  def arithmetic_sqrt
853
- @stack << Math.sqrt(@stack.pop)
879
+ return if @stack.empty?
880
+ val = @stack.pop
881
+ return if val.negative?
882
+ @stack << Math.sqrt(val)
854
883
  end
855
884
 
856
885
  # 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,237 @@
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
+ raise CorruptedTableError, "Unexpected end of CharString reading shortint" if
204
+ b1.nil? || b2.nil?
205
+ value = (b1 << 8) | b2
206
+ value > 0x7FFF ? value - 0x10000 : value
207
+ when 32..246
208
+ # Small integer: -107 to +107
209
+ byte - 139
210
+ when 247..250
211
+ # Positive 2-byte integer: +108 to +1131
212
+ b2 = io.getbyte
213
+ raise CorruptedTableError, "Unexpected end of CharString reading positive integer" if
214
+ b2.nil?
215
+ (byte - 247) * 256 + b2 + 108
216
+ when 251..254
217
+ # Negative 2-byte integer: -108 to -1131
218
+ b2 = io.getbyte
219
+ raise CorruptedTableError, "Unexpected end of CharString reading negative integer" if
220
+ b2.nil?
221
+ -(byte - 251) * 256 - b2 - 108
222
+ when 255
223
+ # 5-byte signed integer (32-bit) as fixed-point 16.16
224
+ bytes = io.read(4)
225
+ raise CorruptedTableError, "Unexpected end of CharString reading fixed-point" if
226
+ bytes.nil? || bytes.length < 4
227
+ value = bytes.unpack1("l>") # Signed 32-bit big-endian
228
+ value / 65536.0 # Convert to float
229
+ else
230
+ raise CorruptedTableError,
231
+ "Invalid CharString number byte: #{byte}"
232
+ end
233
+ end
234
+ end
235
+ end
236
+ end
237
+ end