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
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "stringio"
4
4
  require_relative "../binary/base_record"
5
+ require_relative "../variation/tuple_variation_header"
5
6
 
6
7
  module Fontisan
7
8
  module Tables
@@ -26,46 +27,6 @@ module Fontisan
26
27
  uint16 :tuple_variation_count
27
28
  uint16 :data_offset
28
29
 
29
- # Tuple variation header
30
- class TupleVariationHeader < Binary::BaseRecord
31
- uint16 :variation_data_size
32
- uint16 :tuple_index
33
-
34
- # Tuple index flags
35
- EMBEDDED_PEAK_TUPLE = 0x8000
36
- INTERMEDIATE_REGION = 0x4000
37
- PRIVATE_POINT_NUMBERS = 0x2000
38
- TUPLE_INDEX_MASK = 0x0FFF
39
-
40
- # Check if tuple has embedded peak coordinates
41
- #
42
- # @return [Boolean] True if embedded
43
- def embedded_peak_tuple?
44
- (tuple_index & EMBEDDED_PEAK_TUPLE) != 0
45
- end
46
-
47
- # Check if tuple has intermediate region
48
- #
49
- # @return [Boolean] True if intermediate region
50
- def intermediate_region?
51
- (tuple_index & INTERMEDIATE_REGION) != 0
52
- end
53
-
54
- # Check if tuple has private point numbers
55
- #
56
- # @return [Boolean] True if private points
57
- def private_point_numbers?
58
- (tuple_index & PRIVATE_POINT_NUMBERS) != 0
59
- end
60
-
61
- # Get shared tuple index
62
- #
63
- # @return [Integer] Tuple index
64
- def shared_tuple_index
65
- tuple_index & TUPLE_INDEX_MASK
66
- end
67
- end
68
-
69
30
  # Get version as a float
70
31
  #
71
32
  # @return [Float] Version number (e.g., 1.0)
@@ -109,7 +70,7 @@ module Fontisan
109
70
  break if offset + 4 > data.bytesize
110
71
 
111
72
  header_data = data.byteslice(offset, 4)
112
- header = TupleVariationHeader.read(header_data)
73
+ header = Variation::TupleVariationHeader.read(header_data)
113
74
  offset += 4
114
75
 
115
76
  tuple_info = {
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "stringio"
4
4
  require_relative "../binary/base_record"
5
+ require_relative "../variation/tuple_variation_header"
5
6
 
6
7
  module Fontisan
7
8
  module Tables
@@ -34,46 +35,6 @@ module Fontisan
34
35
  SHARED_POINT_NUMBERS = 0x8000
35
36
  LONG_OFFSETS = 0x0001
36
37
 
37
- # Tuple variation header
38
- class TupleVariationHeader < Binary::BaseRecord
39
- uint16 :variation_data_size
40
- uint16 :tuple_index
41
-
42
- # Tuple index flags
43
- EMBEDDED_PEAK_TUPLE = 0x8000
44
- INTERMEDIATE_REGION = 0x4000
45
- PRIVATE_POINT_NUMBERS = 0x2000
46
- TUPLE_INDEX_MASK = 0x0FFF
47
-
48
- # Check if tuple has embedded peak coordinates
49
- #
50
- # @return [Boolean] True if embedded
51
- def embedded_peak_tuple?
52
- (tuple_index & EMBEDDED_PEAK_TUPLE) != 0
53
- end
54
-
55
- # Check if tuple has intermediate region
56
- #
57
- # @return [Boolean] True if intermediate region
58
- def intermediate_region?
59
- (tuple_index & INTERMEDIATE_REGION) != 0
60
- end
61
-
62
- # Check if tuple has private point numbers
63
- #
64
- # @return [Boolean] True if private points
65
- def private_point_numbers?
66
- (tuple_index & PRIVATE_POINT_NUMBERS) != 0
67
- end
68
-
69
- # Get shared tuple index
70
- #
71
- # @return [Integer] Tuple index
72
- def shared_tuple_index
73
- tuple_index & TUPLE_INDEX_MASK
74
- end
75
- end
76
-
77
38
  # Get version as a float
78
39
  #
79
40
  # @return [Float] Version number (e.g., 1.0)
@@ -198,7 +159,7 @@ module Fontisan
198
159
  header_data = io.read(4)
199
160
  break if header_data.nil? || header_data.bytesize < 4
200
161
 
201
- header = TupleVariationHeader.read(header_data)
162
+ header = Variation::TupleVariationHeader.read(header_data)
202
163
 
203
164
  tuple_info = {
204
165
  data_size: header.variation_data_size,
@@ -279,6 +279,20 @@ module Fontisan
279
279
  true
280
280
  end
281
281
 
282
+ # Check if font is TrueType flavored
283
+ #
284
+ # @return [Boolean] true for TrueType fonts
285
+ def truetype?
286
+ true
287
+ end
288
+
289
+ # Check if font is CFF flavored
290
+ #
291
+ # @return [Boolean] false for TrueType fonts
292
+ def cff?
293
+ false
294
+ end
295
+
282
296
  # Check if font has a specific table
283
297
  #
284
298
  # @param tag [String] The table tag to check for
@@ -579,18 +593,19 @@ module Fontisan
579
593
  # @param path [String] Path to the TTF file
580
594
  # @return [void]
581
595
  def update_checksum_adjustment_in_file(path)
582
- # Calculate file checksum
583
- checksum = Utilities::ChecksumCalculator.calculate_file_checksum(path)
596
+ # Use tempfile-based checksum calculation for Windows compatibility
597
+ # This keeps the tempfile alive until we're done with the checksum
598
+ File.open(path, "r+b") do |io|
599
+ checksum, _tmpfile = Utilities::ChecksumCalculator.calculate_checksum_from_io_with_tempfile(io)
584
600
 
585
- # Calculate adjustment
586
- adjustment = Utilities::ChecksumCalculator.calculate_adjustment(checksum)
601
+ # Calculate adjustment
602
+ adjustment = Utilities::ChecksumCalculator.calculate_adjustment(checksum)
587
603
 
588
- # Find head table position
589
- head_entry = head_table
590
- return unless head_entry
604
+ # Find head table position
605
+ head_entry = head_table
606
+ return unless head_entry
591
607
 
592
- # Write adjustment to head table (offset 8 within head table)
593
- File.open(path, "r+b") do |io|
608
+ # Write adjustment to head table (offset 8 within head table)
594
609
  io.seek(head_entry.offset + 8)
595
610
  io.write([adjustment].pack("N"))
596
611
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ # Extensions to TrueTypeFont for table-based construction
5
+ class TrueTypeFont
6
+ # Create font from hash of tables
7
+ #
8
+ # This is used during font conversion when we have tables but not a file.
9
+ #
10
+ # @param tables [Hash<String, String>] Map of table tag to binary data
11
+ # @return [TrueTypeFont] New font instance
12
+ def self.from_tables(tables)
13
+ # Create minimal header structure
14
+ font = new
15
+ font.initialize_storage
16
+ font.loading_mode = LoadingModes::FULL
17
+
18
+ # Store table data
19
+ font.table_data = tables
20
+
21
+ # Build header from tables
22
+ num_tables = tables.size
23
+ max_power = 0
24
+ n = num_tables
25
+ while n > 1
26
+ n >>= 1
27
+ max_power += 1
28
+ end
29
+
30
+ search_range = (1 << max_power) * 16
31
+ entry_selector = max_power
32
+ range_shift = (num_tables * 16) - search_range
33
+
34
+ font.header.sfnt_version = 0x00010000 # TrueType
35
+ font.header.num_tables = num_tables
36
+ font.header.search_range = search_range
37
+ font.header.entry_selector = entry_selector
38
+ font.header.range_shift = range_shift
39
+
40
+ # Build table directory
41
+ font.tables.clear
42
+ tables.each_key do |tag|
43
+ entry = TableDirectory.new
44
+ entry.tag = tag
45
+ entry.checksum = 0 # Will be calculated on write
46
+ entry.offset = 0 # Will be calculated on write
47
+ entry.table_length = tables[tag].bytesize
48
+ font.tables << entry
49
+ end
50
+
51
+ font
52
+ end
53
+ end
54
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "stringio"
4
+ require "tempfile"
4
5
  require_relative "../constants"
5
6
 
6
7
  module Fontisan
@@ -101,6 +102,47 @@ module Fontisan
101
102
  sum
102
103
  end
103
104
 
105
+ # Calculate checksum from an IO object using a tempfile for Windows compatibility.
106
+ #
107
+ # This method creates a temporary file from the IO content to ensure proper
108
+ # file handle semantics on Windows, where file handles must remain open
109
+ # for checksum calculation. The tempfile reference is returned alongside
110
+ # the checksum to prevent premature garbage collection on Windows.
111
+ #
112
+ # @param io [IO] the IO object to read from (must be rewindable)
113
+ # @return [Array<Integer, Tempfile>] array containing [checksum, tempfile]
114
+ # The checksum value and the tempfile that must be kept alive until
115
+ # the caller is done with the checksum.
116
+ #
117
+ # @example
118
+ # checksum, tmpfile = ChecksumCalculator.calculate_checksum_from_io_with_tempfile(io)
119
+ # # Use checksum...
120
+ # # tmpfile will be GC'd when it goes out of scope, which is safe
121
+ #
122
+ # @note On Windows, Ruby's Tempfile automatically deletes the temp file when
123
+ # the Tempfile object is garbage collected. In multi-threaded environments,
124
+ # this can cause PermissionDenied errors if the file is deleted while
125
+ # another thread is still using it. By returning the tempfile reference,
126
+ # the caller can ensure it remains alive until all operations complete.
127
+ def self.calculate_checksum_from_io_with_tempfile(io)
128
+ io.rewind
129
+
130
+ # Create a tempfile to handle Windows file locking issues
131
+ tmpfile = Tempfile.new(["font", ".ttf"])
132
+ tmpfile.binmode
133
+
134
+ # Copy IO content to tempfile
135
+ IO.copy_stream(io, tmpfile)
136
+ tmpfile.close
137
+
138
+ # Calculate checksum from the tempfile
139
+ checksum = calculate_file_checksum(tmpfile.path)
140
+
141
+ # Return both checksum and tempfile to keep it alive
142
+ # The caller must keep the tempfile reference until done with checksum
143
+ [checksum, tmpfile]
144
+ end
145
+
104
146
  private_class_method :calculate_checksum_from_io
105
147
  end
106
148
  end
@@ -114,7 +114,7 @@ module Fontisan
114
114
  skip_tables = @checksum_config["skip_tables"] || []
115
115
 
116
116
  font.tables.each do |table_entry|
117
- tag = table_entry.tag
117
+ tag = table_entry.tag.to_s # Convert BinData field to string
118
118
 
119
119
  # Skip tables that are exempt from checksum validation
120
120
  next if skip_tables.include?(tag)
@@ -125,7 +125,7 @@ module Fontisan
125
125
 
126
126
  # Calculate checksum for the table
127
127
  calculated_checksum = calculate_table_checksum(table_data)
128
- declared_checksum = table_entry.checksum
128
+ declared_checksum = table_entry.checksum.to_i # Convert BinData field to integer
129
129
 
130
130
  # Special handling for head table (checksum adjustment field should be 0)
131
131
  if tag == Constants::HEAD_TAG
@@ -84,7 +84,7 @@ module Fontisan
84
84
  next if font.has_table?(table_tag)
85
85
 
86
86
  # Special case: CFF or CFF2 are alternatives
87
- if (table_tag == "CFF") && font.has_table?("CFF2")
87
+ if (table_tag == Constants::CFF_TAG) && font.has_table?("CFF2")
88
88
  next
89
89
  end
90
90
 
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Validation
5
+ # VariableFontValidator validates variable font structure
6
+ #
7
+ # Validates:
8
+ # - fvar table structure
9
+ # - Axis definitions and ranges
10
+ # - Instance definitions
11
+ # - Variation table consistency
12
+ # - Metrics variation tables
13
+ #
14
+ # @example Validate a variable font
15
+ # validator = VariableFontValidator.new(font)
16
+ # errors = validator.validate
17
+ # puts "Found #{errors.length} errors" if errors.any?
18
+ class VariableFontValidator
19
+ # Initialize validator with font
20
+ #
21
+ # @param font [TrueTypeFont, OpenTypeFont] Font to validate
22
+ def initialize(font)
23
+ @font = font
24
+ @errors = []
25
+ end
26
+
27
+ # Validate variable font
28
+ #
29
+ # @return [Array<String>] Array of error messages
30
+ def validate
31
+ return [] unless @font.has_table?("fvar")
32
+
33
+ validate_fvar_structure
34
+ validate_axes
35
+ validate_instances
36
+ validate_variation_tables
37
+ validate_metrics_variation
38
+
39
+ @errors
40
+ end
41
+
42
+ private
43
+
44
+ # Validate fvar table structure
45
+ #
46
+ # @return [void]
47
+ def validate_fvar_structure
48
+ fvar = @font.table("fvar")
49
+ return unless fvar
50
+
51
+ if !fvar.respond_to?(:axes) || fvar.axes.nil? || fvar.axes.empty?
52
+ @errors << "fvar: No axes defined"
53
+ return
54
+ end
55
+
56
+ if fvar.respond_to?(:axis_count) && fvar.axis_count != fvar.axes.length
57
+ @errors << "fvar: Axis count mismatch (expected #{fvar.axis_count}, got #{fvar.axes.length})"
58
+ end
59
+ end
60
+
61
+ # Validate all axes
62
+ #
63
+ # @return [void]
64
+ def validate_axes
65
+ fvar = @font.table("fvar")
66
+ return unless fvar.respond_to?(:axes)
67
+
68
+ fvar.axes.each_with_index do |axis, index|
69
+ validate_axis_range(axis, index)
70
+ validate_axis_tag(axis, index)
71
+ end
72
+ end
73
+
74
+ # Validate axis range values
75
+ #
76
+ # @param axis [Object] Axis object
77
+ # @param index [Integer] Axis index
78
+ # @return [void]
79
+ def validate_axis_range(axis, index)
80
+ return unless axis.respond_to?(:min_value) && axis.respond_to?(:max_value)
81
+
82
+ if axis.min_value > axis.max_value
83
+ tag = axis.respond_to?(:axis_tag) ? axis.axis_tag : "axis #{index}"
84
+ @errors << "Axis #{tag}: min_value (#{axis.min_value}) > max_value (#{axis.max_value})"
85
+ end
86
+
87
+ if axis.respond_to?(:default_value) && (axis.default_value < axis.min_value || axis.default_value > axis.max_value)
88
+ tag = axis.respond_to?(:axis_tag) ? axis.axis_tag : "axis #{index}"
89
+ @errors << "Axis #{tag}: default_value (#{axis.default_value}) out of range [#{axis.min_value}, #{axis.max_value}]"
90
+ end
91
+ end
92
+
93
+ # Validate axis tag format
94
+ #
95
+ # @param axis [Object] Axis object
96
+ # @param index [Integer] Axis index
97
+ # @return [void]
98
+ def validate_axis_tag(axis, index)
99
+ return unless axis.respond_to?(:axis_tag)
100
+
101
+ tag = axis.axis_tag
102
+ unless tag.is_a?(String) && tag.length == 4 && tag =~ /^[a-zA-Z]{4}$/
103
+ @errors << "Axis #{index}: invalid tag '#{tag}' (must be 4 ASCII letters)"
104
+ end
105
+ end
106
+
107
+ # Validate named instances
108
+ #
109
+ # @return [void]
110
+ def validate_instances
111
+ fvar = @font.table("fvar")
112
+ return unless fvar.respond_to?(:instances)
113
+ return unless fvar.instances
114
+
115
+ fvar.instances.each_with_index do |instance, idx|
116
+ validate_instance_coordinates(instance, idx, fvar)
117
+ end
118
+ end
119
+
120
+ # Validate instance coordinates
121
+ #
122
+ # @param instance [Object] Instance object
123
+ # @param idx [Integer] Instance index
124
+ # @param fvar [Object] fvar table
125
+ # @return [void]
126
+ def validate_instance_coordinates(instance, idx, fvar)
127
+ return unless instance.is_a?(Hash) && instance[:coordinates]
128
+
129
+ coords = instance[:coordinates]
130
+ axis_count = fvar.respond_to?(:axis_count) ? fvar.axis_count : fvar.axes.length
131
+
132
+ if coords.length != axis_count
133
+ @errors << "Instance #{idx}: coordinate count mismatch (expected #{axis_count}, got #{coords.length})"
134
+ end
135
+
136
+ coords.each_with_index do |value, axis_idx|
137
+ next if axis_idx >= fvar.axes.length
138
+
139
+ axis = fvar.axes[axis_idx]
140
+ next unless axis.respond_to?(:min_value) && axis.respond_to?(:max_value)
141
+
142
+ if value < axis.min_value || value > axis.max_value
143
+ tag = axis.respond_to?(:axis_tag) ? axis.axis_tag : "axis #{axis_idx}"
144
+ @errors << "Instance #{idx}: coordinate for #{tag} (#{value}) out of range [#{axis.min_value}, #{axis.max_value}]"
145
+ end
146
+ end
147
+ end
148
+
149
+ # Validate variation tables
150
+ #
151
+ # @return [void]
152
+ def validate_variation_tables
153
+ has_gvar = @font.has_table?("gvar")
154
+ has_cff2 = @font.has_table?("CFF2")
155
+ has_glyf = @font.has_table?("glyf")
156
+ has_cff = @font.has_table?("CFF ")
157
+
158
+ # TrueType variable fonts should have gvar
159
+ if has_glyf && !has_gvar
160
+ @errors << "TrueType variable font missing gvar table"
161
+ end
162
+
163
+ # CFF variable fonts should have CFF2
164
+ if has_cff && !has_cff2
165
+ @errors << "CFF variable font missing CFF2 table"
166
+ end
167
+
168
+ # Can't have both gvar and CFF2
169
+ if has_gvar && has_cff2
170
+ @errors << "Font has both gvar and CFF2 tables (incompatible)"
171
+ end
172
+ end
173
+
174
+ # Validate metrics variation tables
175
+ #
176
+ # @return [void]
177
+ def validate_metrics_variation
178
+ validate_hvar if @font.has_table?("HVAR")
179
+ validate_vvar if @font.has_table?("VVAR")
180
+ validate_mvar if @font.has_table?("MVAR")
181
+ end
182
+
183
+ # Validate HVAR table
184
+ #
185
+ # @return [void]
186
+ def validate_hvar
187
+ # HVAR validation would go here
188
+ # For now, just check it exists
189
+ hvar = @font.table_data["HVAR"]
190
+ if hvar.nil? || hvar.empty?
191
+ @errors << "HVAR table is empty"
192
+ end
193
+ end
194
+
195
+ # Validate VVAR table
196
+ #
197
+ # @return [void]
198
+ def validate_vvar
199
+ # VVAR validation would go here
200
+ vvar = @font.table_data["VVAR"]
201
+ if vvar.nil? || vvar.empty?
202
+ @errors << "VVAR table is empty"
203
+ end
204
+ end
205
+
206
+ # Validate MVAR table
207
+ #
208
+ # @return [void]
209
+ def validate_mvar
210
+ # MVAR validation would go here
211
+ mvar = @font.table_data["MVAR"]
212
+ if mvar.nil? || mvar.empty?
213
+ @errors << "MVAR table is empty"
214
+ end
215
+ end
216
+ end
217
+ end
218
+ end