lib/ttfunk.rb



# frozen_string_literal: true

require 'stringio'
require 'pathname'

require_relative 'ttfunk/aggregate'
require_relative 'ttfunk/directory'
require_relative 'ttfunk/resource_file'
require_relative 'ttfunk/collection'
require_relative 'ttfunk/ttf_encoder'
require_relative 'ttfunk/encoded_string'
require_relative 'ttfunk/placeholder'
require_relative 'ttfunk/otf_encoder'
require_relative 'ttfunk/sci_form'
require_relative 'ttfunk/bit_field'
require_relative 'ttfunk/bin_utils'
require_relative 'ttfunk/sub_table'
require_relative 'ttfunk/min'
require_relative 'ttfunk/max'
require_relative 'ttfunk/sum'
require_relative 'ttfunk/one_based_array'

# TTFunk is a TrueType and OpenType font library written in pure ruby. It
# supports both parsing and encoding of fonts. Also provides limited font
# subsetting.
#
# It supports a veriety of SFNT-based formats:
# * TrueType fonts (ttf)
# * OpenType fonts (otf), with both TrueType and CFF glyph outlines
# * DFont resources (dfont)
# * TrueType Collections (ttc)
#
# While not all TrueType and OpenType tables are implemented the most common
# ones are.
module TTFunk
  # TTFunk-specific exceptions
  class Error < StandardError; end

  # File represents an individual font. It can represents both TrueType and
  # OpenType fonts.
  class File
    # Raw content of the font.
    # @return [String]
    attr_reader :contents

    # Font tables directory.
    # @return [TTFunk::Directory]
    attr_reader :directory

    # Open font file
    #
    # @overload open(io)
    #   @param io [IO] IO to read font content from. IO position and binmode
    #     might change.
    #   @return [TTFunk::File]
    # @overload open(path)
    #   @param path [String, Pathname] Path to file to read the font from.
    #   @return [TTFunk::File]
    def self.open(io_or_path)
      new(verify_and_read(io_or_path))
    end

    # Load a font from a resource file.
    #
    # @param file [String, Pathname] Path to the resource file.
    # @param which [Integer, String] index or name of the font to load
    # @return [TTFunk::File]]
    def self.from_dfont(file, which = 0)
      new(ResourceFile.open(file) { |dfont| dfont['sfnt', which] })
    end

    # Load a font from a TrueType collection.
    #
    # @overload from_ttc(io, which = 0)
    #   @param file [IO] IO to read the collection from.
    #   @param which [Integer] index of the font to load
    #   @return [TTFunk::File]
    # @overload from_ttc(file_path, which = 0)
    #   @param file_path [String, Pathname] Path to the resource file.
    #   @param which [Integer] index of the font to load
    #   @return [TTFunk::File]
    def self.from_ttc(file, which = 0)
      Collection.open(file) { |ttc| ttc[which] }
    end

    # Turn a path or IO into an IO convenient for TTFunk. The resulting IO is
    # going to be in bin mode and its position set to the beginning.
    #
    # @overload verify_and_open(io)
    #   @param io [IO] IO to prepare. Its position and binmode might
    #     change.
    #   @return [io]
    # @overload verify_and_open(path)
    #   @param path [String, Pathname] path of the file to turn into an IO.
    #   @return [IO] newly opened IO for the path
    # @deprecated This method might retain open files for longer than necessary.
    # @see .verify_and_read
    def self.verify_and_open(io_or_path)
      # File or IO
      if io_or_path.respond_to?(:rewind)
        io = io_or_path
        # Rewind if the object we're passed is an IO, so that multiple embeds of
        # the same IO object will work
        io.rewind
        # read the file as binary so the size is calculated correctly
        # guard binmode because some objects acting io-like don't implement it
        io.binmode if io.respond_to?(:binmode)
        return io
      end
      # String or Pathname
      io_or_path = Pathname.new(io_or_path)
      raise ArgumentError, "#{io_or_path} not found" unless io_or_path.file?

      io_or_path.open('rb')
    end

    # Read contents of a path or IO.
    #
    # @overload verify_and_read(io)
    #   @param io [IO] IO to read from. Its position and binmode might
    #     change. IO is read from the beginning regardless of its initial
    #     position.
    #   @return [String]
    # @overload verify_and_read(path)
    #   @param path [String, Pathname] path of the file to read.
    #   @return [String]
    def self.verify_and_read(io_or_path)
      # File or IO
      if io_or_path.respond_to?(:rewind)
        io = io_or_path
        # Rewind if the object we're passed is an IO, so that multiple embeds of
        # the same IO object will work
        io.rewind
        # read the file as binary so the size is calculated correctly
        # guard binmode because some objects acting io-like don't implement it
        io.binmode if io.respond_to?(:binmode)
        return io.read
      end
      # String or Pathname
      io_or_path = Pathname.new(io_or_path)
      raise ArgumentError, "#{io_or_path} not found" unless io_or_path.file?

      io_or_path.binread
    end

    # @param contents [String] binary string containg the font data
    # @param offset [Integer] offset at which the font data starts
    def initialize(contents, offset = 0)
      @contents = StringIO.new(contents)
      @directory = Directory.new(@contents, offset)
    end

    # Glyphs ascent as defined for in the font.
    #
    # @return [Integer]
    def ascent
      @ascent ||= (os2.exists? && os2.ascent && os2.ascent.nonzero?) ||
        horizontal_header.ascent
    end

    # Glyphs descent as defined in the font.
    #
    # @return [Integer]
    def descent
      @descent ||= (os2.exists? && os2.descent && os2.descent.nonzero?) ||
        horizontal_header.descent
    end

    # Line gap as defined in the font.
    #
    # @return [Integer]
    def line_gap
      @line_gap ||= (os2.exists? && os2.line_gap && os2.line_gap.nonzero?) ||
        horizontal_header.line_gap
    end

    # Glyps bounding box as defined in the font.
    #
    # @return [Array(Integer, Integer, Integer, Integer)]
    def bbox
      [header.x_min, header.y_min, header.x_max, header.y_max]
    end

    # Font directory entry for the table with the provided tag.
    #
    # @param tag [String] table tab
    # @return [Hash, nil]
    def directory_info(tag)
      directory.tables[tag.to_s]
    end

    # Font Header (`head`) table
    #
    # @return [TTFunk::Table::Head, nil]
    def header
      @header ||= TTFunk::Table::Head.new(self)
    end

    # Character to Glyph Index Mapping (`cmap`) table
    #
    # @return [TTFunk::Tbale::Cmap, nil]
    def cmap
      @cmap ||= TTFunk::Table::Cmap.new(self)
    end

    # Horizontal Header (`hhea`) table
    #
    # @return [TTFunk::Table::Hhea, nil]
    def horizontal_header
      @horizontal_header ||= TTFunk::Table::Hhea.new(self)
    end

    # Horizontal Metrics (`hmtx`) table
    #
    # @return [TTFunk::Table::Hmtx, nil]
    def horizontal_metrics
      @horizontal_metrics ||= TTFunk::Table::Hmtx.new(self)
    end

    # Maximum Profile (`maxp`) table
    #
    # @return [TTFunk::Table::Maxp, nil]
    def maximum_profile
      @maximum_profile ||= TTFunk::Table::Maxp.new(self)
    end

    # Kerning (`kern`) table
    #
    # @return [TTFunk::Table::Kern, nil]
    def kerning
      @kerning ||= TTFunk::Table::Kern.new(self)
    end

    # Naming (`name`) table
    #
    # @return [TTFunk::Table::Name, nil]
    def name
      @name ||= TTFunk::Table::Name.new(self)
    end

    # OS/2 and Windows Metrics (`OS/2`) table
    #
    # @return [TTFunk::Table:OS2, nil]
    def os2
      @os2 ||= TTFunk::Table::OS2.new(self)
    end

    # PostScript (`post`) table
    #
    # @return [TTFunk::Table::Post, nil]
    def postscript
      @postscript ||= TTFunk::Table::Post.new(self)
    end

    # Index to Location (`loca`) table
    #
    # @return [TTFunk::Table::Loca, nil]
    def glyph_locations
      @glyph_locations ||= TTFunk::Table::Loca.new(self)
    end

    # Glyph Data (`glyf`) table
    #
    # @return [TTFunk::Table::Glyf, nil]
    def glyph_outlines
      @glyph_outlines ||= TTFunk::Table::Glyf.new(self)
    end

    # Standard Bitmap Graphics (`sbix`) table
    #
    # @return [TTFunk::Table::Sbix, nil]
    def sbix
      @sbix ||= TTFunk::Table::Sbix.new(self)
    end

    # Compact Font Format (`CFF `) table
    #
    # @return [Table::Table::Cff, nil]
    def cff
      @cff ||= TTFunk::Table::Cff.new(self)
    end

    # Vertical Origin (`VORG`) table
    #
    # @return [TTFunk::Table::Vorg, nil]
    def vertical_origins
      @vertical_origins ||=
        if directory.tables.include?(TTFunk::Table::Vorg::TAG)
          TTFunk::Table::Vorg.new(self)
        end
    end

    # Digital Signature (`DSIG`) table
    #
    # @return [TTFunk::Table::Dsig, nil]
    def digital_signature
      @digital_signature ||=
        if directory.tables.include?(TTFunk::Table::Dsig::TAG)
          TTFunk::Table::Dsig.new(self)
        end
    end

    # Find glyph by its index.
    #
    # @return [TTFunk::Table::Cff::Charstring] if it's a CFF-based OpenType font
    # @return [TTFunk::Table::Glyf::Simple, TTFunk::Table::Glyf::Compound]
    #   if it's a TrueType font
    def find_glyph(glyph_id)
      if cff.exists?
        cff.top_index[0].charstrings_index[glyph_id].glyph
      else
        glyph_outlines.for(glyph_id)
      end
    end
  end
end

require_relative 'ttfunk/table/cff'
require_relative 'ttfunk/table/cmap'
require_relative 'ttfunk/table/dsig'
require_relative 'ttfunk/table/glyf'
require_relative 'ttfunk/table/head'
require_relative 'ttfunk/table/hhea'
require_relative 'ttfunk/table/hmtx'
require_relative 'ttfunk/table/kern'
require_relative 'ttfunk/table/loca'
require_relative 'ttfunk/table/maxp'
require_relative 'ttfunk/table/name'
require_relative 'ttfunk/table/os2'
require_relative 'ttfunk/table/post'
require_relative 'ttfunk/table/sbix'
require_relative 'ttfunk/table/vorg'