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
@@ -2,10 +2,9 @@
2
2
 
3
3
  require "thor"
4
4
  require_relative "base_command"
5
- require_relative "../variable/instancer"
5
+ require_relative "../variation/instance_generator"
6
+ require_relative "../variation/instance_writer"
6
7
  require_relative "../variation/validator"
7
- require_relative "../variation/parallel_generator"
8
- require_relative "../converters/format_converter"
9
8
  require_relative "../error"
10
9
 
11
10
  module Fontisan
@@ -20,19 +19,18 @@ module Fontisan
20
19
  # - Validation before generation
21
20
  # - Dry-run mode for previewing
22
21
  # - Progress tracking
23
- # - Parallel batch generation
24
22
  #
25
23
  # @example Instance at coordinates
26
24
  # fontisan instance variable.ttf --wght=700 --output=bold.ttf
27
25
  #
26
+ # @example Instance with format conversion
27
+ # fontisan instance variable.ttf --wght=700 --to=otf --output=bold.otf
28
+ #
28
29
  # @example Instance with validation
29
30
  # fontisan instance variable.ttf --wght=700 --validate --output=bold.ttf
30
31
  #
31
32
  # @example Dry-run to preview
32
33
  # fontisan instance variable.ttf --wght=700 --dry-run
33
- #
34
- # @example Instance with progress
35
- # fontisan instance variable.ttf --wght=700 --progress --output=bold.ttf
36
34
  class InstanceCommand < BaseCommand
37
35
  # Instance a variable font at specified coordinates
38
36
  #
@@ -44,18 +42,15 @@ module Fontisan
44
42
  # Validate font if requested
45
43
  validate_font(font) if options[:validate]
46
44
 
47
- # Create instancer
48
- instancer = Variable::Instancer.new(font)
49
-
50
45
  # Handle list-instances option
51
46
  if options[:list_instances]
52
- list_instances(instancer)
47
+ list_instances(font)
53
48
  return
54
49
  end
55
50
 
56
51
  # Handle dry-run mode
57
52
  if options[:dry_run]
58
- preview_instance(instancer, options)
53
+ preview_instance(font, input_path, options)
59
54
  return
60
55
  end
61
56
 
@@ -64,20 +59,19 @@ module Fontisan
64
59
 
65
60
  # Generate instance
66
61
  if options[:named_instance]
67
- instance_named(instancer, options[:named_instance], output_path,
68
- options)
62
+ instance_named(font, options[:named_instance], output_path, options)
69
63
  else
70
- instance_coords(instancer, extract_coordinates(options), output_path,
64
+ instance_coords(font, extract_coordinates(options), output_path,
71
65
  options)
72
66
  end
73
67
 
74
68
  puts "Static font instance written to: #{output_path}"
75
69
  rescue VariationError => e
76
- $stderr.puts "Variation Error: #{e.detailed_message}"
70
+ warn "Variation Error: #{e.detailed_message}"
77
71
  exit 1
78
72
  rescue StandardError => e
79
- $stderr.puts "Error: #{e.message}"
80
- $stderr.puts e.backtrace.first(5).join("\n") if options[:verbose]
73
+ warn "Error: #{e.message}"
74
+ warn e.backtrace.first(5).join("\n") if options[:verbose]
81
75
  exit 1
82
76
  end
83
77
 
@@ -93,9 +87,9 @@ module Fontisan
93
87
  errors = validator.validate
94
88
 
95
89
  if errors.any?
96
- $stderr.puts "Validation errors found:"
90
+ warn "Validation errors found:"
97
91
  errors.each do |error|
98
- $stderr.puts " - #{error}"
92
+ warn " - #{error}"
99
93
  end
100
94
  exit 1
101
95
  end
@@ -105,9 +99,10 @@ module Fontisan
105
99
 
106
100
  # Preview instance without generating
107
101
  #
108
- # @param instancer [Variable::Instancer] Instancer object
102
+ # @param font [Object] Font object
103
+ # @param input_path [String] Input file path
109
104
  # @param options [Hash] Command options
110
- def preview_instance(instancer, options)
105
+ def preview_instance(_font, input_path, options)
111
106
  coords = extract_coordinates(options)
112
107
 
113
108
  if coords.empty?
@@ -122,18 +117,20 @@ module Fontisan
122
117
  puts " #{axis}: #{value}"
123
118
  end
124
119
  puts
125
- puts "Output would be written to: #{determine_output_path(@input_path, options)}"
120
+ puts "Output would be written to: #{determine_output_path(input_path,
121
+ options)}"
122
+ puts "Output format: #{options[:to] || 'same as input'}"
126
123
  puts
127
124
  puts "Use without --dry-run to actually generate the instance."
128
125
  end
129
126
 
130
127
  # Instance at specific coordinates
131
128
  #
132
- # @param instancer [Variable::Instancer] Instancer object
129
+ # @param font [Object] Font object
133
130
  # @param coords [Hash] User coordinates
134
131
  # @param output_path [String] Output file path
135
132
  # @param options [Hash] Command options
136
- def instance_coords(instancer, coords, output_path, options)
133
+ def instance_coords(font, coords, output_path, options)
137
134
  if coords.empty?
138
135
  raise ArgumentError,
139
136
  "No coordinates specified. Use --wght=700, --wdth=100, etc."
@@ -142,47 +139,64 @@ module Fontisan
142
139
  # Show progress if requested
143
140
  print "Generating instance..." if options[:progress]
144
141
 
145
- # Generate instance
146
- binary = instancer.instance(coords)
142
+ # Generate instance tables using InstanceGenerator
143
+ generator = Variation::InstanceGenerator.new(font, coords)
144
+ tables = generator.generate
147
145
 
148
146
  puts " done" if options[:progress]
149
147
 
150
- # Convert format if requested
151
- if options[:to]
152
- print "Converting format..." if options[:progress]
153
- binary = convert_format(binary, options)
154
- puts " done" if options[:progress]
155
- end
156
-
157
- # Write to file
148
+ # Write instance using InstanceWriter
158
149
  print "Writing output..." if options[:progress]
159
- File.binwrite(output_path, binary)
150
+
151
+ # Detect source format for conversion
152
+ source_format = detect_source_format(font)
153
+
154
+ Variation::InstanceWriter.write(
155
+ tables,
156
+ output_path,
157
+ format: options[:to]&.to_sym,
158
+ source_format: source_format,
159
+ optimize: options[:optimize] || false,
160
+ )
161
+
160
162
  puts " done" if options[:progress]
161
163
  end
162
164
 
163
165
  # Instance using named instance
164
166
  #
165
- # @param instancer [Variable::Instancer] Instancer object
166
- # @param instance_name [String] Named instance name
167
+ # @param font [Object] Font object
168
+ # @param instance_index [Integer] Named instance index
167
169
  # @param output_path [String] Output file path
168
170
  # @param options [Hash] Command options
169
- def instance_named(instancer, instance_name, output_path, options)
170
- # Generate instance
171
- binary = instancer.instance_named(instance_name)
172
-
173
- # Convert format if requested
174
- binary = convert_format(binary, options) if options[:to]
175
-
176
- # Write to file
177
- File.binwrite(output_path, binary)
171
+ def instance_named(font, instance_index, output_path, options)
172
+ # Generate instance using named instance
173
+ generator = Variation::InstanceGenerator.new(font)
174
+ tables = generator.generate_named_instance(instance_index)
175
+
176
+ # Detect source format
177
+ source_format = detect_source_format(font)
178
+
179
+ # Write instance
180
+ Variation::InstanceWriter.write(
181
+ tables,
182
+ output_path,
183
+ format: options[:to]&.to_sym,
184
+ source_format: source_format,
185
+ optimize: options[:optimize] || false,
186
+ )
178
187
  end
179
188
 
180
189
  # List available named instances
181
190
  #
182
- # @param instancer [Variable::Instancer] Instancer object
183
- def list_instances(instancer)
184
- instances = instancer.named_instances
191
+ # @param font [Object] Font object
192
+ def list_instances(font)
193
+ fvar = font.table("fvar")
194
+ unless fvar
195
+ puts "Not a variable font - no named instances available."
196
+ return
197
+ end
185
198
 
199
+ instances = fvar.instances
186
200
  if instances.empty?
187
201
  puts "No named instances defined in font."
188
202
  return
@@ -191,11 +205,15 @@ module Fontisan
191
205
  puts "Available named instances:"
192
206
  puts
193
207
 
194
- instances.each do |instance|
195
- puts " #{instance[:name]}"
208
+ instances.each_with_index do |instance, index|
209
+ name_id = instance[:subfamily_name_id]
210
+ puts " [#{index}] Instance #{name_id}"
196
211
  puts " Coordinates:"
197
- instance[:coordinates].each do |axis, value|
198
- puts " #{axis}: #{value}"
212
+ instance[:coordinates].each_with_index do |value, axis_index|
213
+ next if axis_index >= fvar.axes.length
214
+
215
+ axis = fvar.axes[axis_index]
216
+ puts " #{axis.axis_tag}: #{value}"
199
217
  end
200
218
  puts
201
219
  end
@@ -243,38 +261,12 @@ module Fontisan
243
261
  "#{dir}/#{base}-instance.#{ext}"
244
262
  end
245
263
 
246
- # Convert format using FormatConverter
264
+ # Detect source format from font
247
265
  #
248
- # @param binary [String] Font binary
249
- # @param options [Hash] Command options
250
- # @return [String] Converted binary
251
- def convert_format(binary, options)
252
- target_format = options[:to].to_sym
253
-
254
- # Load font from binary
255
- require "tempfile"
256
- Tempfile.create(["instance", ".ttf"]) do |temp_file|
257
- temp_file.binmode
258
- temp_file.write(binary)
259
- temp_file.flush
260
-
261
- font = FontLoader.load(temp_file.path)
262
- converter = Converters::FormatConverter.new
263
-
264
- result = converter.convert(font, target_format)
265
-
266
- case target_format
267
- when :woff, :woff2
268
- result[:font_data]
269
- when :svg
270
- result[:svg_xml]
271
- else
272
- binary
273
- end
274
- end
275
- rescue StandardError => e
276
- warn "Format conversion failed: #{e.message}"
277
- binary
266
+ # @param font [Object] Font object
267
+ # @return [Symbol] Source format (:ttf or :otf)
268
+ def detect_source_format(font)
269
+ font.has_table?("CFF ") || font.has_table?("CFF2") ? :otf : :ttf
278
270
  end
279
271
 
280
272
  # Load font from file
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "base_command"
4
4
  require_relative "../validation/validator"
5
+ require_relative "../validation/variable_font_validator"
5
6
  require_relative "../font_loader"
6
7
 
7
8
  module Fontisan
@@ -53,6 +54,9 @@ quiet: false)
53
54
  # Run validation
54
55
  report = validator.validate(font, @input)
55
56
 
57
+ # Add variable font validation if applicable
58
+ validate_variable_font(font, report) if font.has_table?("fvar")
59
+
56
60
  # Output results unless quiet mode
57
61
  output_report(report) unless @quiet
58
62
 
@@ -66,6 +70,30 @@ quiet: false)
66
70
 
67
71
  private
68
72
 
73
+ # Validate variable font structure
74
+ #
75
+ # @param font [TrueTypeFont, OpenTypeFont] The font to validate
76
+ # @param report [Models::ValidationReport] The validation report to update
77
+ # @return [void]
78
+ def validate_variable_font(font, report)
79
+ var_validator = Validation::VariableFontValidator.new(font)
80
+ errors = var_validator.validate
81
+
82
+ if errors.any?
83
+ puts "\nVariable font validation:" if @verbose && !@quiet
84
+ errors.each do |error|
85
+ puts " ERROR: #{error}" if @verbose && !@quiet
86
+ # Add to report if report supports adding errors
87
+ if report.respond_to?(:errors)
88
+ report.errors << { message: error,
89
+ category: "variable_font" }
90
+ end
91
+ end
92
+ elsif @verbose && !@quiet
93
+ puts "\n✓ Variable font structure valid"
94
+ end
95
+ end
96
+
69
97
  # Validate command parameters
70
98
  #
71
99
  # @raise [ArgumentError] if parameters are invalid
@@ -23,7 +23,7 @@ required_tables:
23
23
 
24
24
  # Additional tables required for OpenType/CFF fonts (.otf)
25
25
  opentype_cff:
26
- - CFF # or CFF2 for CFF2 format
26
+ - "CFF " # or CFF2 for CFF2 format (note: CFF has trailing space per OpenType spec)
27
27
 
28
28
  # Additional tables required for variable fonts
29
29
  variable:
@@ -87,6 +87,16 @@ module Fontisan
87
87
  # CFF2 table tag identifier (CFF version 2 with variations)
88
88
  CFF2_TAG = "CFF2"
89
89
 
90
+ # TrueType hinting tables
91
+ # Font Program table (TrueType bytecode executed once at font load)
92
+ FPGM_TAG = "fpgm"
93
+
94
+ # Control Value Program table (TrueType bytecode for initialization)
95
+ PREP_TAG = "prep"
96
+
97
+ # Control Value Table (metrics used by TrueType hinting)
98
+ CVT_TAG = "cvt "
99
+
90
100
  # Magic number used for font file checksum adjustment calculation.
91
101
  # This constant is used in conjunction with the file checksum to compute
92
102
  # the checksumAdjustment value stored in the 'head' table.
@@ -107,30 +117,30 @@ module Fontisan
107
117
  # These strings are frozen and reused to reduce memory allocations
108
118
  # when parsing fonts with common subfamily names.
109
119
  STRING_POOL = {
110
- "Regular" => "Regular".freeze,
111
- "Bold" => "Bold".freeze,
112
- "Italic" => "Italic".freeze,
113
- "Bold Italic" => "Bold Italic".freeze,
114
- "BoldItalic" => "BoldItalic".freeze,
115
- "Light" => "Light".freeze,
116
- "Medium" => "Medium".freeze,
117
- "Semibold" => "Semibold".freeze,
118
- "SemiBold" => "SemiBold".freeze,
119
- "Black" => "Black".freeze,
120
- "Thin" => "Thin".freeze,
121
- "ExtraLight" => "ExtraLight".freeze,
122
- "Extra Light" => "Extra Light".freeze,
123
- "ExtraBold" => "ExtraBold".freeze,
124
- "Extra Bold" => "Extra Bold".freeze,
125
- "Heavy" => "Heavy".freeze,
126
- "Book" => "Book".freeze,
127
- "Roman" => "Roman".freeze,
128
- "Normal" => "Normal".freeze,
129
- "Oblique" => "Oblique".freeze,
130
- "Light Italic" => "Light Italic".freeze,
131
- "Medium Italic" => "Medium Italic".freeze,
132
- "Semibold Italic" => "Semibold Italic".freeze,
133
- "Bold Oblique" => "Bold Oblique".freeze,
120
+ "Regular" => "Regular",
121
+ "Bold" => "Bold",
122
+ "Italic" => "Italic",
123
+ "Bold Italic" => "Bold Italic",
124
+ "BoldItalic" => "BoldItalic",
125
+ "Light" => "Light",
126
+ "Medium" => "Medium",
127
+ "Semibold" => "Semibold",
128
+ "SemiBold" => "SemiBold",
129
+ "Black" => "Black",
130
+ "Thin" => "Thin",
131
+ "ExtraLight" => "ExtraLight",
132
+ "Extra Light" => "Extra Light",
133
+ "ExtraBold" => "ExtraBold",
134
+ "Extra Bold" => "Extra Bold",
135
+ "Heavy" => "Heavy",
136
+ "Book" => "Book",
137
+ "Roman" => "Roman",
138
+ "Normal" => "Normal",
139
+ "Oblique" => "Oblique",
140
+ "Light Italic" => "Light Italic",
141
+ "Medium Italic" => "Medium Italic",
142
+ "Semibold Italic" => "Semibold Italic",
143
+ "Bold Oblique" => "Bold Oblique",
134
144
  }.freeze
135
145
 
136
146
  # Intern a string using the string pool
@@ -73,20 +73,51 @@ module Fontisan
73
73
  # @param font [TrueTypeFont, OpenTypeFont] Source font
74
74
  # @param target_format [Symbol] Target format (:ttf, :otf, :woff2, :svg)
75
75
  # @param options [Hash] Additional conversion options
76
+ # @option options [Boolean] :preserve_variation Preserve variation data
77
+ # (default: true)
78
+ # @option options [Boolean] :preserve_hints Preserve rendering hints
79
+ # (default: false)
80
+ # @option options [Hash] :instance_coordinates Coordinates for variable→SVG
81
+ # @option options [Integer] :instance_index Named instance index for variable→SVG
76
82
  # @return [Hash<String, String>] Map of table tags to binary data
77
83
  # @raise [ArgumentError] If parameters are invalid
78
84
  # @raise [Error] If conversion is not supported
79
85
  #
80
86
  # @example
81
87
  # tables = converter.convert(font, :otf)
88
+ #
89
+ # @example Variable font to SVG at specific weight
90
+ # result = converter.convert(variable_font, :svg, instance_coordinates: { "wght" => 700.0 })
91
+ #
92
+ # @example Convert with hint preservation
93
+ # tables = converter.convert(font, :otf, preserve_hints: true)
82
94
  def convert(font, target_format, options = {})
83
95
  validate_parameters!(font, target_format)
84
96
 
85
97
  source_format = detect_format(font)
86
98
  validate_conversion_supported!(source_format, target_format)
87
99
 
100
+ # Special case: Variable font to SVG
101
+ if variable_font?(font) && target_format == :svg
102
+ return convert_variable_to_svg(font, options)
103
+ end
104
+
88
105
  strategy = select_strategy(source_format, target_format)
89
- strategy.convert(font, options.merge(target_format: target_format))
106
+ tables = strategy.convert(font,
107
+ options.merge(target_format: target_format))
108
+
109
+ # Preserve variation data if requested and font is variable
110
+ if options.fetch(:preserve_variation, true) && variable_font?(font)
111
+ tables = preserve_variation_data(
112
+ font,
113
+ tables,
114
+ source_format,
115
+ target_format,
116
+ options,
117
+ )
118
+ end
119
+
120
+ tables
90
121
  end
91
122
 
92
123
  # Check if a conversion is supported
@@ -137,6 +168,128 @@ module Fontisan
137
168
 
138
169
  private
139
170
 
171
+ # Convert variable font to SVG at specific coordinates
172
+ #
173
+ # @param font [TrueTypeFont, OpenTypeFont] Variable font
174
+ # @param options [Hash] Conversion options
175
+ # @option options [Hash] :instance_coordinates Design space coordinates
176
+ # @option options [Integer] :instance_index Named instance index
177
+ # @return [Hash] Hash with :svg_xml key
178
+ def convert_variable_to_svg(font, options = {})
179
+ require_relative "../variation/variable_svg_generator"
180
+
181
+ coordinates = options[:instance_coordinates] || {}
182
+ generator = Variation::VariableSvgGenerator.new(font, coordinates)
183
+
184
+ # Use named instance if specified
185
+ if options[:instance_index]
186
+ generator.generate_named_instance(options[:instance_index], options)
187
+ else
188
+ generator.generate(options)
189
+ end
190
+ end
191
+
192
+ # Check if font is a variable font
193
+ #
194
+ # @param font [TrueTypeFont, OpenTypeFont] Font to check
195
+ # @return [Boolean] True if font has fvar table
196
+ def variable_font?(font)
197
+ font.has_table?("fvar")
198
+ end
199
+
200
+ # Preserve variation data from source to target
201
+ #
202
+ # @param font [TrueTypeFont, OpenTypeFont] Source font
203
+ # @param tables [Hash<String, String>] Target tables
204
+ # @param source_format [Symbol] Source format
205
+ # @param target_format [Symbol] Target format
206
+ # @param options [Hash] Preservation options
207
+ # @return [Hash<String, String>] Tables with variation preserved
208
+ def preserve_variation_data(font, tables, source_format, target_format,
209
+ options)
210
+ # Case 1: Compatible formats (same outline format) - just copy tables
211
+ if compatible_variation_formats?(source_format, target_format)
212
+ require_relative "../variation/variation_preserver"
213
+ Variation::VariationPreserver.preserve(font, tables, options)
214
+
215
+ # Case 2: Different outline formats - convert variation data
216
+ elsif convertible_variation_formats?(source_format, target_format)
217
+ convert_variation_data(font, tables, source_format, target_format,
218
+ options)
219
+
220
+ # Case 3: Unsupported conversion
221
+ else
222
+ if options[:preserve_variation]
223
+ raise Fontisan::Error,
224
+ "Cannot preserve variation data for " \
225
+ "#{source_format} → #{target_format}"
226
+ end
227
+ tables
228
+ end
229
+ end
230
+
231
+ # Check if formats have compatible variation (same outline format)
232
+ #
233
+ # @param source [Symbol] Source format
234
+ # @param target [Symbol] Target format
235
+ # @return [Boolean] True if compatible
236
+ def compatible_variation_formats?(source, target)
237
+ # Same format (copy operation)
238
+ return true if source == target
239
+
240
+ # Same outline format (just packaging change)
241
+ (source == :ttf && target == :woff) ||
242
+ (source == :otf && target == :woff) ||
243
+ (source == :woff && target == :ttf) ||
244
+ (source == :woff && target == :otf) ||
245
+ (source == :ttf && target == :woff2) ||
246
+ (source == :otf && target == :woff2)
247
+ end
248
+
249
+ # Check if formats allow variation conversion (different outline formats)
250
+ #
251
+ # @param source [Symbol] Source format
252
+ # @param target [Symbol] Target format
253
+ # @return [Boolean] True if convertible
254
+ def convertible_variation_formats?(source, target)
255
+ # Different outline formats (need variation conversion)
256
+ (source == :ttf && target == :otf) ||
257
+ (source == :otf && target == :ttf)
258
+ end
259
+
260
+ # Convert variation data between outline formats
261
+ #
262
+ # This is a placeholder for full TTF↔OTF variation conversion.
263
+ # Full implementation would:
264
+ # 1. Use Variation::Converter to convert gvar ↔ CFF2 blend
265
+ # 2. Build appropriate variation tables for target format
266
+ # 3. Preserve common tables (fvar, avar, STAT, metrics)
267
+ #
268
+ # @param font [TrueTypeFont, OpenTypeFont] Source font
269
+ # @param tables [Hash<String, String>] Target tables
270
+ # @param source_format [Symbol] Source format
271
+ # @param target_format [Symbol] Target format
272
+ # @param options [Hash] Conversion options
273
+ # @return [Hash<String, String>] Tables with converted variation
274
+ def convert_variation_data(font, tables, source_format, target_format,
275
+ _options)
276
+ require_relative "../variation/variation_preserver"
277
+ require_relative "../variation/converter"
278
+
279
+ # For now, just preserve common tables and warn about conversion
280
+ warn "WARNING: Full variation conversion (#{source_format} → " \
281
+ "#{target_format}) not yet implemented. " \
282
+ "Preserving common variation tables only."
283
+
284
+ # Preserve common tables (fvar, avar, STAT) but not format-specific
285
+ Variation::VariationPreserver.preserve(
286
+ font,
287
+ tables,
288
+ preserve_format_specific: false,
289
+ preserve_metrics: true,
290
+ )
291
+ end
292
+
140
293
  # Load conversion matrix from YAML config
141
294
  #
142
295
  # @param path [String, nil] Path to config file