Class: Fontisan::Converters::OutlineConverter

Inherits:
Object
  • Object
show all
Includes:
CffTableBuilder, ConversionStrategy, GlyfTableBuilder, OutlineExtraction, OutlineOptimizer
Defined in:
lib/fontisan/converters/outline_converter.rb

Overview

Strategy for converting between TTF and OTF outline formats

[‘OutlineConverter`](lib/fontisan/converters/outline_converter.rb) handles conversion between TrueType (glyf/loca) and CFF outline formats. This involves:

  • Extracting glyph outlines from source format

  • Converting to universal [‘Outline`](lib/fontisan/models/outline.rb) model

  • Building target format tables using specialized builders

  • Updating related tables (maxp, head)

  • Preserving all other font tables

  • Optionally preserving rendering hints

**Conversion Details:**

TTF → OTF:

  • Extract glyphs from glyf/loca tables

  • Convert TrueType quadratic curves to universal format

  • Build complete CFF table with CharStrings INDEX

  • Remove glyf/loca tables

  • Update maxp to version 0.5 (CFF format)

  • Update head table (clear indexToLocFormat)

OTF → TTF:

  • Extract CharStrings from CFF table

  • Convert CFF cubic curves to universal format

  • Build glyf and loca tables

  • Remove CFF table

  • Update maxp to version 1.0 (TrueType format)

  • Update head table (set indexToLocFormat)

Examples:

Converting TTF to OTF

converter = Fontisan::Converters::OutlineConverter.new
otf_font = converter.convert(ttf_font, target_format: :otf)

Converting OTF to TTF

converter = Fontisan::Converters::OutlineConverter.new
ttf_font = converter.convert(otf_font, target_format: :ttf)

Converting with hint preservation

converter = Fontisan::Converters::OutlineConverter.new
otf_font = converter.convert(ttf_font, target_format: :otf, preserve_hints: true)

Constant Summary collapse

SUPPORTED_FORMATS =

Supported outline formats

%i[ttf otf cff2].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Methods included from OutlineOptimizer

#optimize_charstrings

Methods included from GlyfTableBuilder

#build_glyf_loca_tables

Methods included from CffTableBuilder

#build_cff_table

Methods included from OutlineExtraction

#extract_cff_outlines, #extract_ttf_outlines

Methods included from ConversionStrategy

#supports?

Constructor Details

#initializeOutlineConverter

Initialize converter



78
79
80
# File 'lib/fontisan/converters/outline_converter.rb', line 78

def initialize
  @font = nil
end

Instance Attribute Details

#fontTrueTypeFont, OpenTypeFont (readonly)

Returns Source font.

Returns:



75
76
77
# File 'lib/fontisan/converters/outline_converter.rb', line 75

def font
  @font
end

Instance Method Details

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

Convert font between TTF and OTF formats

Parameters:

Options Hash (options):

  • :target_format (Symbol)

    Target format (:ttf or :otf)

  • :optimize_cff (Boolean)

    Enable CFF subroutine optimization (default: false)

  • :preserve_hints (Boolean)

    Preserve rendering hints (default: false)

  • :preserve_variations (Boolean)

    Keep variation data during conversion (default: true)

  • :generate_instance (Boolean)

    Generate static instance instead of variable font (default: false)

  • :instance_coordinates (Hash)

    Axis coordinates for instance generation (default: {})

Returns:

  • (Hash<String, String>)

    Map of table tags to binary data



93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
# File 'lib/fontisan/converters/outline_converter.rb', line 93

def convert(font, options = {})
  @font = font
  @options = options
  @optimize_cff = options.fetch(:optimize_cff, false)
  @preserve_hints = options.fetch(:preserve_hints, false)
  @preserve_variations = options.fetch(:preserve_variations, true)
  @generate_instance = options.fetch(:generate_instance, false)
  @instance_coordinates = options.fetch(:instance_coordinates, {})
  target_format = options[:target_format] ||
    detect_target_format(font)
  validate(font, target_format)

  source_format = detect_format(font)

  # Check if we should generate a static instance instead
  if @generate_instance && variable_font?(font)
    return generate_static_instance(font, source_format, target_format)
  end

  case [source_format, target_format]
  when %i[ttf otf]
    convert_ttf_to_otf(font, options)
  when %i[otf ttf]
    convert_otf_to_ttf(font)
  when %i[cff2 ttf]
    # CFF2 to TTF - treat CFF2 similar to OTF for now
    convert_otf_to_ttf(font)
  when %i[ttf cff2]
    # TTF to CFF2 - for variable fonts
    convert_ttf_to_otf(font, options)
  else
    raise Fontisan::Error,
          "Unsupported conversion: #{source_format}#{target_format}"
  end
end

#convert_otf_to_ttf(font) ⇒ Hash<String, String>

Convert OpenType/CFF font to TrueType

Parameters:

Returns:

  • (Hash<String, String>)

    Target tables



201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
# File 'lib/fontisan/converters/outline_converter.rb', line 201

def convert_otf_to_ttf(font)
  # Extract all glyphs from CFF table
  outlines = extract_cff_outlines(font)

  # Extract hints if preservation is enabled
  hints_per_glyph = @preserve_hints ? extract_cff_hints(font) : {}

  # Build glyf and loca tables
  glyf_data, loca_data, loca_format = build_glyf_loca_tables(outlines)

  # Copy all tables except CFF
  tables = copy_tables(font, ["CFF ", "CFF2"])

  # Add glyf and loca tables
  tables["glyf"] = glyf_data
  tables["loca"] = loca_data

  # Update maxp table for TrueType
  tables["maxp"] = update_maxp_for_truetype(font, outlines, loca_format)

  # Update head table for TrueType
  tables["head"] = update_head_for_truetype(font, loca_format)

  # Convert and apply hints if preservation is enabled
  if @preserve_hints && hints_per_glyph.any?
    # Extract font-level hints separately
    hint_set = extract_cff_hint_set(font)

    unless hint_set.empty?
      # Convert PostScript hints to TrueType format
      converter = Hints::HintConverter.new
      tt_hint_set = converter.convert_hint_set(hint_set, :truetype)

      # Apply TrueType hints (writes fpgm/prep/cvt tables)
      applier = Hints::TrueTypeHintApplier.new
      tables = applier.apply(tt_hint_set, tables)
    end
  end

  tables
end

#convert_ttf_to_otf(font, _options = {}) ⇒ Hash<String, String>

Convert TrueType font to OpenType/CFF

Parameters:

  • font (TrueTypeFont)

    Source font

  • options (Hash)

    Conversion options (currently unused)

Returns:

  • (Hash<String, String>)

    Target tables



134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
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
192
193
194
195
# File 'lib/fontisan/converters/outline_converter.rb', line 134

def convert_ttf_to_otf(font, _options = {})
  # Extract all glyphs from glyf table
  outlines = extract_ttf_outlines(font)

  # Extract hints if preservation is enabled
  hints_per_glyph = @preserve_hints ? extract_ttf_hints(font) : {}

  # Build CharStrings from outlines
  charstrings = outlines.map do |outline|
    builder = Tables::Cff::CharStringBuilder.new
    if outline.empty?
      builder.build_empty
    else
      builder.build(outline)
    end
  end

  # Apply subroutine optimization if enabled
  local_subrs = []
  if @optimize_cff
    begin
      charstrings, local_subrs = optimize_charstrings(charstrings)
    rescue StandardError => e
      # If optimization fails, fall back to unoptimized CharStrings
      warn "CFF optimization failed: #{e.message}, using unoptimized CharStrings"
      local_subrs = []
    end
  end

  # Build CFF table from charstrings and local subrs
  cff_data = build_cff_table(charstrings, local_subrs, font)

  # Copy all tables except glyf/loca
  tables = copy_tables(font, %w[glyf loca])

  # Add CFF table
  tables["CFF "] = cff_data

  # Update maxp table for CFF
  tables["maxp"] = update_maxp_for_cff(font, outlines.length)

  # Update head table for CFF
  tables["head"] = update_head_for_cff(font)

  # Convert and apply hints if preservation is enabled
  if @preserve_hints && hints_per_glyph.any?
    # Extract font-level hints separately
    hint_set = extract_ttf_hint_set(font)

    unless hint_set.empty?
      # Convert TrueType hints to PostScript format
      converter = Hints::HintConverter.new
      ps_hint_set = converter.convert_hint_set(hint_set, :postscript)

      # Apply PostScript hints (validation mode - CFF modification pending)
      applier = Hints::PostScriptHintApplier.new
      tables = applier.apply(ps_hint_set, tables)
    end
  end

  tables
end

#convert_variations(font, target_format) ⇒ Hash?

Convert variation data during outline conversion

Parameters:

Returns:

  • (Hash, nil)

    Converted variation data or nil



449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
# File 'lib/fontisan/converters/outline_converter.rb', line 449

def convert_variations(font, target_format)
  return nil unless @preserve_variations
  return nil unless variable_font?(font)

  fvar = font.table("fvar")
  return nil unless fvar

  axes = fvar.axes
  converter = Variation::Converter.new(font, axes)

  # Get glyph count
  maxp = font.table("maxp")
  return nil unless maxp

  glyph_count = maxp.num_glyphs

  # Convert variation data for each glyph
  variation_data = {}
  glyph_count.times do |glyph_id|
    source_format = detect_format(font)

    data = case [source_format, target_format]
           when %i[ttf otf], %i[ttf cff2]
             # gvar → blend
             converter.gvar_to_blend(glyph_id)
           when %i[otf ttf], %i[cff2 ttf]
             # blend → gvar
             converter.blend_to_gvar(glyph_id)
           end

    variation_data[glyph_id] = data if data
  end

  variation_data.empty? ? nil : variation_data
end

#copy_tables(font, exclude_tags = []) ⇒ Hash<String, String>

Copy non-outline tables from source to target

Parameters:

Returns:

  • (Hash<String, String>)

    Copied tables



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

def copy_tables(font, exclude_tags = [])
  tables = {}

  font.table_data.each do |tag, data|
    next if exclude_tags.include?(tag)

    tables[tag] = data if data
  end

  tables
end

#detect_format(font) ⇒ Symbol

Detect font format from tables

Parameters:

Returns:

  • (Symbol)

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

Raises:

  • (Error)

    If format cannot be detected



490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
# File 'lib/fontisan/converters/outline_converter.rb', line 490

def detect_format(font)
  # Check for CFF2 table first (OpenType variable fonts with CFF2 outlines)
  if font.has_table?("CFF2")
    :cff2
  # Check for CFF table (OpenType/CFF)
  elsif font.has_table?("CFF ")
    :otf
  # Check for glyf table (TrueType)
  elsif font.has_table?("glyf")
    :ttf
  else
    raise Fontisan::Error,
          "Cannot detect font format: missing outline tables (CFF2, CFF, or glyf)"
  end
end

#detect_target_format(font) ⇒ Symbol

Detect target format as opposite of source

Parameters:

Returns:

  • (Symbol)

    Target format



510
511
512
513
514
515
516
517
518
519
520
# File 'lib/fontisan/converters/outline_converter.rb', line 510

def detect_target_format(font)
  source = detect_format(font)
  case source
  when :ttf
    :otf
  when :cff2
    :ttf
  else
    :ttf
  end
end

#extract_cff_hint_set(font) ⇒ HintSet

Extract complete PostScript hint set from font

Parameters:

Returns:

  • (HintSet)

    Complete hint set



652
653
654
655
656
657
658
# File 'lib/fontisan/converters/outline_converter.rb', line 652

def extract_cff_hint_set(font)
  extractor = Hints::PostScriptHintExtractor.new
  extractor.extract_from_font(font)
rescue StandardError => e
  warn "Failed to extract PostScript hint set: #{e.message}"
  Models::HintSet.new(format: :postscript)
end

#extract_cff_hints(font) ⇒ Hash<Integer, Array<Hint>>

Extract hints from CFF font

Parameters:

Returns:

  • (Hash<Integer, Array<Hint>>)

    Map of glyph ID to hints



610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
# File 'lib/fontisan/converters/outline_converter.rb', line 610

def extract_cff_hints(font)
  hints_per_glyph = {}
  extractor = Hints::PostScriptHintExtractor.new

  # Get CFF table
  cff = font.table("CFF ")
  return {} unless cff

  # Get number of glyphs
  num_glyphs = cff.glyph_count

  # Extract hints from each CharString
  num_glyphs.times do |glyph_id|
    charstring = cff.charstring_for_glyph(glyph_id)
    next if charstring.nil?

    hints = extractor.extract(charstring)
    hints_per_glyph[glyph_id] = hints if hints.any?
  end

  hints_per_glyph
rescue StandardError => e
  warn "Failed to extract CFF hints: #{e.message}"
  {}
end

#extract_ttf_hint_set(font) ⇒ HintSet

Extract complete TrueType hint set from font

Parameters:

Returns:

  • (HintSet)

    Complete hint set



640
641
642
643
644
645
646
# File 'lib/fontisan/converters/outline_converter.rb', line 640

def extract_ttf_hint_set(font)
  extractor = Hints::TrueTypeHintExtractor.new
  extractor.extract_from_font(font)
rescue StandardError => e
  warn "Failed to extract TrueType hint set: #{e.message}"
  Models::HintSet.new(format: :truetype)
end

#extract_ttf_hints(font) ⇒ Hash<Integer, Array<Hint>>

Extract hints from TrueType font

Parameters:

Returns:

  • (Hash<Integer, Array<Hint>>)

    Map of glyph ID to hints



578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
# File 'lib/fontisan/converters/outline_converter.rb', line 578

def extract_ttf_hints(font)
  hints_per_glyph = {}
  extractor = Hints::TrueTypeHintExtractor.new

  # Get required tables
  head = font.table("head")
  maxp = font.table("maxp")
  loca = font.table("loca")
  glyf = font.table("glyf")

  # Parse loca with context
  loca.parse_with_context(head.index_to_loc_format, maxp.num_glyphs)

  # Extract hints from each glyph
  maxp.num_glyphs.times do |glyph_id|
    glyph = glyf.glyph_for(glyph_id, loca, head)
    next if glyph.nil? || glyph.empty?

    hints = extractor.extract(glyph)
    hints_per_glyph[glyph_id] = hints if hints.any?
  end

  hints_per_glyph
rescue StandardError => e
  warn "Failed to extract TrueType hints: #{e.message}"
  {}
end

#generate_static_instance(font, source_format, target_format) ⇒ Hash<String, String>

Generate static instance from variable font

Parameters:

  • font (TrueTypeFont, OpenTypeFont)

    Variable font

  • source_format (Symbol)

    Source format

  • target_format (Symbol)

    Target format

Returns:

  • (Hash<String, String>)

    Static font tables



415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
# File 'lib/fontisan/converters/outline_converter.rb', line 415

def generate_static_instance(font, source_format, target_format)
  # Generate instance at specified coordinates
  fvar = font.table("fvar")
  fvar ? fvar.axes : []

  generator = Variation::InstanceGenerator.new(font,
                                               @instance_coordinates)
  instance_tables = generator.generate

  # If target format differs from source, convert outlines
  if source_format == target_format
    instance_tables
  else
    # Create temporary font with instance tables
    temp_font = font.class.new
    temp_font.instance_variable_set(:@table_data, instance_tables)

    # Convert outline format
    case [source_format, target_format]
    when %i[ttf otf]
      convert_ttf_to_otf(temp_font, @options)
    when %i[otf ttf], %i[cff2 ttf]
      convert_otf_to_ttf(temp_font)
    else
      instance_tables
    end
  end
end

#otf_to_ttfHash<String, String>

Convert OpenType/CFF font to TrueType

Returns:

  • (Hash<String, String>)

    Target tables

Raises:



255
256
257
258
259
# File 'lib/fontisan/converters/outline_converter.rb', line 255

def otf_to_ttf
  raise Fontisan::Error, "No font loaded" unless @font

  convert_otf_to_ttf(@font)
end

#supported_conversionsArray<Array<Symbol>>

Get supported conversions

Returns:

  • (Array<Array<Symbol>>)

    Supported conversion pairs



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

def supported_conversions
  [
    %i[ttf otf],
    %i[otf ttf],
    %i[cff2 ttf],
    %i[ttf cff2],
  ]
end

#ttf_to_otfHash<String, String>

Convert TrueType font to OpenType/CFF

Returns:

  • (Hash<String, String>)

    Target tables

Raises:



246
247
248
249
250
# File 'lib/fontisan/converters/outline_converter.rb', line 246

def ttf_to_otf
  raise Fontisan::Error, "No font loaded" unless @font

  convert_ttf_to_otf(@font)
end

#update_head_for_cff(font) ⇒ String

Update head table for CFF format

Parameters:

Returns:

  • (String)

    Updated head table binary data



382
383
384
385
386
387
388
389
390
391
392
# File 'lib/fontisan/converters/outline_converter.rb', line 382

def update_head_for_cff(font)
  font.table("head")
  head_data = font.table_data["head"].dup

  # For CFF fonts, indexToLocFormat is not relevant
  # but we'll set it to 0 for consistency
  # indexToLocFormat is at offset 50 (2 bytes)
  head_data[50, 2] = [0].pack("n")

  head_data
end

#update_head_for_truetype(font, loca_format) ⇒ String

Update head table for TrueType format

Parameters:

  • font (OpenTypeFont)

    Source font

  • loca_format (Integer)

    Loca format (0=short, 1=long)

Returns:

  • (String)

    Updated head table binary data



399
400
401
402
403
404
405
406
407
# File 'lib/fontisan/converters/outline_converter.rb', line 399

def update_head_for_truetype(font, loca_format)
  font.table("head")
  head_data = font.table_data["head"].dup

  # Set indexToLocFormat at offset 50 (2 bytes)
  head_data[50, 2] = [loca_format].pack("n")

  head_data
end

#update_maxp_for_cff(_font, num_glyphs) ⇒ String

Update maxp table for CFF format

Parameters:

  • font (TrueTypeFont)

    Source font

  • num_glyphs (Integer)

    Number of glyphs

Returns:

  • (String)

    Updated maxp table binary data



325
326
327
328
329
# File 'lib/fontisan/converters/outline_converter.rb', line 325

def update_maxp_for_cff(_font, num_glyphs)
  # CFF uses maxp version 0.5 (0x00005000)
  # Structure: version (4 bytes) + numGlyphs (2 bytes)
  [Tables::Maxp::VERSION_0_5, num_glyphs].pack("Nn")
end

#update_maxp_for_truetype(font, outlines, _loca_format) ⇒ String

Update maxp table for TrueType format

Parameters:

  • font (OpenTypeFont)

    Source font

  • outlines (Array<Outline>)

    Glyph outlines

  • loca_format (Integer)

    Loca format (0 or 1)

Returns:

  • (String)

    Updated maxp table binary data



337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
# File 'lib/fontisan/converters/outline_converter.rb', line 337

def update_maxp_for_truetype(font, outlines, _loca_format)
  # Get source maxp
  font.table("maxp")
  num_glyphs = outlines.length

  # Calculate statistics from outlines
  max_points = 0
  max_contours = 0

  outlines.each do |outline|
    next if outline.empty?

    contours = outline.to_truetype_contours
    max_contours = [max_contours, contours.length].max

    contours.each do |contour|
      max_points = [max_points, contour.length].max
    end
  end

  # Build maxp v1.0 table
  # We'll use conservative defaults for instruction-related fields
  [
    Tables::Maxp::VERSION_1_0, # version
    num_glyphs,                  # numGlyphs
    max_points,                  # maxPoints
    max_contours,                # maxContours
    0,                           # maxCompositePoints
    0,                           # maxCompositeContours
    2,                           # maxZones
    0,                           # maxTwilightPoints
    0,                           # maxStorage
    0,                           # maxFunctionDefs
    0,                           # maxInstructionDefs
    0,                           # maxStackElements
    0,                           # maxSizeOfInstructions
    0,                           # maxComponentElements
    0,                           # maxComponentDepth
  ].pack("Nnnnnnnnnnnnnnn")
end

#validate(font, target_format) ⇒ Boolean

Validate font for conversion

Parameters:

Returns:

  • (Boolean)

    True if valid

Raises:

  • (ArgumentError)

    If font is invalid

  • (Error)

    If conversion is not supported



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

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

  unless font.respond_to?(:tables)
    raise ArgumentError, "Font must respond to :tables"
  end

  unless font.respond_to?(:table)
    raise ArgumentError, "Font must respond to :table"
  end

  source_format = detect_format(font)
  unless supports?(source_format, target_format)
    raise Fontisan::Error,
          "Conversion #{source_format}#{target_format} not supported"
  end

  # Check that source font has required tables
  validate_source_tables(font, source_format)

  true
end

#validate_source_tables(font, format) ⇒ Object

Validate source font has required tables

Parameters:

Raises:

  • (Error)

    If required tables are missing



527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
# File 'lib/fontisan/converters/outline_converter.rb', line 527

def validate_source_tables(font, format)
  case format
  when :ttf
    unless font.has_table?("glyf") && font.has_table?("loca")
      raise Fontisan::MissingTableError,
            "TrueType font missing required glyf or loca table"
    end
    # Also verify tables can actually be loaded
    unless font.table("glyf") && font.table("loca")
      raise Fontisan::MissingTableError,
            "TrueType font missing required glyf or loca table"
    end
  when :cff2
    unless font.has_table?("CFF2")
      raise Fontisan::MissingTableError,
            "CFF2 font missing required CFF2 table"
    end
    unless font.table("CFF2")
      raise Fontisan::MissingTableError,
            "CFF2 font missing required CFF2 table"
    end
  when :otf
    unless font.has_table?("CFF ") || font.has_table?("CFF2")
      raise Fontisan::MissingTableError,
            "OpenType font missing required CFF or CFF2 table"
    end
    # Verify at least one can be loaded
    unless font.table("CFF ") || font.table("CFF2")
      raise Fontisan::MissingTableError,
            "OpenType font missing required CFF or CFF2 table"
    end
  end

  # Common required tables
  %w[head hhea maxp].each do |tag|
    unless font.has_table?(tag)
      raise Fontisan::MissingTableError,
            "Font missing required #{tag} table"
    end
    # Verify table can actually be loaded
    unless font.table(tag)
      raise Fontisan::MissingTableError,
            "Font missing required #{tag} table"
    end
  end
end

#variable_font?(font) ⇒ Boolean

Check if font is a variable font

Parameters:

Returns:

  • (Boolean)

    True if font has variation tables



664
665
666
# File 'lib/fontisan/converters/outline_converter.rb', line 664

def variable_font?(font)
  font.has_table?("fvar")
end