prawnpdf/prawn

View on GitHub
lib/prawn/grid.rb

Summary

Maintainability
A
0 mins
Test Coverage
# frozen_string_literal: true

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

    # Defines the grid system for a particular document. Takes the number of
    # rows and columns and the width to use for the gutter as the
    # keys :rows, :columns, :gutter, :row_gutter, :column_gutter
    #
    # @note A completely new grid object is built each time `define_grid`
    #   is called. This means that all subsequent calls to grid() will use
    #   the newly defined Grid object -- grids are not nestable like
    #   bounding boxes are.
    #
    # @param options [Hash{Symbol => any}]
    # @option options :columns [Integer] Number of columns in the grid.
    # @option options :rows [Integer] Number of rows in the grid.
    # @option options :gutter [Number] Gutter size. `:row_gutter` and
    #   `:column_gutter` are ignored if specified.
    # @option options :row_gutter [Number] Row gutter size.
    # @option options :column_gutter [Number] Column gutter size.
    # @return [Grid]
    def define_grid(options = {})
      @boxes = nil
      @grid = Grid.new(self, options)
    end

    # A method that can either be used to access a particular grid on the page
    # or work with the grid system directly.
    #
    # @overload grid
    #   Get current grid.
    #
    #   @return [Grid]
    #
    # @overload grid(row, column)
    #   Get a grid box.
    #
    #   @param row [Integer]
    #   @param column [Integer]
    #   @return [GridBox]
    #
    # @overload grid(box1, box2)
    #   Get a grid multi-box.
    #
    #   @param box1 [Array(Integer, Integer)] Start box coordinates.
    #   @param box2 [Array(Integer, Integer)] End box coordinates.
    #   @return [MultiBox]
    def grid(*args)
      @boxes ||= {}
      @boxes[args] ||=
        if args.empty?
          @grid
        else
          g1, g2 = args

          if g1.is_a?(Array) && g2.is_a?(Array) &&
              g1.length == 2 && g2.length == 2
            multi_box(single_box(*g1), single_box(*g2))
          else
            single_box(g1, g2)
          end
        end
    end

    # A Grid represents the entire grid system of a Page and calculates
    # the column width and row height of the base box.
    #
    # @group Experimental API
    class Grid
      # @private
      # @return [Prawn::Document]
      attr_reader :pdf

      # Number of columns in the grid.
      # @return [Integer]
      attr_reader :columns

      # Number of rows in the grid.
      # @return [Integer]
      attr_reader :rows

      # Gutter size.
      # @return [Number]
      attr_reader :gutter

      # Row gutter size.
      # @return [Number]
      attr_reader :row_gutter

      # Column gutter size.
      # @return [Number]
      attr_reader :column_gutter

      # @param pdf [Prawn::Document]
      # @param options [Hash{Symbol => any}]
      # @option options :columns [Integer] Number of columns in the grid.
      # @option options :rows [Integer] Number of rows in the grid.
      # @option options :gutter [Number] Gutter size. `:row_gutter` and
      #   `:column_gutter` are ignored if specified.
      # @option options :row_gutter [Number] Row gutter size.
      # @option options :column_gutter [Number] Column gutter size.
      def initialize(pdf, options = {})
        valid_options = %i[columns rows gutter row_gutter column_gutter]
        Prawn.verify_options(valid_options, options)

        @pdf = pdf
        @columns = options[:columns]
        @rows = options[:rows]
        apply_gutter(options)
      end

      # Calculates the base width of boxes.
      #
      # @return [Float]
      def column_width
        @column_width ||= subdivide(pdf.bounds.width, columns, column_gutter)
      end

      # Calculates the base height of boxes.
      #
      # @return [Float]
      def row_height
        @row_height ||= subdivide(pdf.bounds.height, rows, row_gutter)
      end

      # Diagnostic tool to show all of the grid boxes.
      #
      # @param color [Color]
      # @return [void]
      def show_all(color = 'CCCCCC')
        rows.times do |row|
          columns.times do |column|
            pdf.grid(row, column).show(color)
          end
        end
      end

      private

      def subdivide(total, num, gutter)
        (Float(total) - (gutter * Float((num - 1)))) / Float(num)
      end

      def apply_gutter(options)
        if options.key?(:gutter)
          @gutter = Float(options[:gutter])
          @row_gutter = @gutter
          @column_gutter = @gutter
        else
          @row_gutter = Float(options[:row_gutter])
          @column_gutter = Float(options[:column_gutter])
          @gutter = 0
        end
      end
    end

    # A Box is a class that represents a bounded area of a page.
    # A Grid object has methods that allow easy access to the coordinates of
    # its corners, which can be plugged into most existing Prawn methods.
    #
    # @group Experimental API
    class GridBox
      # @private
      attr_reader :pdf

      def initialize(pdf, rows, columns)
        @pdf = pdf
        @rows = rows
        @columns = columns
      end

      # Mostly diagnostic method that outputs the name of a box as
      # col_num, row_num
      #
      # @return [String]
      def name
        "#{@rows},#{@columns}"
      end

      # @private
      def total_height
        Float(pdf.bounds.height)
      end

      # Width of a box.
      #
      # @return [Float]
      def width
        Float(grid.column_width)
      end

      # Height of a box.
      #
      # @return [Float]
      def height
        Float(grid.row_height)
      end

      # Width of the gutter.
      #
      # @return [Float]
      def gutter
        Float(grid.gutter)
      end

      # x-coordinate of left side.
      #
      # @return [Float]
      def left
        @left ||= (width + grid.column_gutter) * Float(@columns)
      end

      # x-coordinate of right side.
      #
      # @return [Float]
      def right
        @right ||= left + width
      end

      # y-coordinate of the top.
      #
      # @return [Float]
      def top
        @top ||= total_height - ((height + grid.row_gutter) * Float(@rows))
      end

      # y-coordinate of the bottom.
      #
      # @return [Float]
      def bottom
        @bottom ||= top - height
      end

      # x,y coordinates of top left corner.
      #
      # @return [Array(Float, Float)]
      def top_left
        [left, top]
      end

      # x,y coordinates of top right corner.
      #
      # @return [Array(Float, Float)]
      def top_right
        [right, top]
      end

      # x,y coordinates of bottom left corner.
      #
      # @return [Array(Float, Float)]
      def bottom_left
        [left, bottom]
      end

      # x,y coordinates of bottom right corner.
      #
      # @return [Array(Float, Float)]
      def bottom_right
        [right, bottom]
      end

      # Creates a standard bounding box based on the grid box.
      #
      # @yield
      # @return [void]
      def bounding_box(&blk)
        pdf.bounding_box(top_left, width: width, height: height, &blk)
      end

      # Drawn the box. Diagnostic method.
      #
      # @param grid_color [Color]
      # @return [void]
      def show(grid_color = 'CCCCCC')
        bounding_box do
          original_stroke_color = pdf.stroke_color

          pdf.stroke_color = grid_color
          pdf.text(name)
          pdf.stroke_bounds

          pdf.stroke_color = original_stroke_color
        end
      end

      private

      def grid
        pdf.grid
      end
    end

    # A MultiBox is specified by 2 Boxes and spans the areas between.
    #
    # @group Experimental API
    class MultiBox
      def initialize(pdf, box1, box2)
        @pdf = pdf
        @boxes = [box1, box2]
      end

      # @private
      attr_reader :pdf

      # Mostly diagnostic method that outputs the name of a box.
      #
      # @return [String]
      def name
        @boxes.map(&:name).join(':')
      end

      # @private
      def total_height
        @boxes[0].total_height
      end

      # Width of a box.
      #
      # @return [Float]
      def width
        right_box.right - left_box.left
      end

      # Height of a box.
      #
      # @return [Float]
      def height
        top_box.top - bottom_box.bottom
      end

      # Width of the gutter.
      #
      # @return [Float]
      def gutter
        @boxes[0].gutter
      end

      # x-coordinate of left side.
      #
      # @return [Float]
      def left
        left_box.left
      end

      # x-coordinate of right side.
      #
      # @return [Float]
      def right
        right_box.right
      end

      # y-coordinate of the top.
      #
      # @return [Float]
      def top
        top_box.top
      end

      # y-coordinate of the bottom.
      #
      # @return [Float]
      def bottom
        bottom_box.bottom
      end

      # x,y coordinates of top left corner.
      #
      # @return [Array(Float, Float)]
      def top_left
        [left, top]
      end

      # x,y coordinates of top right corner.
      #
      # @return [Array(Float, Float)]
      def top_right
        [right, top]
      end

      # x,y coordinates of bottom left corner.
      #
      # @return [Array(Float, Float)]
      def bottom_left
        [left, bottom]
      end

      # x,y coordinates of bottom right corner.
      #
      # @return [Array(Float, Float)]
      def bottom_right
        [right, bottom]
      end

      # Creates a standard bounding box based on the grid box.
      #
      # @yield
      # @return [void]
      def bounding_box(&blk)
        pdf.bounding_box(top_left, width: width, height: height, &blk)
      end

      # Drawn the box. Diagnostic method.
      #
      # @param grid_color [Color]
      # @return [void]
      def show(grid_color = 'CCCCCC')
        bounding_box do
          original_stroke_color = pdf.stroke_color

          pdf.stroke_color = grid_color
          pdf.text(name)
          pdf.stroke_bounds

          pdf.stroke_color = original_stroke_color
        end
      end

      private

      def left_box
        @left_box ||= @boxes.min_by(&:left)
      end

      def right_box
        @right_box ||= @boxes.max_by(&:right)
      end

      def top_box
        @top_box ||= @boxes.max_by(&:top)
      end

      def bottom_box
        @bottom_box ||= @boxes.min_by(&:bottom)
      end
    end

    private

    def single_box(rows, columns)
      GridBox.new(self, rows, columns)
    end

    def multi_box(box1, box2)
      MultiBox.new(self, box1, box2)
    end
  end
end