prawnpdf/prawn

View on GitHub
lib/prawn/font.rb

Summary

Maintainability
A
1 hr
Test Coverage
# frozen_string_literal: true

require_relative 'font_metric_cache'

module Prawn
  class Document # rubocop: disable Style/Documentation
    # @group Stable API

    # Default empty options.
    DEFAULT_OPTS = {}.freeze

    # Without arguments, this returns the currently selected font. Otherwise, it
    # sets the current font. When a block is used, the font is applied
    # transactionally and is rolled back when the block exits.
    #
    # ```ruby
    # Prawn::Document.generate("font.pdf") do
    #   text "Default font is Helvetica"
    #
    #   font "Times-Roman"
    #   text "Now using Times-Roman"
    #
    #   font("DejaVuSans.ttf") do
    #     text "Using TTF font from file DejaVuSans.ttf"
    #     font "Courier", style: :bold
    #     text "You see this in bold Courier"
    #   end
    #
    #   text "Times-Roman, again"
    # end
    # ```
    #
    # The `name` parameter must be a string. It can be one of the 14 built-in
    # fonts supported by PDF, or the location of a TTF file. The
    # {Fonts::AFM::BUILT_INS} array specifies the valid built in font names.
    #
    # If a TTF/OTF font is specified, the glyphs necessary to render your
    # document will be embedded in the rendered PDF. This should be your
    # preferred option in most cases. It will increase the size of the resulting
    # file, but also make it more portable.
    #
    # The options parameter is an optional hash providing size and style. To use
    # the :style option you need to map those font styles to their respective
    # font files.
    #
    # @param name [String] font name. It can be:
    #   - One of 14 PDF built-in fonts.
    #   - A font file path.
    #   - A font name defined in {font_families}
    # @param options [Hash{Symbol => any}]
    # @option options :style [Symbol] font style
    # @yield
    # @return [Font]
    # @see #font_families
    # @see Font::AFM::BUILT_INS
    def font(name = nil, options = DEFAULT_OPTS)
      return((defined?(@font) && @font) || font('Helvetica')) if name.nil?

      if state.pages.empty? && !state.page.in_stamp_stream?
        raise Prawn::Errors::NotOnPage
      end

      new_font = find_font(name.to_s, options)

      if block_given?
        save_font do
          set_font(new_font, options[:size])
          yield
        end
      else
        set_font(new_font, options[:size])
      end

      @font
    end

    # When called with no argument, returns the current font size.
    #
    # When called with a single argument but no block, sets the current font
    # size. When a block is used, the font size is applied transactionally and
    # is rolled back when the block exits. You may still change the font size
    # within a transactional block for individual text segments, or nested calls
    # to `font_size`.
    #
    # @example
    #   Prawn::Document.generate("font_size.pdf") do
    #     font_size 16
    #     text "At size 16"
    #
    #     font_size(10) do
    #       text "At size 10"
    #       text "At size 6", size: 6
    #       text "At size 10"
    #     end
    #
    #     text "At size 16"
    #   end
    #
    # @overload font_size()
    #   @return [Number] vurrent font size
    # @overload font_size(points)
    #   @param points [Number] new font size
    #   @yield if block is provided font size is set only for the duration of
    #    the block
    #   @return [void]
    def font_size(points = nil)
      return @font_size unless points

      size_before_yield = @font_size
      @font_size = points
      block_given? ? yield : return
      @font_size = size_before_yield
    end

    # Sets the font size.
    #
    # @param size [Number]
    # @return [Number]
    def font_size=(size)
      font_size(size)
    end

    # Returns the width of the given string using the given font. If `:size` is
    # not specified as one of the options, the string is measured using the
    # current font size. You can also pass `:kerning` as an option to indicate
    # whether kerning should be used when measuring the width (defaults to
    # `false`).
    #
    # Note that the string _must_ be encoded properly for the font being used.
    # For AFM fonts, this is WinAnsi. For TTF/OTF, make sure the font is encoded
    # as UTF-8. You can use the Font#normalize_encoding method to make sure
    # strings are in an encoding appropriate for the current font.
    #
    # @devnote
    #   For the record, this method used to be a method of Font (and still
    #   delegates to width computations on Font). However, having the primary
    #   interface for calculating string widths exist on Font made it tricky to
    #   write extensions for Prawn in which widths are computed differently
    #   (e.g., taking formatting tags into account, or the like).
    #
    #   By putting width_of here, on Document itself, extensions may easily
    #   override it and redefine the width calculation behavior.
    #
    # @param string [String]
    # @param options [Hash{Symbol => any}]
    # @option options :inline_format [Boolean] (false)
    # @option options :kerning [Boolean] (false)
    # @option options :style [Symbol]
    # @return [Number]
    def width_of(string, options = {})
      if options.key?(:inline_format)
        p = options[:inline_format]
        p = [] unless p.is_a?(Array)

        # Build up an Arranger with the entire string on one line, finalize it,
        # and find its width.
        arranger = Prawn::Text::Formatted::Arranger.new(self, options)
        arranger.consumed = text_formatter.format(string, *p)
        arranger.finalize_line

        arranger.line_width
      else
        width_of_string(string, options)
      end
    end

    # Hash that maps font family names to their styled individual font
    # definitions.
    #
    # To add support for another font family, append to this hash, e.g:
    #
    # ```ruby
    # pdf.font_families.update(
    #   "MyTrueTypeFamily" => {
    #     bold: "foo-bold.ttf",
    #     italic: "foo-italic.ttf",
    #     bold_italic: "foo-bold-italic.ttf",
    #     normal: "foo.ttf",
    #   }
    # )
    # ```
    #
    # This will then allow you to use the fonts like so:
    #
    # ```ruby
    # pdf.font("MyTrueTypeFamily", style: :bold)
    # pdf.text "Some bold text"
    # pdf.font("MyTrueTypeFamily")
    # pdf.text "Some normal text"
    # ```
    #
    # This assumes that you have appropriate TTF/OTF fonts for each style you
    # wish to support.
    #
    # By default the styles `:bold`, `:italic`, `:bold_italic`, and `:normal`
    # are defined for fonts "Courier", "Times-Roman" and "Helvetica". When
    # defining your own font families, you can map any or all of these styles to
    # whatever font files you'd like.
    #
    # Font definition can be either a hash or just a string.
    #
    # A hash font definition can specify a number of options:
    #
    # - `:file` -- path to the font file (required)
    # - `:subset` -- whether to subset the font (default false). Only
    #   applicable to TrueType and OpenType fonts (includnig DFont and TTC).
    #
    # A string font definition is equivalent to hash definition with only
    # `:file` being specified.
    #
    # @return [Hash{String => Hash{Symbol => String, Hash{Symbol => String}}}]
    def font_families
      @font_families ||= {}.merge!(
        'Courier' => {
          bold: 'Courier-Bold',
          italic: 'Courier-Oblique',
          bold_italic: 'Courier-BoldOblique',
          normal: 'Courier',
        },

        'Times-Roman' => {
          bold: 'Times-Bold',
          italic: 'Times-Italic',
          bold_italic: 'Times-BoldItalic',
          normal: 'Times-Roman',
        },

        'Helvetica' => {
          bold: 'Helvetica-Bold',
          italic: 'Helvetica-Oblique',
          bold_italic: 'Helvetica-BoldOblique',
          normal: 'Helvetica',
        },
      )
    end

    # @group Experimental API

    # Sets the font directly, given an actual {Font} object and size.
    #
    # @private
    # @param font [Font]
    # @param size [Number]
    # @return [void]
    def set_font(font, size = nil)
      @font = font
      @font_size = size if size
    end

    # Saves the current font, and then yields. When the block finishes, the
    # original font is restored.
    #
    # @yield
    # @return [void]
    def save_font
      @font ||= find_font('Helvetica')
      original_font = @font
      original_size = @font_size

      yield
    ensure
      set_font(original_font, original_size) if original_font
    end

    # Looks up the given font using the given criteria. Once a font has been
    # found by that matches the criteria, it will be cached to subsequent
    # lookups for that font will return the same object.
    #
    # @devnote
    #   Challenges involved: the name alone is not sufficient to uniquely
    #   identify a font (think dfont suitcases that can hold multiple different
    #   fonts in a single file). Thus, the `:name` key is included in the cache
    #   key.
    #
    #   It is further complicated, however, since fonts in some formats (like
    #   the dfont suitcases) can be identified either by numeric index, OR by
    #   their name within the suitcase, and both should hash to the same font
    #   object (to avoid the font being embedded multiple times). This is not
    #   yet implemented, which means if someone selects a font both by name, and
    #   by index, the font will be embedded twice. Since we do font subsetting,
    #   this double embedding won't be catastrophic, just annoying.
    #
    # @private
    # @param name [String]
    # @param options [Hash]
    # @option options :style [Symbol]
    # @option options :file [String]
    # @option options :font [Integer, String] index or name of the font in
    #   a font suitcase/collection
    # @return [Font]
    def find_font(name, options = {}) # :nodoc:
      if font_families.key?(name)
        family = name
        name = font_families[name][options[:style] || :normal]
        if name.is_a?(::Hash)
          options = options.merge(name)
          name = options[:file]
        end
      end
      key = "#{family}:#{name}:#{options[:font] || 0}"

      if name.is_a?(Prawn::Font)
        font_registry[key] = name
      else
        font_registry[key] ||=
          Font.load(self, name, options.merge(family: family))
      end
    end

    # Hash of Font objects keyed by names.
    #
    # @private
    # @return [Hash{String => Font}]
    def font_registry
      @font_registry ||= {}
    end

    private

    def width_of_inline_formatted_string(string, options = {})
      # Build up an Arranger with the entire string on one line, finalize it,
      # and find its width.
      arranger = Prawn::Text::Formatted::Arranger.new(self, options)
      arranger.consumed = Text::Formatted::Parser.format(string)
      arranger.finalize_line

      arranger.line_width
    end

    def width_of_string(string, options = {})
      font_metric_cache.width_of(string, options)
    end
  end

  # Provides font information and helper functions.
  #
  # @abstract
  class Font
    require_relative 'fonts'

    # @deprecated
    AFM = Prawn::Fonts::AFM

    # @deprecated
    TTF = Fonts::TTF

    # @deprecated
    DFont = Fonts::DFont

    # @deprecated
    TTC = Fonts::TTC

    # The font name.
    # @return [String]
    attr_reader :name

    # The font family.
    # @return [String]
    attr_reader :family

    # The options hash used to initialize the font.
    # @return [Hash]
    attr_reader :options

    # Shortcut interface for constructing a font object. Filenames of the form
    # `*.ttf` will call {Fonts::TTF#initialize TTF.new}, `*.otf` calls
    # {Fonts::OTF#initialize OTF.new}, `*.dfont` calls {Fonts::DFont#initialize
    # DFont.new}, `*.ttc` goes to {Fonts::TTC#initialize TTC.new}, and anything
    # else will be passed through to {Prawn::Fonts::AFM#initialize AFM.new}.
    #
    # @param document [Prawn::Document] owning document
    # @param src [String] font file path
    # @param options [Hash]
    # @option options :family [String]
    # @option options :style [Symbol]
    # @return [Prawn::Fonts::Font]
    def self.load(document, src, options = {})
      case font_format(src, options)
      when 'ttf' then TTF.new(document, src, options)
      when 'otf' then Fonts::OTF.new(document, src, options)
      when 'dfont' then DFont.new(document, src, options)
      when 'ttc' then TTC.new(document, src, options)
      else AFM.new(document, src, options)
      end
    end

    # Guesses font format.
    #
    # @private
    # @param src [String, IO]
    # @param options [Hash]
    # @option options :format [String]
    # @return [String]
    def self.font_format(src, options)
      return options.fetch(:format, 'ttf') if src.respond_to?(:read)

      case src.to_s
      when /\.ttf$/i then 'ttf'
      when /\.otf$/i then 'otf'
      when /\.dfont$/i then 'dfont'
      when /\.ttc$/i then 'ttc'
      else 'afm'
      end
    end

    # @private
    # @param document [Prawn::Document]
    # @param name [String]
    # @param options [Hash{Symbol => any}]
    # @option options :family [String]
    # @option options :subset [Boolean] (true)
    def initialize(document, name, options = {})
      @document = document
      @name = name
      @options = options

      @family = options[:family]

      @identifier = generate_unique_id

      @references = {}
      @subset_name_cache = {}

      @full_font_embedding = options.key?(:subset) && !options[:subset]
    end

    # The size of the font ascender in PDF points.
    #
    # @return [Number]
    def ascender
      @ascender / 1000.0 * size
    end

    # The size of the font descender in PDF points.
    #
    # @return [Number]
    def descender
      -@descender / 1000.0 * size
    end

    # The size of the recommended gap between lines of text in PDF points
    #
    # @return [Number]
    def line_gap
      @line_gap / 1000.0 * size
    end

    # Normalizes the encoding of the string to an encoding supported by the
    # font. The string is expected to be UTF-8 going in. It will be re-encoded
    # and the new string will be returned.
    #
    # @abstract
    # @!parse def normalize_encoding(string); end
    # @param string [String]
    # @return [String]
    def normalize_encoding(_string)
      raise NotImplementedError,
        'subclasses of Prawn::Font must implement #normalize_encoding'
    end

    # Destructive version of {normalize_encoding}; normalizes the encoding of a
    # string in place.
    #
    # @note This method doesn't mutate its argument any more.
    #
    # @deprecated
    # @param str [String]
    # @return [String]
    def normalize_encoding!(str)
      warn('Font#normalize_encoding! is deprecated. Please use non-mutating version Font#normalize_encoding instead.')
      str.dup.replace(normalize_encoding(str))
    end

    # Gets height of font in PDF points at the given font size.
    #
    # @param size [Number]
    # @return [Number]
    def height_at(size)
      @normalized_height ||= (@ascender - @descender + @line_gap) / 1000.0
      @normalized_height * size
    end

    # Gets height of current font in PDF points at current font size.
    #
    # @return [Number]
    def height
      height_at(size)
    end

    # Registers the given subset of the current font with the current PDF
    # page. This is safe to call multiple times for a given font and subset,
    # as it will only add the font the first time it is called.
    #
    # @param subset [Integer]
    # @return [void]
    def add_to_current_page(subset)
      @references[subset] ||= register(subset)
      @document.state.page.fonts[identifier_for(subset)] = @references[subset]
    end

    # @private
    # @param subset [Integer]
    # @return [Symbol]
    def identifier_for(subset)
      @subset_name_cache[subset] ||=
        if full_font_embedding
          @identifier.to_sym
        else
          :"#{@identifier}.#{subset}"
        end
    end

    # Returns a string containing a human-readable representation of this font.
    #
    # @return [String]
    def inspect
      "#{self.class.name}< #{name}: #{size} >"
    end

    # Return a hash (as in `Object#hash`) for the font. This is required since
    # font objects are used as keys in hashes that cache certain values.
    #
    # @return [Integer]
    def hash
      [self.class, name, family].hash
    end

    # Compliments the {#hash} implementation.
    #
    # @param other [Object]
    # @return [Boolean]
    def eql?(other)
      self.class == other.class && name == other.name &&
        family == other.family && size == other.size
    end

    private

    attr_reader :full_font_embedding

    # generate a font identifier that hasn't been used on the current page yet
    #
    def generate_unique_id
      key = nil
      font_count = @document.font_registry.size + 1
      loop do
        key = :"F#{font_count}"
        break if key_is_unique?(key)

        font_count += 1
      end
      key
    end

    def key_is_unique?(test_key)
      @document.state.page.fonts.keys.none? do |key|
        key.to_s.start_with?("#{test_key}.")
      end
    end

    protected

    def size
      @document.font_size
    end
  end
end