prawnpdf/prawn

View on GitHub
lib/prawn/text/formatted/box.rb

Summary

Maintainability
D
2 days
Test Coverage
# frozen_string_literal: true

# text/formatted/rectangle.rb : Implements text boxes with formatted text
#
# Copyright February 2010, Daniel Nelson. All Rights Reserved.
#
# This is free software. Please see the LICENSE and COPYING files for details.
#

module Prawn
  module Text
    module Formatted
      # Formatted text box.
      #
      # Generally, one would use the {Prawn::Text::Formatted#formatted_text_box}
      # convenience method. However, using `Text::Formatted::Box.new` in
      # conjunction with `#render(dry_run: true)` enables one to do calculations
      # prior to placing text on the page, or to determine how much vertical
      # space was consumed by the printed text
      class Box
        include Prawn::Text::Formatted::Wrap

        # @group Experimental API

        # The text that was successfully printed (or, if `:dry_run` was
        # used, the text that would have been successfully printed).
        # @return [Array<Hash>]
        attr_reader :text

        # True if nothing printed (or, if `:dry_run` was used, nothing would
        # have been successfully printed).
        #
        # @return [Boolean]
        def nothing_printed?
          @nothing_printed
        end

        # True if everything printed (or, if `:dry_run` was used, everything
        # would have been successfully printed).
        #
        # @return [Boolean]
        def everything_printed?
          @everything_printed
        end

        # The upper left corner of the text box.
        # @return [Array(Number, Number)]
        attr_reader :at

        # The line height of the last line printed.
        # @return [Number]
        attr_reader :line_height

        # The height of the ascender of the last line printed.
        # @return [Number]
        attr_reader :ascender

        # The height of the descender of the last line printed.
        # @return [Number]
        attr_reader :descender

        # The leading used during printing.
        # @return [Number]
        attr_reader :leading

        # Gap between adjacent lines of text.
        #
        # @return [Number]
        def line_gap
          line_height - (ascender + descender)
        end

        # See Prawn::Text#text_box for valid options
        #
        # @param formatted_text [Array<Hash{Symbol => any}>]
        #   Formatted text is an array of hashes, where each hash defines text
        #   and format information. The following hash options are supported:
        #
        #   - `:text` --- the text to format according to the other hash
        #     options.
        #   - `:styles` --- an array of styles to apply to this text. Available
        #     styles include `:bold`, `:italic`, `:underline`, `:strikethrough`,
        #     `:subscript`, and `:superscript`.
        #   - `:size` ---a number denoting the font size to apply to this text.
        #   - `:character_spacing` --- a number denoting how much to increase or
        #     decrease the default spacing between characters.
        #   - `:font` --- the name of a font. The name must be an AFM font with
        #     the desired faces or must be a font that is already registered
        #     using {Prawn::Document#font_families}.
        #   - `:color` --- anything compatible with
        #     {Prawn::Graphics::Color#fill_color} and
        #     {Prawn::Graphics::Color#stroke_color}.
        #   - :link` --- a URL to which to create a link. A clickable link will
        #     be created to that URL. Note that you must explicitly underline
        #     and color using the appropriate tags if you which to draw
        #     attention to the link.
        #   - `:anchor` --- a destination that has already been or will be
        #     registered using `PDF::Core::Destinations#add_dest`. A clickable
        #     link will be created to that destination. Note that you must
        #     explicitly underline and color using the appropriate tags if you
        #     which to draw attention to the link.
        #   - `:local` --- a file or application to be opened locally.
        #     A clickable link will be created to the provided local file or
        #     application. If the file is another PDF, it will be opened in
        #     a new window. Note that you must explicitly underline and color
        #     using the appropriate options if you which to draw attention to
        #     the link.
        #   - `:draw_text_callback` --- if provided, this Proc will be called
        #     instead of {Prawn::Document#draw_text!} once per fragment for
        #     every low-level addition of text to the page.
        #   - `:callback` --- an object (or array of such objects) with two
        #     methods: `render_behind` and `render_in_front`, which are called
        #     immediately prior to and immediately after rendering the text
        #     fragment and which are passed the fragment as an argument.
        # @param options [Hash{Symbol => any}]
        # @option options :document [Prawn::Document] Owning document.
        # @option options :kerning [Boolean]
        #   (value of document.default_kerning?)
        #   Whether or not to use kerning (if it is available with the current
        #   font).
        # @option options :size [Number] (current font size)
        #   The font size to use.
        # @option options :character_spacing [Number] (0)
        #   The amount of space to add to or remove from the default character
        #   spacing.
        # @option options :disable_wrap_by_char [Boolean] (false)
        #   Whether or not to prevent mid-word breaks when text does not fit in
        #   box.
        # @option options :mode [Symbol] (:fill)
        #   The text rendering mode. See documentation for
        #   {Prawn::Document#text_rendering_mode} for a list of valid options.
        # @option option :style [Symbol] (current style)
        #   The style to use. The requested style must be part of the current
        #   font family.
        # @option option :at [Array(Number, Number)] (bounds top left corner)
        #   The upper left corner of the box.
        # @option options :width [Number] (bounds.right - at[0])
        #   The width of the box.
        # @option options :height [Number] (default_height())
        #   The height of the box.
        # @option options :direction [:ltr, :rtl]
        #   (value of document.text_direction)
        #   Direction of the text (left-to-right or right-to-left).
        # @option options :fallback_fonts [Array<String>]
        #   An array of font names. Each name must be the name of an AFM font or
        #   the name that was used to register a family of external fonts (see
        #   {Prawn::Document#font_families}). If present, then each glyph will
        #   be rendered using the first font that includes the glyph, starting
        #   with the current font and then moving through `:fallback_fonts`.
        # @option options :align [:left, :center, :right, :justify]
        #   (:left if direction is :ltr, :right if direction is :rtl)
        #   Alignment within the bounding box.
        # @option options :valign [:top, :center, :bottom] (:top)
        #   Vertical alignment within the bounding box.
        # @option options :rotate [Number]
        #   The angle to rotate the text.
        # @option options :rotate_around
        #   [:center, :upper_left, :upper_right, :lower_right, :lower_left]
        #   (:upper_left)
        #   The point around which to rotate the text.
        # @option options :leading [Number] (value of document.default_leading)
        #   Additional space between lines.
        # @option options :single_line [Boolean] (false)
        #   If true, then only the first line will be drawn.
        # @option options :overflow [:truncate, :shrink_to_fit, :expand]
        #   (:truncate)
        #   This controls the behavior when the amount of text exceeds the
        #   available space.
        # @option options :min_font_size [Number] (5)
        #   The minimum font size to use when `:overflow` is set to
        #   `:shrink_to_fit` (that is the font size will not be reduced to less
        #   than this value, even if it means that some text will be cut off).
        def initialize(formatted_text, options = {})
          @inked = false
          Prawn.verify_options(valid_options, options)
          options = options.dup

          self.class.extensions.reverse_each { |e| extend(e) }

          @overflow = options[:overflow] || :truncate
          @disable_wrap_by_char = options[:disable_wrap_by_char]

          self.original_text = formatted_text
          @text = nil

          @document = options[:document]
          @direction = options[:direction] || @document.text_direction
          @fallback_fonts = options[:fallback_fonts] ||
            @document.fallback_fonts
          @at = (
            options[:at] || [@document.bounds.left, @document.bounds.top]
          ).dup
          @width = options[:width] ||
            (@document.bounds.right - @at[0])
          @height = options[:height] || default_height
          @align = options[:align] ||
            (@direction == :rtl ? :right : :left)
          @vertical_align = options[:valign] || :top
          @leading = options[:leading] || @document.default_leading
          @character_spacing = options[:character_spacing] ||
            @document.character_spacing
          @mode = options[:mode] || @document.text_rendering_mode
          @rotate = options[:rotate] || 0
          @rotate_around = options[:rotate_around] || :upper_left
          @single_line = options[:single_line]
          @draw_text_callback = options[:draw_text_callback]

          # if the text rendering mode is :unknown, force it back to :fill
          if @mode == :unknown
            @mode = :fill
          end

          if @overflow == :expand
            # if set to expand, then we simply set the bottom
            # as the bottom of the document bounds, since that
            # is the maximum we should expand to
            @height = default_height
            @overflow = :truncate
          end
          @min_font_size = options[:min_font_size] || 5
          if options[:kerning].nil?
            options[:kerning] = @document.default_kerning?
          end
          @options = {
            kerning: options[:kerning],
            size: options[:size],
            style: options[:style],
          }

          super(formatted_text, options)
        end

        # Render text to the document based on the settings defined in
        # constructor.
        #
        # In order to facilitate look-ahead calculations, this method accepts
        # a `dry_run: true` option. If provided, then everything is executed as
        # if rendering, with the exception that nothing is drawn on the page.
        # Useful for look-ahead computations of height, unprinted text, etc.
        #
        # @param flags [Hash{Symbol => any}]
        # @option flags :dry_run [Boolean] (false)
        #   Do not draw the text. Everything else is done.
        # @return [Array<Hash>]
        #   A formatted text array representing any text that did not print
        #   under the current settings.
        # @raise [Prawn::Text::Formatted::Arranger::BadFontFamily]
        #   If no font family is defined for the current font.
        # @raise [Prawn::Errors::CannotFit]
        #   If not wide enough to print any text.
        def render(flags = {})
          unprinted_text = []

          @document.save_font do
            @document.character_spacing(@character_spacing) do
              @document.text_rendering_mode(@mode) do
                process_options

                text = normalized_text(flags)

                @document.font_size(@font_size) do
                  shrink_to_fit(text) if @overflow == :shrink_to_fit
                  process_vertical_alignment(text)
                  @inked = true unless flags[:dry_run]
                  unprinted_text =
                    if @rotate != 0 && @inked
                      render_rotated(text)
                    else
                      wrap(text)
                    end
                  @inked = false
                end
              end
            end
          end

          unprinted_text.map do |e|
            e.merge(text: @document.font.to_utf8(e[:text]))
          end
        end

        # The width available at this point in the box.
        #
        # @return [Number]
        def available_width
          @width
        end

        # The height actually used during the previous {render}.
        #
        # @return [Number]
        def height
          return 0 if @baseline_y.nil? || @descender.nil?

          (@baseline_y - @descender).abs
        end

        # @private
        # @param fragment [Prawn::Text::Formatted::Fragment]
        # @param accumulated_width [Number]
        # @param line_width [Number]
        # @param word_spacing [Number]
        # @return [void]
        def draw_fragment(
          fragment, accumulated_width = 0, line_width = 0, word_spacing = 0
        )
          case @align
          when :left
            x = @at[0]
          when :center
            x = @at[0] + (@width * 0.5) - (line_width * 0.5)
          when :right
            x = @at[0] + @width - line_width
          when :justify
            x =
              if @direction == :ltr
                @at[0]
              else
                @at[0] + @width - line_width
              end
          else
            raise ArgumentError,
              'align must be one of :left, :right, :center or :justify symbols'
          end

          x += accumulated_width

          y = @at[1] + @baseline_y

          y += fragment.y_offset

          fragment.left = x
          fragment.baseline = y

          if @inked
            draw_fragment_underlays(fragment)

            @document.word_spacing(word_spacing) do
              if @draw_text_callback
                @draw_text_callback.call(
                  fragment.text,
                  at: [x, y],
                  kerning: @kerning,
                )
              else
                @document.draw_text!(
                  fragment.text,
                  at: [x, y],
                  kerning: @kerning,
                )
              end
            end

            draw_fragment_overlays(fragment)
          end
        end

        # @group Extension API

        # Text box extensions.
        #
        # Example:
        #
        # ```ruby
        # module MyWrap
        #   def wrap(array)
        #     initialize_wrap([{ text: 'all your base are belong to us' }])
        #     @line_wrap.wrap_line(
        #       document: @document,
        #       kerning: @kerning,
        #       width: 10000,
        #       arranger: @arranger
        #     )
        #     fragment = @arranger.retrieve_fragment
        #     format_and_draw_fragment(fragment, 0, @line_wrap.width, 0)
        #     []
        #   end
        # end
        #
        # Prawn::Text::Formatted::Box.extensions << MyWrap
        #
        # box = Prawn::Text::Formatted::Box.new('hello world')
        # box.render("why can't I print anything other than" +
        #            '"all your base are belong to us"?')
        # ```
        #
        # See {Prawn::Text::Formatted::Wrap} for what is required of the
        # wrap method if you want to override the default wrapping algorithm.
        #
        # @return [Array<Module>]
        def self.extensions
          @extensions ||= []
        end

        # @private
        def self.inherited(base)
          super
          extensions.each { |e| base.extensions << e }
        end

        # @private
        def valid_options
          PDF::Core::Text::VALID_OPTIONS + %i[
            at
            height width
            align valign
            rotate rotate_around
            overflow min_font_size
            disable_wrap_by_char
            leading character_spacing
            mode single_line
            document
            direction
            fallback_fonts
            draw_text_callback
          ]
        end

        private

        def normalized_text(flags)
          text = normalize_encoding

          text.each { |t| t.delete(:color) } if flags[:dry_run]

          text
        end

        def original_text
          @original_array.map(&:dup)
        end

        def original_text=(formatted_text)
          @original_array = formatted_text
        end

        def normalize_encoding
          formatted_text = original_text

          unless @fallback_fonts.empty?
            formatted_text = process_fallback_fonts(formatted_text)
          end

          formatted_text.each do |hash|
            if hash[:font]
              @document.font(hash[:font]) do
                hash[:text] = @document.font.normalize_encoding(hash[:text])
              end
            else
              hash[:text] = @document.font.normalize_encoding(hash[:text])
            end
          end

          formatted_text
        end

        def process_fallback_fonts(formatted_text)
          modified_formatted_text = []

          formatted_text.each do |hash|
            fragments = analyze_glyphs_for_fallback_font_support(hash)
            modified_formatted_text.concat(fragments)
          end

          modified_formatted_text
        end

        def analyze_glyphs_for_fallback_font_support(hash)
          font_glyph_pairs = []

          original_font = @document.font.family
          fragment_font = hash[:font] || original_font
          fragment_font_options =
            (fragment_font_style = font_style(hash[:styles])) == :normal ? {} : { style: fragment_font_style }

          fallback_fonts = @fallback_fonts.dup
          # always default back to the current font if the glyph is missing from
          # all fonts
          fallback_fonts << fragment_font

          @document.save_font do
            hash[:text].each_char do |char|
              font_glyph_pairs << [
                find_font_for_this_glyph(
                  char,
                  fragment_font,
                  fallback_fonts.dup,
                  fragment_font_options,
                ),
                char,
              ]
            end
          end

          # Don't add a :font to fragments if it wasn't there originally
          if hash[:font].nil?
            font_glyph_pairs.each do |pair|
              pair[0] = nil if pair[0] == original_font
            end
          end

          form_fragments_from_like_font_glyph_pairs(font_glyph_pairs, hash)
        end

        def font_style(styles)
          if styles
            if styles.include?(:bold)
              styles.include?(:italic) ? :bold_italic : :bold
            elsif styles.include?(:italic)
              :italic
            else
              :normal
            end
          else
            :normal
          end
        end

        def find_font_for_this_glyph(char, current_font, fallback_fonts, current_font_options = {})
          @document.font(current_font, current_font_options)
          if fallback_fonts.empty? || @document.font.glyph_present?(char)
            current_font
          else
            find_font_for_this_glyph(char, fallback_fonts.shift, fallback_fonts, current_font_options)
          end
        end

        def form_fragments_from_like_font_glyph_pairs(font_glyph_pairs, hash)
          fragments = []
          fragment = nil
          current_font = nil

          font_glyph_pairs.each do |font, char|
            if font != current_font || fragments.count.zero?
              current_font = font
              fragment = hash.dup
              fragment[:text] = char
              fragment[:font] = font unless font.nil?
              fragments << fragment
            else
              fragment[:text] += char
            end
          end

          fragments
        end

        def move_baseline_down
          if @baseline_y.zero?
            @baseline_y = -@ascender
          else
            @baseline_y -= (@line_height + @leading)
          end
        end

        # Returns the default height to be used if none is provided or if the
        # overflow option is set to :expand. If we are in a stretchy bounding
        # box, assume we can stretch to the bottom of the innermost non-stretchy
        # box.
        #
        def default_height
          # Find the "frame", the innermost non-stretchy bbox.
          frame = @document.bounds
          frame = frame.parent while frame.stretchy? && frame.parent

          @at[1] + @document.bounds.absolute_bottom - frame.absolute_bottom
        end

        def process_vertical_alignment(text)
          # The vertical alignment must only be done once per text box, but
          # we need to wait until render() is called so that the fonts are set
          # up properly for wrapping. So guard with a boolean to ensure this is
          # only run once.
          if defined?(@vertical_alignment_processed) &&
              @vertical_alignment_processed
            return
          end

          @vertical_alignment_processed = true

          return if @vertical_align == :top

          wrap(text)

          case @vertical_align
          when :center
            @at[1] -= (@height - height + @descender) * 0.5
          when :bottom
            @at[1] -= (@height - height)
          else
            raise ArgumentError,
              'valign must be one of :left, :right or :center symbols'
          end

          @height = height
        end

        # Decrease the font size until the text fits or the min font
        # size is reached
        def shrink_to_fit(text)
          loop do
            if @disable_wrap_by_char && @font_size > @min_font_size
              begin
                wrap(text)
              rescue Errors::CannotFit
                # Ignore errors while we can still attempt smaller
                # font sizes.
              end
            else
              wrap(text)
            end

            break if @everything_printed || @font_size <= @min_font_size

            @font_size = [@font_size - 0.5, @min_font_size].max
            @document.font_size = @font_size
          end
        end

        def process_options
          # must be performed within a save_font block because
          # document.process_text_options sets the font
          @document.process_text_options(@options)
          @font_size = @options[:size]
          @kerning = @options[:kerning]
        end

        def render_rotated(text)
          unprinted_text = ''

          case @rotate_around
          when :center
            x = @at[0] + (@width * 0.5)
            y = @at[1] - (@height * 0.5)
          when :upper_right
            x = @at[0] + @width
            y = @at[1]
          when :lower_right
            x = @at[0] + @width
            y = @at[1] - @height
          when :lower_left
            x = @at[0]
            y = @at[1] - @height
          else
            x = @at[0]
            y = @at[1]
          end

          @document.rotate(@rotate, origin: [x, y]) do
            unprinted_text = wrap(text)
          end
          unprinted_text
        end

        def draw_fragment_underlays(fragment)
          fragment.callback_objects.each do |obj|
            obj.render_behind(fragment) if obj.respond_to?(:render_behind)
          end
        end

        def draw_fragment_overlays(fragment)
          draw_fragment_overlay_styles(fragment)
          draw_fragment_overlay_link(fragment)
          draw_fragment_overlay_anchor(fragment)
          draw_fragment_overlay_local(fragment)
          fragment.callback_objects.each do |obj|
            obj.render_in_front(fragment) if obj.respond_to?(:render_in_front)
          end
        end

        def draw_fragment_overlay_link(fragment)
          return unless fragment.link

          box = fragment.absolute_bounding_box
          @document.link_annotation(
            box,
            Border: [0, 0, 0],
            A: {
              Type: :Action,
              S: :URI,
              URI: PDF::Core::LiteralString.new(fragment.link),
            },
          )
        end

        def draw_fragment_overlay_anchor(fragment)
          return unless fragment.anchor

          box = fragment.absolute_bounding_box
          @document.link_annotation(
            box,
            Border: [0, 0, 0],
            Dest: fragment.anchor,
          )
        end

        def draw_fragment_overlay_local(fragment)
          return unless fragment.local

          box = fragment.absolute_bounding_box
          @document.link_annotation(
            box,
            Border: [0, 0, 0],
            A: {
              Type: :Action,
              S: :Launch,
              F: PDF::Core::LiteralString.new(fragment.local),
              NewWindow: true,
            },
          )
        end

        def draw_fragment_overlay_styles(fragment)
          if fragment.styles.include?(:underline)
            @document.stroke_line(fragment.underline_points)
          end

          if fragment.styles.include?(:strikethrough)
            @document.stroke_line(fragment.strikethrough_points)
          end
        end
      end
    end
  end
end