prawnpdf/prawn

View on GitHub
lib/prawn/document/bounding_box.rb

Summary

Maintainability
B
4 hrs
Test Coverage
# frozen_string_literal: true

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

    # A bounding box serves two important purposes:
    # * Provide bounds for flowing text, starting at a given point
    # * Translate the origin (0, 0) for graphics primitives
    #
    # #### Positioning
    #
    # Bounding boxes are positioned relative to their top left corner and
    # the width measurement is towards the right and height measurement is
    # downwards.
    #
    # Usage:
    #
    # * Bounding box 100pt×100pt in the absolute bottom left of the
    #   containing box:
    #
    #   ```ruby
    #   pdf.bounding_box([0, 100], width: 100, height: 100)
    #     stroke_bounds
    #   end
    #   ```
    #
    # * Bounding box 200pt×400pt high in the center of the page:
    #
    #   ```ruby
    #   x_pos = ((bounds.width / 2) - 150)
    #   y_pos = ((bounds.height / 2) + 200)
    #   pdf.bounding_box([x_pos, y_pos], width: 300, height: 400) do
    #     stroke_bounds
    #   end
    #   ```
    #
    # #### Flowing Text
    #
    # When flowing text, the usage of a bounding box is simple. Text will
    # begin at the point specified, flowing the width of the bounding box.
    # After the block exits, the cursor position will be moved to
    # the bottom of the bounding box (y - height). If flowing text exceeds
    # the height of the bounding box, the text will be continued on the next
    # page, starting again at the top-left corner of the bounding box.
    #
    # Usage:
    #
    # ```ruby
    # pdf.bounding_box([100, 500], width: 100, height: 300) do
    #   pdf.text "This text will flow in a very narrow box starting" +
    #    "from [100, 500]. The pointer will then be moved to [100, 200]" +
    #    "and return to the margin_box"
    # end
    # ```
    #
    # Note, this is a low level tool and is designed primarily for building
    # other abstractions. If you just need to flow text on the page, you
    # will want to look at {span} and {text_box} instead.
    #
    # #### Translating Coordinates
    #
    # When translating coordinates, the idea is to allow the user to draw
    # relative to the origin, and then translate their drawing to a specified
    # area of the document, rather than adjust all their drawing coordinates
    # to match this new region.
    #
    # Take for example two triangles which share one point, drawn from the
    # origin:
    #
    # ```ruby
    # pdf.polygon [0, 250], [0, 0], [150, 100]
    # pdf.polygon [100, 0], [150, 100], [200, 0]
    # ```
    #
    # It would be easy enough to translate these triangles to another point,
    # e.g `[200, 200]`
    #
    # ```ruby
    # pdf.polygon [200, 450], [200, 200], [350, 300]
    # pdf.polygon [300, 200], [350, 300], [400, 200]
    # ```
    #
    # However, each time you want to move the drawing, you'd need to alter
    # every point in the drawing calls, which as you might imagine, can become
    # tedious.
    #
    # If instead, we think of the drawing as being bounded by a box, we can
    # see that the image is 200 points wide by 250 points tall.
    #
    # To translate it to a new origin, we simply select a point at
    # (x, y + height).
    #
    # Using the [200, 200] example:
    #
    # ```ruby
    # pdf.bounding_box([200, 450], width: 200, height: 250) do
    #   pdf.stroke do
    #     pdf.polygon [0, 250], [0, 0], [150, 100]
    #     pdf.polygon [100, 0], [150, 100], [200, 0]
    #   end
    # end
    # ```
    #
    # Notice that the drawing is still relative to the origin. If we want to
    # move this drawing around the document, we simply need to recalculate the
    # top-left corner of the rectangular bounding-box, and all of our graphics
    # calls remain unmodified.
    #
    # #### Nesting Bounding Boxes
    #
    # At the top level, bounding boxes are specified relative to the document's
    # margin_box (which is itself a bounding box). You can also nest bounding
    # boxes, allowing you to build components which are relative to each other
    #
    # Usage:
    #
    # ```ruby
    # pdf.bounding_box([200, 450], width: 200, height: 250) do
    #   pdf.stroke_bounds   # Show the containing bounding box
    #   pdf.bounding_box([50, 200], width: 50, height: 50) do
    #     # a 50x50 bounding box that starts 50 pixels left and 50 pixels down
    #     # the parent bounding box.
    #     pdf.stroke_bounds
    #   end
    # end
    # ```
    #
    # #### Stretchiness
    #
    # If you do not specify a height to a bounding box, it will become stretchy
    # and its height will be calculated automatically as you stretch the box
    # downwards.
    #
    #  pdf.bounding_box([100, 400], width: 400) do
    #    pdf.text("The height of this box is #{pdf.bounds.height}")
    #    pdf.text('this is some text')
    #    pdf.text('this is some more text')
    #    pdf.text('and finally a bit more')
    #    pdf.text("Now the height of this box is #{pdf.bounds.height}")
    #  end
    #
    # #### Absolute Positioning
    #
    # If you wish to position the bounding boxes at absolute coordinates rather
    # than relative to the margins or other bounding boxes, you can use canvas()
    #
    # ```ruby
    # pdf.bounding_box([50, 500], width: 200, height: 300) do
    #   pdf.stroke_bounds
    #   pdf.canvas do
    #     Positioned outside the containing box at the 'real' (300, 450)
    #     pdf.bounding_box([300, 450], width: 200, height: 200) do
    #       pdf.stroke_bounds
    #     end
    #   end
    # end
    # ```
    #
    # Of course, if you use canvas, you will be responsible for ensuring that
    # you remain within the printable area of your document.
    #
    # @overload bounding_box(point, options = {}, &block)
    #   @param point [Array(Number, Number)]
    #     top left corner of the new bounding box
    #   @param options [Hash{Symbol => any}]
    #   @option options :width [Number]
    #     width of the new bounding box, must be specified.
    #   @option options :height [Number]
    #     height of the new bounding box, stretchy box if omitted.
    #   @yieldparam parent_box [BoundingBox] parent bounding box
    #   @yieldreturn [void]
    #   @return [void]
    def bounding_box(point, *args, &block)
      init_bounding_box(block) do |parent_box|
        point = map_to_absolute(point)
        @bounding_box = BoundingBox.new(self, parent_box, point, *args)
      end
    end

    # A shortcut to produce a bounding box which is mapped to the document's
    # absolute coordinates, regardless of how things are nested or margin sizes.
    #
    # @example
    #   pdf.canvas do
    #     pdf.line pdf.bounds.bottom_left, pdf.bounds.top_right
    #   end
    #
    # @yieldreturn [void]
    # @return [void]
    def canvas(&block)
      init_bounding_box(block, hold_position: true) do |_|
        # Canvas bbox acts like margin_box in that its parent bounds are unset.
        @bounding_box = BoundingBox.new(
          self,
          nil,
          [0, page.dimensions[3]],
          width: page.dimensions[2],
          height: page.dimensions[3],
        )
      end
    end

    private

    def init_bounding_box(user_block, options = {})
      unless user_block
        raise ArgumentError,
          'bounding boxes require a block to be drawn within the box'
      end

      parent_box = @bounding_box

      original_ypos = y

      yield(parent_box)

      self.y = @bounding_box.absolute_top
      user_block.call

      # If the user actions did not modify the y position
      # restore the original y position before the bounding
      # box was created.

      if y == @bounding_box.absolute_top
        self.y = original_ypos
      end

      unless options[:hold_position] || @bounding_box.stretchy?
        self.y = @bounding_box.absolute_bottom
      end

      created_box = @bounding_box
      @bounding_box = parent_box

      created_box
    end

    # Low level layout helper that simplifies coordinate math.
    #
    # See {Prawn::Document#bounding_box} for a description of what this class
    # is used for.
    class BoundingBox
      # Indicates absence of a reference bounding box of a fixed height.
      class NoReferenceBounds < StandardError
        def initialize(message = "Can't find reference bounds: my parent is unset")
          super
        end
      end

      # @private
      # @param document [Prawn::Document] ownding document
      # @param parent [BoundingBox?] parent bounding box
      # @param point [Array(Number, Number)] coordinates of the top left corner
      # @param options [Hash{Symbol => any}]
      # @option options :width [Number] width
      # @option options :height [Number] optional height
      def initialize(document, parent, point, options = {})
        unless options[:width]
          raise ArgumentError, 'BoundingBox needs the :width option to be set'
        end

        @document = document
        @parent = parent
        @x, @y = point
        @width = options[:width]
        @height = options[:height]
        @total_left_padding = 0
        @total_right_padding = 0
        @stretched_height = nil
      end

      # Owning document.
      #
      # @private
      # @return [Prawn::Document]
      attr_reader :document

      # Parent bounding box.
      #
      # @private
      # @return [BoundingBox?]
      attr_reader :parent

      # The current indentation of the left side of the bounding box.
      #
      # @private
      # @return [Number]
      attr_reader :total_left_padding

      # The current indentation of the right side of the bounding box.
      #
      # @private
      # @return [Number]
      attr_reader :total_right_padding

      # The translated origin (x, y - height) which describes the location of
      # the bottom left corner of the bounding box.
      #
      # @private
      # @return [Array(Number, Number)]
      def anchor
        [@x, @y - height]
      end

      # Relative left x-coordinate of the bounding box. Always 0.
      #
      # @example Position some text 3 pts from the left of the containing box
      #   draw_text('hello', at: [(bounds.left + 3), 0])
      #
      # @return [Number]
      def left
        0
      end

      # Temporarily adjust the x coordinate to allow for left padding
      #
      # @example
      #   indent 20 do
      #     text "20 points in"
      #     indent 30 do
      #       text "50 points in"
      #     end
      #   end
      #
      #   indent 20, 20 do
      #     text "indented on both sides"
      #   end
      #
      # @private
      # @param left_padding [Number]
      # @param right_padding [Number]
      # @return [void]
      def indent(left_padding, right_padding = 0)
        add_left_padding(left_padding)
        add_right_padding(right_padding)
        yield
      ensure
        @document.bounds.subtract_left_padding(left_padding)
        @document.bounds.subtract_right_padding(right_padding)
      end

      # Increase the left padding of the bounding box.
      #
      # @private
      # @param left_padding [Number]
      # @return [void]
      def add_left_padding(left_padding)
        @total_left_padding += left_padding
        @x += left_padding
        @width -= left_padding
      end

      # Decrease the left padding of the bounding box.
      #
      # @private
      # @param left_padding [Number]
      # @return [void]
      def subtract_left_padding(left_padding)
        @total_left_padding -= left_padding
        @x -= left_padding
        @width += left_padding
      end

      # Increase the right padding of the bounding box.
      #
      # @private
      # @param right_padding [Number]
      # @return [void]
      def add_right_padding(right_padding)
        @total_right_padding += right_padding
        @width -= right_padding
      end

      # Decrease the right padding of the bounding box.
      #
      # @private
      # @param right_padding [Number]
      # @return [void]
      def subtract_right_padding(right_padding)
        @total_right_padding -= right_padding
        @width += right_padding
      end

      # Relative right x-coordinate of the bounding box. Equal to the box width.
      #
      # @example Position some text 3 pts from the right of the containing box
      #   draw_text('hello', at: [(bounds.right - 3), 0])
      #
      # @return [Number]
      def right
        @width
      end

      # Relative top y-coordinate of the bounding box. Equal to the box height.
      #
      # @example Position some text 3 pts from the top of the containing box
      #   draw_text('hello', at: [0, (bounds.top - 3)])
      #
      # @return [Number]
      def top
        height
      end

      # Relative bottom y-coordinate of the bounding box. Always 0.
      #
      # @example Position some text 3 pts from the bottom of the containing box
      #   draw_text('hello', at: [0, (bounds.bottom + 3)])
      #
      # @return [Number]
      def bottom
        0
      end

      # Relative top-left point of the bounding_box.
      #
      # @example Draw a line from the top left of the box diagonally to the bottom right
      #   stroke do
      #     line(bounds.top_left, bounds.bottom_right)
      #   end
      #
      # @return [Array(Number, Number)]
      def top_left
        [left, top]
      end

      # Relative top-right point of the bounding box.
      #
      # @example Draw a line from the top_right of the box diagonally to the bottom left
      #   stroke do
      #     line(bounds.top_right, bounds.bottom_left)
      #   end
      #
      # @return [Array(Number, Number)]
      def top_right
        [right, top]
      end

      # Relative bottom-right point of the bounding box.
      #
      # @example Draw a line along the right hand side of the page
      #   stroke do
      #     line(bounds.bottom_right, bounds.top_right)
      #   end
      #
      # @return [Array(Number, Number)]
      def bottom_right
        [right, bottom]
      end

      # Relative bottom-left point of the bounding box.
      #
      # @example Draw a line along the left hand side of the page
      #   stroke do
      #     line(bounds.bottom_left, bounds.top_left)
      #   end
      #
      # @return [Array(Number, Number)]
      def bottom_left
        [left, bottom]
      end

      # Absolute left x-coordinate of the bounding box.
      #
      # @return [Number]
      def absolute_left
        @x
      end

      # Absolute right x-coordinate of the bounding box.
      #
      # @return [Number]
      def absolute_right
        @x + width
      end

      # Absolute top y-coordinate of the bounding box.
      #
      # @return [Number]
      def absolute_top
        @y
      end

      # Absolute bottom y-coordinate of the bottom box.
      #
      # @return [Number]
      def absolute_bottom
        @y - height
      end

      # Absolute top-left point of the bounding box.
      #
      # @return [Array(Number, Number)]
      def absolute_top_left
        [absolute_left, absolute_top]
      end

      # Absolute top-right point of the bounding box.
      #
      # @return [Array(Number, Number)]
      def absolute_top_right
        [absolute_right, absolute_top]
      end

      # Absolute bottom-left point of the bounding box.
      #
      # @return [Array(Number, Number)]
      def absolute_bottom_left
        [absolute_left, absolute_bottom]
      end

      # Absolute bottom-left point of the bounding box.
      #
      # @return [Array(Number, Number)]
      def absolute_bottom_right
        [absolute_right, absolute_bottom]
      end

      # Width of the bounding box.
      #
      # @return [Number]
      attr_reader :width

      # Height of the bounding box. If the box is 'stretchy' (unspecified
      # height attribute), height is calculated as the distance from the top of
      # the box to the current drawing position.
      #
      # @return [Number]
      def height
        return @height if @height

        @stretched_height = [
          (absolute_top - @document.y),
          Float(@stretched_height || 0.0),
        ].max
      end

      # An alias for {absolute_left}.
      #
      # @private
      # @return [Number]
      def left_side
        absolute_left
      end

      # An alias for {absolute_right}.
      #
      # @private
      # @return [Number]
      def right_side
        absolute_right
      end

      # @group Extension API

      # Moves to the top of the next page of the document, starting a new page
      # if necessary.
      #
      # @return [void]
      def move_past_bottom
        if @document.page_number == @document.page_count
          @document.start_new_page
        else
          @document.go_to_page(@document.page_number + 1)
        end
      end

      # Returns `false` when the box has a defined height, `true` when the
      # height is being calculated on the fly based on the current vertical
      # position.
      #
      # @return [Boolean]
      def stretchy?
        !@height
      end

      # Returns the innermost non-stretchy bounding box.
      #
      # @return [BoundingBox]
      # @raise [NoReferenceBounds]
      def reference_bounds
        if stretchy?
          raise NoReferenceBounds unless @parent

          @parent.reference_bounds
        else
          self
        end
      end

      alias update_height height

      # Returns a deep copy of these bounds (including all parent bounds but
      # not copying the reference to the Document).
      #
      # @private
      # @return [BoundingBox]
      def deep_copy
        copy = dup
        # Deep-copy the parent bounds
        copy.instance_variable_set(
          :@parent,
          if @parent.is_a?(BoundingBox)
            @parent.deep_copy
          end,
        )
        copy.instance_variable_set(:@document, nil)
        copy
      end

      # Restores a copy of the bounds taken by {BoundingBox#deep_copy} in the
      # context of the given `document`. Does *not* set the bounds of the
      # document to the resulting {BoundingBox}, only returns it.
      #
      # @private
      # @param bounds [BoundingBox]
      # @param document [Prawn::Document]
      # @return [BoundingBox]
      def self.restore_deep_copy(bounds, document)
        bounds.instance_variable_set(:@document, document)
        bounds
      end
    end
  end
end