Class: Fontisan::Converters::OutlineConverter

Inherits:
Object
  • Object
show all
Includes:
ConversionStrategy
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 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

#build_cff_table(outlines, font, _hints_per_glyph) ⇒ String

Build CFF table from outlines

Parameters:

  • outlines (Array<Outline>)

    Glyph outlines

  • font (TrueTypeFont)

    Source font (for metadata)

Returns:

  • (String)

    CFF table binary data



361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
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
443
444
445
446
447
448
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
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
# File 'lib/fontisan/converters/outline_converter.rb', line 361

def build_cff_table(outlines, font, _hints_per_glyph)
  # Build CharStrings INDEX from outlines
  begin
    charstrings = outlines.map do |outline|
      builder = Tables::Cff::CharStringBuilder.new
      if outline.empty?
        builder.build_empty
      else
        builder.build(outline)
      end
    end
  rescue StandardError => e
    raise Fontisan::Error, "Failed to build CharStrings: #{e.message}"
  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 font metadata
  begin
    font_name = extract_font_name(font)
  rescue StandardError => e
    raise Fontisan::Error, "Failed to extract font name: #{e.message}"
  end

  # Build all INDEXes
  begin
    header_size = 4
    name_index_data = Tables::Cff::IndexBuilder.build([font_name])
    string_index_data = Tables::Cff::IndexBuilder.build([]) # Empty strings
    global_subr_index_data = Tables::Cff::IndexBuilder.build([]) # Empty global subrs
    charstrings_index_data = Tables::Cff::IndexBuilder.build(charstrings)
    local_subrs_index_data = Tables::Cff::IndexBuilder.build(local_subrs)
  rescue StandardError => e
    raise Fontisan::Error, "Failed to build CFF indexes: #{e.message}"
  end

  # Build Private DICT with Subrs offset if we have local subroutines
  begin
    private_dict_hash = {
      default_width_x: 1000,
      nominal_width_x: 0,
    }

    # If we have local subroutines, add Subrs offset
    # Subrs offset is relative to Private DICT start
    if local_subrs.any?
      # Calculate size of Private DICT itself to know where Subrs starts
      temp_private_dict_data = Tables::Cff::DictBuilder.build(private_dict_hash)
      subrs_offset = temp_private_dict_data.bytesize

      # Add Subrs offset to DICT
      private_dict_hash[:subrs] = subrs_offset
    end

    # Build final Private DICT
    private_dict_data = Tables::Cff::DictBuilder.build(private_dict_hash)
    private_dict_size = private_dict_data.bytesize
  rescue StandardError => e
    raise Fontisan::Error, "Failed to build Private DICT: #{e.message}"
  end

  # Calculate offsets with iterative refinement
  begin
    # Initial pass
    top_dict_index_start = header_size + name_index_data.bytesize
    string_index_start = top_dict_index_start + 100 # Approximate
    global_subr_index_start = string_index_start + string_index_data.bytesize
    charstrings_offset = global_subr_index_start + global_subr_index_data.bytesize

    # Build Top DICT
    top_dict_hash = {
      charset: 0,
      encoding: 0,
      charstrings: charstrings_offset,
    }
    top_dict_data = Tables::Cff::DictBuilder.build(top_dict_hash)
    top_dict_index_data = Tables::Cff::IndexBuilder.build([top_dict_data])

    # Recalculate with actual Top DICT size
    string_index_start = top_dict_index_start + top_dict_index_data.bytesize
    global_subr_index_start = string_index_start + string_index_data.bytesize
    charstrings_offset = global_subr_index_start + global_subr_index_data.bytesize
    private_dict_offset = charstrings_offset + charstrings_index_data.bytesize

    # Update Top DICT with Private DICT info
    top_dict_hash = {
      charset: 0,
      encoding: 0,
      charstrings: charstrings_offset,
      private: [private_dict_size, private_dict_offset],
    }
    top_dict_data = Tables::Cff::DictBuilder.build(top_dict_hash)
    top_dict_index_data = Tables::Cff::IndexBuilder.build([top_dict_data])

    # Final recalculation
    string_index_start = top_dict_index_start + top_dict_index_data.bytesize
    global_subr_index_start = string_index_start + string_index_data.bytesize
    charstrings_offset = global_subr_index_start + global_subr_index_data.bytesize
    private_dict_offset = charstrings_offset + charstrings_index_data.bytesize

    # Final Top DICT
    top_dict_hash = {
      charset: 0,
      encoding: 0,
      charstrings: charstrings_offset,
      private: [private_dict_size, private_dict_offset],
    }
    top_dict_data = Tables::Cff::DictBuilder.build(top_dict_hash)
    top_dict_index_data = Tables::Cff::IndexBuilder.build([top_dict_data])
  rescue StandardError => e
    raise Fontisan::Error,
          "Failed to calculate CFF table offsets: #{e.message}"
  end

  # Build CFF Header
  begin
    header = [
      1,    # major version
      0,    # minor version
      4,    # header size
      4,    # offSize (will be in INDEX)
    ].pack("C4")
  rescue StandardError => e
    raise Fontisan::Error, "Failed to build CFF header: #{e.message}"
  end

  # Assemble complete CFF table
  begin
    header +
      name_index_data +
      top_dict_index_data +
      string_index_data +
      global_subr_index_data +
      charstrings_index_data +
      private_dict_data +
      local_subrs_index_data
  rescue StandardError => e
    raise Fontisan::Error, "Failed to assemble CFF table: #{e.message}"
  end
end

#build_glyf_loca_tables(outlines, _hints_per_glyph) ⇒ Array<String, String, Integer>

Build glyf and loca tables from outlines

Parameters:

  • outlines (Array<Outline>)

    Glyph outlines

Returns:

  • (Array<String, String, Integer>)
    glyf_data, loca_data, loca_format


517
518
519
520
521
522
523
524
525
526
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
# File 'lib/fontisan/converters/outline_converter.rb', line 517

def build_glyf_loca_tables(outlines, _hints_per_glyph)
  glyf_data = "".b
  offsets = []

  # Build each glyph
  outlines.each do |outline|
    offsets << glyf_data.bytesize

    if outline.empty?
      # Empty glyph - no data
      next
    end

    # Build glyph data using GlyphBuilder class method
    glyph_data = Fontisan::Tables::GlyphBuilder.build_simple_glyph(outline)
    glyf_data << glyph_data

    # Add padding to 4-byte boundary
    padding = (4 - (glyf_data.bytesize % 4)) % 4
    glyf_data << ("\x00" * padding) if padding.positive?
  end

  # Add final offset
  offsets << glyf_data.bytesize

  # Build loca table
  # Determine format based on max offset
  max_offset = offsets.max
  if max_offset <= 0x1FFFE
    # Short format (offsets / 2)
    loca_format = 0
    loca_data = offsets.map { |off| off / 2 }.pack("n*")
  else
    # Long format
    loca_format = 1
    loca_data = offsets.pack("N*")
  end

  [glyf_data, loca_data, loca_format]
end

#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



179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
# File 'lib/fontisan/converters/outline_converter.rb', line 179

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,
                                                             hints_per_glyph)

  # 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
# 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 CFF table from outlines and hints
  cff_data = build_cff_table(outlines, font, hints_per_glyph)

  # 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



783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
# File 'lib/fontisan/converters/outline_converter.rb', line 783

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



563
564
565
566
567
568
569
570
571
572
573
# File 'lib/fontisan/converters/outline_converter.rb', line 563

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



824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
# File 'lib/fontisan/converters/outline_converter.rb', line 824

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



844
845
846
847
848
849
850
851
852
853
854
# File 'lib/fontisan/converters/outline_converter.rb', line 844

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



986
987
988
989
990
991
992
# File 'lib/fontisan/converters/outline_converter.rb', line 986

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



944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
# File 'lib/fontisan/converters/outline_converter.rb', line 944

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_cff_outlines(font) ⇒ Array<Outline>

Extract outlines from CFF font

Parameters:

Returns:

  • (Array<Outline>)

    Array of outline objects

Raises:



327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
# File 'lib/fontisan/converters/outline_converter.rb', line 327

def extract_cff_outlines(font)
  # Get CFF table
  cff = font.table("CFF ")
  raise Fontisan::Error, "CFF table not found" unless cff

  # Get number of glyphs
  num_glyphs = cff.glyph_count

  # Extract all glyphs
  outlines = []
  num_glyphs.times do |glyph_id|
    charstring = cff.charstring_for_glyph(glyph_id)

    outlines << if charstring.nil? || charstring.path.empty?
                  # Empty glyph
                  Models::Outline.new(
                    glyph_id: glyph_id,
                    commands: [],
                    bbox: { x_min: 0, y_min: 0, x_max: 0, y_max: 0 },
                  )
                else
                  # Convert CharString to outline
                  Models::Outline.from_cff(charstring, glyph_id)
                end
  end

  outlines
end

#extract_font_name(font) ⇒ String

Extract font name from name table

Parameters:

Returns:

  • (String)

    Font name



668
669
670
671
672
673
674
675
676
# File 'lib/fontisan/converters/outline_converter.rb', line 668

def extract_font_name(font)
  name_table = font.table("name")
  if name_table
    font_name = name_table.english_name(Tables::Name::FAMILY)
    return font_name.dup.force_encoding("ASCII-8BIT") if font_name
  end

  "UnnamedFont"
end

#extract_ttf_hint_set(font) ⇒ HintSet

Extract complete TrueType hint set from font

Parameters:

Returns:

  • (HintSet)

    Complete hint set



974
975
976
977
978
979
980
# File 'lib/fontisan/converters/outline_converter.rb', line 974

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



912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
# File 'lib/fontisan/converters/outline_converter.rb', line 912

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

#extract_ttf_outlines(font) ⇒ Array<Outline>

Extract outlines from TrueType font

Parameters:

Returns:

  • (Array<Outline>)

    Array of outline objects



286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
# File 'lib/fontisan/converters/outline_converter.rb', line 286

def extract_ttf_outlines(font)
  # 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)

  # Create resolver for compound glyphs
  resolver = Tables::CompoundGlyphResolver.new(glyf, loca, head)

  # Extract all glyphs
  outlines = []
  maxp.num_glyphs.times do |glyph_id|
    glyph = glyf.glyph_for(glyph_id, loca, head)

    outlines << if glyph.nil? || glyph.empty?
                  # Empty glyph - create empty outline
                  Models::Outline.new(
                    glyph_id: glyph_id,
                    commands: [],
                    bbox: { x_min: 0, y_min: 0, x_max: 0, y_max: 0 },
                  )
                elsif glyph.simple?
                  # Convert simple glyph to outline
                  Models::Outline.from_truetype(glyph, glyph_id)
                else
                  # Compound glyph - resolve to simple outline
                  resolver.resolve(glyph)
                end
  end

  outlines
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



749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
# File 'lib/fontisan/converters/outline_converter.rb', line 749

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

#optimize_charstrings(charstrings) ⇒ Array<Array<String>, Array<String>>

Optimize CharStrings using subroutine extraction

Parameters:

  • charstrings (Array<String>)

    Original CharString bytes

Returns:

  • (Array<Array<String>, Array<String>>)
    optimized_charstrings, local_subrs


682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
# File 'lib/fontisan/converters/outline_converter.rb', line 682

def optimize_charstrings(charstrings)
  # Convert to hash format expected by PatternAnalyzer
  charstrings_hash = {}
  charstrings.each_with_index do |cs, index|
    charstrings_hash[index] = cs
  end

  # Analyze patterns
  analyzer = Optimizers::PatternAnalyzer.new(
    min_length: 10,
    stack_aware: true,
  )
  patterns = analyzer.analyze(charstrings_hash)

  # Return original if no patterns found
  return [charstrings, []] if patterns.empty?

  # Optimize selection
  optimizer = Optimizers::SubroutineOptimizer.new(patterns,
                                                  max_subrs: 65_535)
  selected_patterns = optimizer.optimize_selection

  # Optimize ordering
  selected_patterns = optimizer.optimize_ordering(selected_patterns)

  # Return original if no patterns selected
  return [charstrings, []] if selected_patterns.empty?

  # Build subroutines
  builder = Optimizers::SubroutineBuilder.new(selected_patterns,
                                              type: :local)
  local_subrs = builder.build

  # Build subroutine map
  subroutine_map = {}
  selected_patterns.each_with_index do |pattern, index|
    subroutine_map[pattern.bytes] = index
  end

  # Rewrite CharStrings
  rewriter = Optimizers::CharstringRewriter.new(subroutine_map, builder)
  optimized_charstrings = charstrings.map.with_index do |charstring, glyph_id|
    # Find patterns for this glyph
    glyph_patterns = selected_patterns.select do |p|
      p.glyphs.include?(glyph_id)
    end

    if glyph_patterns.empty?
      charstring
    else
      rewriter.rewrite(charstring, glyph_patterns)
    end
  end

  [optimized_charstrings, local_subrs]
rescue StandardError => e
  # If optimization fails for any reason, return original CharStrings
  warn "Optimization warning: #{e.message}"
  [charstrings, []]
end

#otf_to_ttfHash<String, String>

Convert OpenType/CFF font to TrueType

Returns:

  • (Hash<String, String>)

    Target tables

Raises:



234
235
236
237
238
# File 'lib/fontisan/converters/outline_converter.rb', line 234

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



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

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:



225
226
227
228
229
# File 'lib/fontisan/converters/outline_converter.rb', line 225

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



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

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



654
655
656
657
658
659
660
661
662
# File 'lib/fontisan/converters/outline_converter.rb', line 654

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



580
581
582
583
584
# File 'lib/fontisan/converters/outline_converter.rb', line 580

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



592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
# File 'lib/fontisan/converters/outline_converter.rb', line 592

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



259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
# File 'lib/fontisan/converters/outline_converter.rb', line 259

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



861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
# File 'lib/fontisan/converters/outline_converter.rb', line 861

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



998
999
1000
# File 'lib/fontisan/converters/outline_converter.rb', line 998

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