Class: Fontisan::Converters::FormatConverter

Inherits:
Object
  • Object
show all
Defined in:
lib/fontisan/converters/format_converter.rb

Overview

Main orchestrator for font format conversions

FormatConverter is the primary entry point for all format conversion operations. It:

  • Selects appropriate conversion strategy based on source/target formats
  • Validates conversions against the conversion matrix
  • Validates user-supplied options against the selected strategy's schema
  • Delegates actual conversion to strategy implementations
  • Provides clean error messages for unsupported conversions

The converter uses a strategy pattern with pluggable strategies for different conversion types. Each strategy declares its own options via the ConversionStrategy.option DSL; the converter enforces the format ↔ option mapping at runtime.

Examples:

Converting TTF to OTF

converter = Fontisan::Converters::FormatConverter.new
tables = converter.convert(font, :otf)
FontWriter.write_to_file(tables, 'output.otf',
                         sfnt_version: 0x4F54544F)

Same-format copy

converter = Fontisan::Converters::FormatConverter.new
tables = converter.convert(font, :ttf)  # TTF to TTF
FontWriter.write_to_file(tables, 'copy.ttf')

Constant Summary collapse

STRATEGY_CLASSES =

Registry of all strategy classes. Single source of truth for option discovery and strategy lookup. Add a new format by appending its strategy class here.

[
  TableCopier,
  OutlineConverter,
  Type1Converter,
  WoffWriter,
  Woff2Encoder,
  SvgGenerator,
].freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(conversion_matrix_path: nil) ⇒ FormatConverter

Initialize converter with strategies

Parameters:

  • conversion_matrix_path (String, nil) (defaults to: nil)

    Path to conversion matrix config. If nil, uses default.



118
119
120
121
# File 'lib/fontisan/converters/format_converter.rb', line 118

def initialize(conversion_matrix_path: nil)
  @strategies = self.class::STRATEGY_CLASSES.map(&:new)
  load_conversion_matrix(conversion_matrix_path)
end

Instance Attribute Details

#conversion_matrixHash (readonly)

Returns Conversion matrix loaded from config.

Returns:

  • (Hash)

    Conversion matrix loaded from config



109
110
111
# File 'lib/fontisan/converters/format_converter.rb', line 109

def conversion_matrix
  @conversion_matrix
end

#strategiesArray (readonly)

Returns Available conversion strategies.

Returns:

  • (Array)

    Available conversion strategies



112
113
114
# File 'lib/fontisan/converters/format_converter.rb', line 112

def strategies
  @strategies
end

Class Method Details

.all_strategy_option_namesArray<Symbol>

All option names declared by any strategy. Used by ConversionOptions to keep its generating-options list in sync without duplicating the schema.

Returns:

  • (Array<Symbol>)


51
52
53
# File 'lib/fontisan/converters/format_converter.rb', line 51

def all_strategy_option_names
  STRATEGY_CLASSES.flat_map { |k| k.supported_options.map(&:name) }.uniq
end

.strategies_for_target(target_format) ⇒ Array[Class]

Strategies whose supported_conversions include the given target.

Parameters:

  • target_format (Symbol)

Returns:

  • (Array[Class])


72
73
74
75
76
77
78
# File 'lib/fontisan/converters/format_converter.rb', line 72

def strategies_for_target(target_format)
  STRATEGY_CLASSES.select do |klass|
    klass.new.supported_conversions.any? { |(_, t)| t == target_format }
  rescue NotImplementedError
    false
  end
end

.strategy_class_for(source_format, target_format) ⇒ Class?

Look up the strategy class that handles a given conversion.

Parameters:

  • source_format (Symbol)
  • target_format (Symbol)

Returns:

  • (Class, nil)


60
61
62
63
64
65
66
# File 'lib/fontisan/converters/format_converter.rb', line 60

def strategy_class_for(source_format, target_format)
  STRATEGY_CLASSES.find do |klass|
    klass.new.supports?(source_format, target_format)
  rescue NotImplementedError
    false
  end
end

.validate_options_for_target!(target_format, options) ⇒ void

This method returns an undefined value.

Cross-format option check, independent of source format. Used by OutputWriter (which doesn't know the source format at write time) to catch e.g. --brotli-quality passed with --to woff.

Parameters:

  • target_format (Symbol)
  • options (Hash)

Raises:

  • (ArgumentError)

    if a user-supplied option is declared only by strategies that don't handle this target



89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
# File 'lib/fontisan/converters/format_converter.rb', line 89

def validate_options_for_target!(target_format, options)
  handlers = strategies_for_target(target_format)
  handler_names = handlers.flat_map do |k|
    k.supported_options.map(&:name)
  end.to_set
  non_handler_names = (STRATEGY_CLASSES - handlers)
    .flat_map { |k| k.supported_options.map(&:name) }

  conflicts = options.keys.select do |k|
    non_handler_names.include?(k.to_sym)
  end
  return if conflicts.empty?

  accepted = handler_names.empty? ? "(none)" : handler_names.join(", ")
  raise ArgumentError,
        "Option(s) #{conflicts.map(&:inspect).join(', ')} do not apply " \
        "to --to #{target_format}. Accepted for #{target_format}: #{accepted}"
end

Instance Method Details

#all_conversionsArray<Hash>

Get all supported conversions

Returns:

  • (Array<Hash>)

    Array of conversion hashes with :from and :to



228
229
230
231
232
233
234
235
236
237
# File 'lib/fontisan/converters/format_converter.rb', line 228

def all_conversions
  return [] unless conversion_matrix

  conversions = conversion_matrix["conversions"]
  return [] unless conversions

  conversions.map do |conv|
    { from: conv["from"].to_sym, to: conv["to"].to_sym }
  end
end

#compatible_variation_formats?(source, target) ⇒ Boolean

Check if formats have compatible variation (same outline format)

Parameters:

  • source (Symbol)

    Source format

  • target (Symbol)

    Target format

Returns:

  • (Boolean)

    True if compatible



306
307
308
309
310
311
312
313
314
315
316
317
# File 'lib/fontisan/converters/format_converter.rb', line 306

def compatible_variation_formats?(source, target)
  # Same format (copy operation)
  return true if source == target

  # Same outline format (just packaging change)
  (source == :ttf && target == :woff) ||
    (source == :otf && target == :woff) ||
    (source == :woff && target == :ttf) ||
    (source == :woff && target == :otf) ||
    (source == :ttf && target == :woff2) ||
    (source == :otf && target == :woff2)
end

#convert(font, target_format, options = {}) ⇒ Hash<String, String>

Convert font to target format

This is the main entry point for format conversion. It:

  1. Detects source format from font
  2. Validates conversion is supported
  3. Selects appropriate strategy
  4. Delegates conversion to strategy

Examples:

tables = converter.convert(font, :otf)

Variable font to SVG at specific weight

result = converter.convert(variable_font, :svg, instance_coordinates: { "wght" => 700.0 })

Convert with hint preservation

tables = converter.convert(font, :otf, preserve_hints: true)

Parameters:

  • font (TrueTypeFont, OpenTypeFont)

    Source font

  • target_format (Symbol)

    Target format (:ttf, :otf, :woff2, :svg)

  • options (Hash) (defaults to: {})

    Additional conversion options

Options Hash (options):

  • :preserve_variation (Boolean)

    Preserve variation data (default: true)

  • :preserve_hints (Boolean)

    Preserve rendering hints (default: false)

  • :instance_coordinates (Hash)

    Coordinates for variable→SVG

  • :instance_index (Integer)

    Named instance index for variable→SVG

Returns:

  • (Hash<String, String>)

    Map of table tags to binary data

Raises:

  • (ArgumentError)

    If parameters are invalid

  • (Error)

    If conversion is not supported



152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
# File 'lib/fontisan/converters/format_converter.rb', line 152

def convert(font, target_format, options = {})
  validate_parameters!(font, target_format)

  source_format = detect_format(font)
  validate_conversion_supported!(source_format, target_format)

  # Special case: Variable font to SVG
  if variable_font?(font) && target_format == :svg
    return convert_variable_to_svg(font, options)
  end

  strategy = select_strategy(source_format, target_format)

  # Enforce format ↔ option mapping at the orchestrator level so each
  # strategy only sees options it declared. Cross-format misuse
  # (e.g., `--zlib-level` on a WOFF2 conversion) raises here with a
  # clear message; the strategy itself never has to defensively
  # ignore unknown keys.
  validate_strategy_options!(strategy, source_format, target_format,
                             options)
  sliced = slice_strategy_options(options, strategy)

  tables = strategy.convert(
    font,
    sliced.merge(target_format: target_format),
  )

  # Preserve variation data if requested and font is variable
  if options.fetch(:preserve_variation, true) && variable_font?(font)
    tables = preserve_variation_data(
      font,
      tables,
      source_format,
      target_format,
      options,
    )
  end

  tables
end

#convert_variable_to_svg(font, options = {}) ⇒ Hash

Convert variable font to SVG at specific coordinates

Parameters:

Options Hash (options):

  • :instance_coordinates (Hash)

    Design space coordinates

  • :instance_index (Integer)

    Named instance index

Returns:

  • (Hash)

    Hash with :svg_xml key



248
249
250
251
252
253
254
255
256
257
258
# File 'lib/fontisan/converters/format_converter.rb', line 248

def convert_variable_to_svg(font, options = {})
  coordinates = options[:instance_coordinates] || {}
  generator = Variation::VariableSvgGenerator.new(font, coordinates)

  # Use named instance if specified
  if options[:instance_index]
    generator.generate_named_instance(options[:instance_index], options)
  else
    generator.generate(options)
  end
end

#convert_variation_data(font, tables, source_format, target_format, _options) ⇒ Hash<String, String>

Convert variation data between outline formats

This is a placeholder for full TTF↔OTF variation conversion. Full implementation would:

  1. Use Variation::Converter to convert gvar ↔ CFF2 blend
  2. Build appropriate variation tables for target format
  3. Preserve common tables (fvar, avar, STAT, metrics)

Parameters:

  • font (TrueTypeFont, OpenTypeFont)

    Source font

  • tables (Hash<String, String>)

    Target tables

  • source_format (Symbol)

    Source format

  • target_format (Symbol)

    Target format

  • options (Hash)

    Conversion options

Returns:

  • (Hash<String, String>)

    Tables with converted variation



344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
# File 'lib/fontisan/converters/format_converter.rb', line 344

def convert_variation_data(font, tables, source_format, target_format,
_options)
  # For now, just preserve common tables and warn about conversion
  warn "WARNING: Full variation conversion (#{source_format} → " \
       "#{target_format}) not yet implemented. " \
       "Preserving common variation tables only."

  # Preserve common tables (fvar, avar, STAT) but not format-specific
  Variation::VariationPreserver.preserve(
    font,
    tables,
    preserve_format_specific: false,
    preserve_metrics: true,
  )
end

#convertible_variation_formats?(source, target) ⇒ Boolean

Check if formats allow variation conversion (different outline formats)

Parameters:

  • source (Symbol)

    Source format

  • target (Symbol)

    Target format

Returns:

  • (Boolean)

    True if convertible



324
325
326
327
328
# File 'lib/fontisan/converters/format_converter.rb', line 324

def convertible_variation_formats?(source, target)
  # Different outline formats (need variation conversion)
  (source == :ttf && target == :otf) ||
    (source == :otf && target == :ttf)
end

#default_conversion_matrixHash

Get default conversion matrix (fallback)

Returns:

  • (Hash)

    Default conversion matrix



392
393
394
395
396
397
398
399
400
401
# File 'lib/fontisan/converters/format_converter.rb', line 392

def default_conversion_matrix
  {
    "conversions" => [
      { "from" => "ttf", "to" => "ttf" },
      { "from" => "otf", "to" => "otf" },
      { "from" => "ttf", "to" => "otf" },
      { "from" => "otf", "to" => "ttf" },
    ],
  }
end

#default_conversion_matrix_pathString

Get default conversion matrix path

Returns:

  • (String)

    Path to conversion matrix config



380
381
382
383
384
385
386
387
# File 'lib/fontisan/converters/format_converter.rb', line 380

def default_conversion_matrix_path
  File.join(
    __dir__,
    "..",
    "config",
    "conversion_matrix.yml",
  )
end

#detect_format(font) ⇒ Symbol

Detect font format from tables

Parameters:

Returns:

  • (Symbol)

    Format (:ttf, :otf, or :type1)

Raises:

  • (Error)

    If format cannot be detected



470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
# File 'lib/fontisan/converters/format_converter.rb', line 470

def detect_format(font)
  # Check for Type1Font first (uses different interface)
  return :type1 if font.is_a?(Type1Font)

  # Check for CFF/CFF2 tables (OpenType/CFF)
  if font.has_table?("CFF ") || font.has_table?("CFF2")
    :otf
  # Check for glyf table (TrueType)
  elsif font.has_table?("glyf")
    :ttf
  else
    raise Fontisan::Error,
          "Cannot detect font format: missing both CFF and glyf tables"
  end
end

#load_conversion_matrix(path) ⇒ Object

Load conversion matrix from YAML config

Parameters:

  • path (String, nil)

    Path to config file



363
364
365
366
367
368
369
370
371
372
373
374
375
# File 'lib/fontisan/converters/format_converter.rb', line 363

def load_conversion_matrix(path)
  config_path = path || default_conversion_matrix_path

  @conversion_matrix = if File.exist?(config_path)
                         YAML.load_file(config_path)
                       else
                         # Use default inline matrix if file doesn't exist
                         default_conversion_matrix
                       end
rescue StandardError => e
  warn "Failed to load conversion matrix: #{e.message}"
  @conversion_matrix = default_conversion_matrix
end

#preserve_variation_data(font, tables, source_format, target_format, options) ⇒ Hash<String, String>

Preserve variation data from source to target

Parameters:

  • font (TrueTypeFont, OpenTypeFont)

    Source font

  • tables (Hash<String, String>)

    Target tables

  • source_format (Symbol)

    Source format

  • target_format (Symbol)

    Target format

  • options (Hash)

    Preservation options

Returns:

  • (Hash<String, String>)

    Tables with variation preserved



279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
# File 'lib/fontisan/converters/format_converter.rb', line 279

def preserve_variation_data(font, tables, source_format, target_format,
options)
  # Case 1: Compatible formats (same outline format) - just copy tables
  if compatible_variation_formats?(source_format, target_format)
    Variation::VariationPreserver.preserve(font, tables, options)

  # Case 2: Different outline formats - convert variation data
  elsif convertible_variation_formats?(source_format, target_format)
    convert_variation_data(font, tables, source_format, target_format,
                           options)

  # Case 3: Unsupported conversion
  else
    if options[:preserve_variation]
      raise Fontisan::Error,
            "Cannot preserve variation data for " \
            "#{source_format} → #{target_format}"
    end
    tables
  end
end

#select_strategy(source_format, target_format) ⇒ ConversionStrategy

Select conversion strategy

Parameters:

  • source_format (Symbol)

    Source format

  • target_format (Symbol)

    Target format

Returns:

Raises:

  • (Error)

    If no strategy supports the conversion



452
453
454
455
456
457
458
459
460
461
462
463
# File 'lib/fontisan/converters/format_converter.rb', line 452

def select_strategy(source_format, target_format)
  strategy = strategies.find do |s|
    s.supports?(source_format, target_format)
  end

  unless strategy
    raise Fontisan::Error,
          "No strategy available for #{source_format} → #{target_format}"
  end

  strategy
end

#slice_strategy_options(options, strategy) ⇒ Hash

Slice an options hash to only the keys declared by the given strategy.

Parameters:

  • options (Hash)
  • strategy (Object)

Returns:

  • (Hash)


530
531
532
533
# File 'lib/fontisan/converters/format_converter.rb', line 530

def slice_strategy_options(options, strategy)
  names = strategy.class.supported_options.to_set(&:name)
  options.select { |k, _| names.include?(k.to_sym) }
end

#supported?(source_format, target_format) ⇒ Boolean

Check if a conversion is supported

Parameters:

  • source_format (Symbol)

    Source format

  • target_format (Symbol)

    Target format

Returns:

  • (Boolean)

    True if conversion is supported



198
199
200
201
202
203
204
205
206
207
208
# File 'lib/fontisan/converters/format_converter.rb', line 198

def supported?(source_format, target_format)
  return false unless conversion_matrix

  conversions = conversion_matrix["conversions"]
  return false unless conversions

  conversions.any? do |conv|
    conv["from"] == source_format.to_s &&
      conv["to"] == target_format.to_s
  end
end

#supported_targets(source_format) ⇒ Array<Symbol>

Get list of supported target formats for a source format

Parameters:

  • source_format (Symbol)

    Source format

Returns:

  • (Array<Symbol>)

    Supported target formats



214
215
216
217
218
219
220
221
222
223
# File 'lib/fontisan/converters/format_converter.rb', line 214

def supported_targets(source_format)
  return [] unless conversion_matrix

  conversions = conversion_matrix["conversions"]
  return [] unless conversions

  conversions
    .select { |conv| conv["from"] == source_format.to_s }
    .map { |conv| conv["to"].to_sym }
end

#validate_conversion_supported!(source_format, target_format) ⇒ Object

Validate conversion is supported

Parameters:

  • source_format (Symbol)

    Source format

  • target_format (Symbol)

    Target format

Raises:

  • (Error)

    If conversion is not supported



431
432
433
434
435
436
437
438
439
440
441
442
443
444
# File 'lib/fontisan/converters/format_converter.rb', line 431

def validate_conversion_supported!(source_format, target_format)
  unless supported?(source_format, target_format)
    available = supported_targets(source_format)
    message = "Conversion from #{source_format} to #{target_format} " \
              "is not supported."
    message += if available.any?
                 " Available targets for #{source_format}: " \
                            "#{available.join(', ')}"
               else
                 " No conversions available from #{source_format}."
               end
    raise Fontisan::Error, message
  end
end

#validate_parameters!(font, target_format) ⇒ Object

Validate conversion parameters

Parameters:

  • font (Object)

    Font object

  • target_format (Symbol)

    Target format

Raises:

  • (ArgumentError)

    If parameters are invalid



408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
# File 'lib/fontisan/converters/format_converter.rb', line 408

def validate_parameters!(font, target_format)
  raise ArgumentError, "Font cannot be nil" if font.nil?

  # Type1Font uses a different interface (font_dictionary, charstrings, etc.)
  # rather than the SFNT table interface
  is_type1 = font.is_a?(Type1Font)

  unless is_type1 || font.is_a?(SfntSource)
    raise ArgumentError,
          "Font must be an SfntSource instance or a Type1Font"
  end

  unless target_format.is_a?(Symbol)
    raise ArgumentError,
          "target_format must be a Symbol, got: #{target_format.class}"
  end
end

#validate_strategy_options!(strategy, source_format, target_format, options) ⇒ void

This method returns an undefined value.

Cross-format option check. Any user-supplied key that belongs to another strategy (i.e., is valid for some other format) raises immediately — this is what makes --zlib-level --to woff2 fail predictably.

Parameters:

  • strategy (Object)

    Selected strategy instance

  • source_format (Symbol)
  • target_format (Symbol)
  • options (Hash)

Raises:

  • (ArgumentError)

    if a user option is declared by another strategy

  • (ArgumentError)

    if a user option fails type/range validation



498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
# File 'lib/fontisan/converters/format_converter.rb', line 498

def validate_strategy_options!(strategy, source_format, target_format,
                               options)
  this_class = strategy.class
  other_classes = self.class::STRATEGY_CLASSES.reject do |k|
    k == this_class
  end
  other_names = other_classes.flat_map do |k|
    k.supported_options.map(&:name)
  end

  conflicts = options.keys.select do |k|
    other_names.include?(k.to_sym)
  end

  if conflicts.any?
    accepted = this_class.supported_options.map(&:name)
    accepted_list = accepted.empty? ? "(none)" : accepted.join(", ")
    raise ArgumentError,
          "Option(s) #{conflicts.map(&:inspect).join(', ')} do not apply " \
          "to #{source_format}→#{target_format} " \
          "(#{this_class.name.demodulize}). " \
          "Accepted: #{accepted_list}"
  end

  this_class.validate_options!(slice_strategy_options(options, strategy))
end

#variable_font?(font) ⇒ Boolean

Check if font is a variable font

Parameters:

Returns:

  • (Boolean)

    True if font has fvar table



264
265
266
267
268
269
# File 'lib/fontisan/converters/format_converter.rb', line 264

def variable_font?(font)
  # Type 1 fonts are never variable fonts
  return false if font.is_a?(Type1Font)

  font.has_table?("fvar")
end