lib/ttfunk/ttf_encoder.rb



# frozen_string_literal: true

module TTFunk
  # Encodes a TrueType font subset to its binary representation.
  class TTFEncoder
    # Optimal table order according to TrueType specification.
    OPTIMAL_TABLE_ORDER = [
      'head', 'hhea', 'maxp', 'OS/2', 'hmtx', 'LTSH', 'VDMX',
      'hdmx', 'cmap', 'fpgm', 'prep', 'cvt ', 'loca', 'glyf',
      'kern', 'name', 'post', 'gasp', 'PCLT',
    ].freeze

    # Original font.
    # @return [TTFunk::File]
    attr_reader :original

    # Subset to encode.
    # @return [TTFunk::Subset]
    attr_reader :subset

    # Encoding options.
    # @return [Hash]
    attr_reader :options

    # @param original [TTFunk::File]
    # @param subset [TTFunk::Subset]
    # @param options [Hash]
    # @option options :kerning [Boolean] whether to encode Kerning (`kern`)
    #   table.
    def initialize(original, subset, options = {})
      @original = original
      @subset = subset
      @options = options
    end

    # Encode the font subset.
    #
    # @return [String]
    def encode
      # https://www.microsoft.com/typography/otspec/otff.htm#offsetTable
      search_range = (2**Math.log2(tables.length).floor) * 16
      entry_selector = Integer(Math.log2(2**Math.log2(tables.length).floor))
      range_shift = (tables.length * 16) - search_range
      range_shift = 0 if range_shift.negative?

      newfont = EncodedString.new

      newfont << [
        original.directory.scaler_type,
        tables.length,
        search_range,
        entry_selector,
        range_shift,
      ].pack('Nn*')

      # Tables are supposed to be listed in ascending order whereas there is a
      # known optimal order for table data.
      tables.keys.sort.each do |tag|
        newfont << [tag, checksum(tables[tag])].pack('A4N')
        newfont << Placeholder.new(tag, length: 4)
        newfont << [tables[tag].length].pack('N')
      end

      optimal_table_order.each do |optimal_tag|
        next unless tables.include?(optimal_tag)

        newfont.resolve_placeholder(optimal_tag, [newfont.length].pack('N'))
        newfont << tables[optimal_tag]
        newfont.align!(4)
      end

      sum = checksum(newfont)
      adjustment = 0xB1B0AFBA - sum
      newfont.resolve_placeholder(:checksum, [adjustment].pack('N'))

      newfont.string
    end

    private

    def optimal_table_order
      OPTIMAL_TABLE_ORDER +
        (tables.keys - ['DSIG'] - OPTIMAL_TABLE_ORDER) +
        ['DSIG']
    end

    # "mandatory" tables. Every font should ("should") have these

    def cmap_table
      @cmap_table ||= subset.new_cmap_table
    end

    def glyf_table
      @glyf_table ||= TTFunk::Table::Glyf.encode(glyphs, new_to_old_glyph, old_to_new_glyph)
    end

    def loca_table
      @loca_table ||= TTFunk::Table::Loca.encode(glyf_table[:offsets])
    end

    def hmtx_table
      @hmtx_table ||= TTFunk::Table::Hmtx.encode(original.horizontal_metrics, new_to_old_glyph)
    end

    def hhea_table
      @hhea_table = TTFunk::Table::Hhea.encode(original.horizontal_header, hmtx_table, original, new_to_old_glyph)
    end

    def maxp_table
      @maxp_table ||= TTFunk::Table::Maxp.encode(original.maximum_profile, old_to_new_glyph)
    end

    def post_table
      @post_table ||= TTFunk::Table::Post.encode(original.postscript, new_to_old_glyph)
    end

    def name_table
      @name_table ||= TTFunk::Table::Name.encode(original.name, glyf_table.fetch(:table, ''))
    end

    def head_table
      @head_table ||= TTFunk::Table::Head.encode(original.header, loca_table, new_to_old_glyph)
    end

    # "optional" tables. Fonts may omit these if they do not need them.
    # Because they apply globally, we can simply copy them over, without
    # modification, if they exist.

    def os2_table
      @os2_table ||= TTFunk::Table::OS2.encode(original.os2, subset)
    end

    def cvt_table
      @cvt_table ||= TTFunk::Table::Simple.new(original, 'cvt ').raw
    end

    def fpgm_table
      @fpgm_table ||= TTFunk::Table::Simple.new(original, 'fpgm').raw
    end

    def prep_table
      @prep_table ||= TTFunk::Table::Simple.new(original, 'prep').raw
    end

    def gasp_table
      @gasp_table ||= TTFunk::Table::Simple.new(original, 'gasp').raw
    end

    def kern_table
      # for PDFs, the kerning info is all included in the PDF as the text is
      # drawn. Thus, the PDF readers do not actually use the kerning info in
      # embedded fonts. If the library is used for something else, the
      # generated subfont may need a kerning table... in that case, you need
      # to opt into it.
      if options[:kerning]
        @kern_table ||= TTFunk::Table::Kern.encode(original.kerning, old_to_new_glyph)
      end
    end

    def vorg_table
      @vorg_table ||= TTFunk::Table::Vorg.encode(original.vertical_origins)
    end

    def dsig_table
      @dsig_table ||= TTFunk::Table::Dsig.encode(original.digital_signature)
    end

    def tables
      @tables ||= {
        'cmap' => cmap_table[:table],
        'glyf' => glyf_table[:table],
        'loca' => loca_table[:table],
        'kern' => kern_table,
        'hmtx' => hmtx_table[:table],
        'hhea' => hhea_table,
        'maxp' => maxp_table,
        'OS/2' => os2_table,
        'post' => post_table,
        'name' => name_table,
        'head' => head_table,
        'prep' => prep_table,
        'fpgm' => fpgm_table,
        'cvt ' => cvt_table,
        'VORG' => vorg_table,
        'DSIG' => dsig_table,
        'gasp' => gasp_table,
      }.compact
    end

    def glyphs
      subset.glyphs
    end

    def new_to_old_glyph
      subset.new_to_old_glyph
    end

    def old_to_new_glyph
      subset.old_to_new_glyph
    end

    def checksum(data)
      align(raw(data), 4).unpack('N*').sum & 0xFFFF_FFFF
    end

    def raw(data)
      data.respond_to?(:unresolved_string) ? data.unresolved_string : data
    end

    def align(data, width)
      if (data.length % width).positive?
        data + ("\0" * (width - (data.length % width)))
      else
        data
      end
    end
  end
end