# encoding: utf-8
# cell.rb: Table cell drawing.
#
# Copyright December 2009, Gregory Brown and Brad Ediger. All Rights Reserved.
#
# This is free software. Please see the LICENSE and COPYING files for details.
require 'date'
module Prawn
class Document
# @group Experimental API
# Instantiates and draws a cell on the document.
#
# cell(:content => "Hello world!", :at => [12, 34])
#
# See Prawn::Table::Cell.make for full options.
#
def cell(options={})
cell = Table::Cell.make(self, options.delete(:content), options)
cell.draw
cell
end
# Set up, but do not draw, a cell. Useful for creating cells with
# formatting options to be inserted into a Table. Call +draw+ on the
# resulting Cell to ink it.
#
# See the documentation on Prawn::Cell for details on the arguments.
#
def make_cell(content, options={})
Prawn::Table::Cell.make(self, content, options)
end
end
class Table
# A Cell is a rectangular area of the page into which content is drawn. It
# has a framework for sizing itself and adding padding and simple styling.
# There are several standard Cell subclasses that handle things like text,
# Tables, and (in the future) stamps, images, and arbitrary content.
#
# Cells are a basic building block for table support (see Prawn::Table).
#
# Please subclass me if you want new content types! I'm designed to be very
# extensible. See the different standard Cell subclasses in
# lib/prawn/table/cell/*.rb for a template.
#
class Cell
# Amount of dead space (in PDF points) inside the borders but outside the
# content. Padding defaults to 5pt.
#
attr_reader :padding
# If provided, the minimum width that this cell in its column will permit.
#
def min_width_ignoring_span
set_width_constraints
@min_width
end
# Minimum width of the entire span group this cell controls.
#
def min_width
return min_width_ignoring_span if @colspan == 1
# Sum up the largest min-width from each column, including myself.
min_widths = Hash.new(0)
dummy_cells.each do |cell|
min_widths[cell.column] =
[min_widths[cell.column], cell.min_width].max
end
min_widths[column] = [min_widths[column], min_width_ignoring_span].max
min_widths.values.inject(0, &:+)
end
# Min-width of the span divided by the number of columns.
#
def avg_spanned_min_width
min_width.to_f / colspan
end
# If provided, the maximum width that this cell can be drawn in, within
# its column.
#
def max_width_ignoring_span
set_width_constraints
@max_width
end
# Maximum width of the entire span group this cell controls.
#
def max_width
return max_width_ignoring_span if @colspan == 1
# Sum the smallest max-width from each column in the group, including
# myself.
max_widths = Hash.new(0)
dummy_cells.each do |cell|
max_widths[cell.column] =
[max_widths[cell.column], cell.max_width].min
end
max_widths[column] = [max_widths[column], max_width_ignoring_span].min
max_widths.values.inject(0, &:+)
end
# Manually specify the cell's height.
#
attr_writer :height
# Specifies which borders to enable. Must be an array of zero or more of:
# [:left, :right, :top, :bottom].
#
attr_accessor :borders
# Width, in PDF points, of the cell's borders: [top, right, bottom, left].
#
attr_reader :border_widths
# HTML RGB-format ("ccffff") border colors: [top, right, bottom, left].
#
attr_reader :border_colors
# Line style
#
attr_reader :border_lines
# Specifies the content for the cell. Must be a "cellable" object. See the
# "Data" section of the Prawn::Table documentation for details on cellable
# objects.
#
attr_accessor :content
# The background color, if any, for this cell. Specified in HTML RGB
# format, e.g., "ccffff". The background is drawn under the whole cell,
# including any padding.
#
attr_accessor :background_color
# Number of columns this cell spans. Defaults to 1.
#
attr_reader :colspan
# Number of rows this cell spans. Defaults to 1.
#
attr_reader :rowspan
# Array of SpanDummy cells (if any) that represent the other cells in
# this span group. They know their own width / height, but do not draw
# anything.
#
# @private
attr_reader :dummy_cells
# Instantiates a Cell based on the given options. The particular class of
# cell returned depends on the :content argument. See the Prawn::Table
# documentation under "Data" for allowable content types.
#
def self.make(pdf, content, options={})
at = options.delete(:at) || [0, pdf.cursor]
content = content.to_s if content.nil? || content.kind_of?(Numeric) ||
content.kind_of?(Date)
if content.is_a?(Hash)
if content[:image]
return Cell::Image.new(pdf, at, content)
end
options.update(content)
content = options[:content]
else
options[:content] = content
end
options[:content] = content = "" if content.nil?
case content
when Prawn::Table::Cell
content
when String
Cell::Text.new(pdf, at, options)
when Prawn::Table
Cell::Subtable.new(pdf, at, options)
when Array
subtable = Prawn::Table.new(options[:content], pdf, {})
Cell::Subtable.new(pdf, at, options.merge(:content => subtable))
else
raise Errors::UnrecognizedTableContent
end
end
# A small amount added to the bounding box width to cover over floating-
# point errors when round-tripping from content_width to width and back.
# This does not change cell positioning; it only slightly expands each
# cell's bounding box width so that rounding error does not prevent a cell
# from rendering.
#
FPTolerance = 1
# Sets up a cell on the document +pdf+, at the given x/y location +point+,
# with the given +options+. Cell, like Table, follows the "options set
# accessors" paradigm (see "Options" under the Table documentation), so
# any cell accessor cell.foo = :bar can be set by providing the
# option :foo => :bar here.
#
def initialize(pdf, point, options={})
@pdf = pdf
@point = point
# Set defaults; these can be changed by options
@padding = [5, 5, 5, 5]
@borders = [:top, :bottom, :left, :right]
@border_widths = [1] * 4
@border_colors = ['000000'] * 4
@border_lines = [:solid] * 4
@colspan = 1
@rowspan = 1
@dummy_cells = []
options.each { |k, v| send("#{k}=", v) }
@initializer_run = true
end
# Supports setting multiple properties at once.
#
# cell.style(:padding => 0, :border_width => 2)
#
# is the same as:
#
# cell.padding = 0
# cell.border_width = 2
#
def style(options={}, &block)
options.each do |k, v|
send("#{k}=", v) if respond_to?("#{k}=")
end
# The block form supports running a single block for multiple cells, as
# in Cells#style.
block.call(self) if block
end
# Returns the width of the cell in its first column alone, ignoring any
# colspans.
#
def width_ignoring_span
# We can't ||= here because the FP error accumulates on the round-trip
# from #content_width.
defined?(@width) && @width || (content_width + padding_left + padding_right)
end
# Returns the cell's width in points, inclusive of padding. If the cell is
# the master cell of a colspan, returns the width of the entire span
# group.
#
def width
return width_ignoring_span if @colspan == 1 && @rowspan == 1
# We're in a span group; get the maximum width per column (including
# the master cell) and sum each column.
column_widths = Hash.new(0)
dummy_cells.each do |cell|
column_widths[cell.column] =
[column_widths[cell.column], cell.width].max
end
column_widths[column] = [column_widths[column], width_ignoring_span].max
column_widths.values.inject(0, &:+)
end
# Manually sets the cell's width, inclusive of padding.
#
def width=(w)
@width = @min_width = @max_width = w
end
# Returns the width of the bare content in the cell, excluding padding.
#
def content_width
if defined?(@width) && @width # manually set
return @width - padding_left - padding_right
end
natural_content_width
end
# Width of the entire span group.
#
def spanned_content_width
width - padding_left - padding_right
end
# Returns the width this cell would naturally take on, absent other
# constraints. Must be implemented in subclasses.
#
def natural_content_width
raise NotImplementedError,
"subclasses must implement natural_content_width"
end
# Returns the cell's height in points, inclusive of padding, in its first
# row only.
#
def height_ignoring_span
# We can't ||= here because the FP error accumulates on the round-trip
# from #content_height.
defined?(@height) && @height || (content_height + padding_top + padding_bottom)
end
# Returns the cell's height in points, inclusive of padding. If the cell
# is the master cell of a rowspan, returns the width of the entire span
# group.
#
def height
return height_ignoring_span if @colspan == 1 && @rowspan == 1
# We're in a span group; get the maximum height per row (including the
# master cell) and sum each row.
row_heights = Hash.new(0)
dummy_cells.each do |cell|
row_heights[cell.row] = [row_heights[cell.row], cell.height].max
end
row_heights[row] = [row_heights[row], height_ignoring_span].max
row_heights.values.inject(0, &:+)
end
# Returns the height of the bare content in the cell, excluding padding.
#
def content_height
if defined?(@height) && @height # manually set
return @height - padding_top - padding_bottom
end
natural_content_height
end
# Height of the entire span group.
#
def spanned_content_height
height - padding_top - padding_bottom
end
# Returns the height this cell would naturally take on, absent
# constraints. Must be implemented in subclasses.
#
def natural_content_height
raise NotImplementedError,
"subclasses must implement natural_content_height"
end
# Indicates the number of columns that this cell is to span. Defaults to
# 1.
#
# This must be provided as part of the table data, like so:
#
# pdf.table([["foo", {:content => "bar", :colspan => 2}]])
#
# Setting colspan from the initializer block is invalid because layout
# has already run. For example, this will NOT work:
#
# pdf.table([["foo", "bar"]]) { cells[0, 1].colspan = 2 }
#
def colspan=(span)
if defined?(@initializer_run) && @initializer_run
raise Prawn::Errors::InvalidTableSpan,
"colspan must be provided in the table's structure, never in the " +
"initialization block. See Prawn's documentation for details."
end
@colspan = span
end
# Indicates the number of rows that this cell is to span. Defaults to 1.
#
# This must be provided as part of the table data, like so:
#
# pdf.table([["foo", {:content => "bar", :rowspan => 2}], ["baz"]])
#
# Setting rowspan from the initializer block is invalid because layout
# has already run. For example, this will NOT work:
#
# pdf.table([["foo", "bar"], ["baz"]]) { cells[0, 1].rowspan = 2 }
#
def rowspan=(span)
if defined?(@initializer_run) && @initializer_run
raise Prawn::Errors::InvalidTableSpan,
"rowspan must be provided in the table's structure, never in the " +
"initialization block. See Prawn's documentation for details."
end
@rowspan = span
end
# Draws the cell onto the document. Pass in a point [x,y] to override the
# location at which the cell is drawn.
#
# If drawing a group of cells at known positions, look into
# Cell.draw_cells, which ensures that the backgrounds, borders, and
# content are all drawn in correct order so as not to overlap.
#
def draw(pt=[x, y])
Prawn::Table::Cell.draw_cells([[self, pt]])
end
# Given an array of pairs [cell, pt], draws each cell at its
# corresponding pt, making sure all backgrounds are behind all borders
# and content.
#
def self.draw_cells(cells)
cells.each do |cell, pt|
cell.set_width_constraints
cell.draw_background(pt)
end
cells.each do |cell, pt|
cell.draw_borders(pt)
cell.draw_bounded_content(pt)
end
end
# Draws the cell's content at the point provided.
#
def draw_bounded_content(pt)
@pdf.float do
@pdf.bounding_box([pt[0] + padding_left, pt[1] - padding_top],
:width => spanned_content_width + FPTolerance,
:height => spanned_content_height + FPTolerance) do
draw_content
end
end
end
# x-position of the cell within the parent bounds.
#
def x
@point[0]
end
# Set the x-position of the cell within the parent bounds.
#
def x=(val)
@point[0] = val
end
# y-position of the cell within the parent bounds.
#
def y
@point[1]
end
# Set the y-position of the cell within the parent bounds.
#
def y=(val)
@point[1] = val
end
# Sets padding on this cell. The argument can be one of:
#
# * an integer (sets all padding)
# * a two-element array [vertical, horizontal]
# * a three-element array [top, horizontal, bottom]
# * a four-element array [top, right, bottom, left]
#
def padding=(pad)
@padding = case
when pad.nil?
[0, 0, 0, 0]
when Numeric === pad # all padding
[pad, pad, pad, pad]
when pad.length == 2 # vert, horiz
[pad[0], pad[1], pad[0], pad[1]]
when pad.length == 3 # top, horiz, bottom
[pad[0], pad[1], pad[2], pad[1]]
when pad.length == 4 # top, right, bottom, left
[pad[0], pad[1], pad[2], pad[3]]
else
raise ArgumentError, ":padding must be a number or an array [v,h] " +
"or [t,r,b,l]"
end
end
def padding_top
@padding[0]
end
def padding_top=(val)
@padding[0] = val
end
def padding_right
@padding[1]
end
def padding_right=(val)
@padding[1] = val
end
def padding_bottom
@padding[2]
end
def padding_bottom=(val)
@padding[2] = val
end
def padding_left
@padding[3]
end
def padding_left=(val)
@padding[3] = val
end
# Sets border colors on this cell. The argument can be one of:
#
# * an integer (sets all colors)
# * a two-element array [vertical, horizontal]
# * a three-element array [top, horizontal, bottom]
# * a four-element array [top, right, bottom, left]
#
def border_color=(color)
@border_colors = case
when color.nil?
["000000"] * 4
when String === color # all colors
[color, color, color, color]
when color.length == 2 # vert, horiz
[color[0], color[1], color[0], color[1]]
when color.length == 3 # top, horiz, bottom
[color[0], color[1], color[2], color[1]]
when color.length == 4 # top, right, bottom, left
[color[0], color[1], color[2], color[3]]
else
raise ArgumentError, ":border_color must be a string " +
"or an array [v,h] or [t,r,b,l]"
end
end
alias_method :border_colors=, :border_color=
def border_top_color
@border_colors[0]
end
def border_top_color=(val)
@border_colors[0] = val
end
def border_right_color
@border_colors[1]
end
def border_right_color=(val)
@border_colors[1] = val
end
def border_bottom_color
@border_colors[2]
end
def border_bottom_color=(val)
@border_colors[2] = val
end
def border_left_color
@border_colors[3]
end
def border_left_color=(val)
@border_colors[3] = val
end
# Sets border widths on this cell. The argument can be one of:
#
# * an integer (sets all widths)
# * a two-element array [vertical, horizontal]
# * a three-element array [top, horizontal, bottom]
# * a four-element array [top, right, bottom, left]
#
def border_width=(width)
@border_widths = case
when width.nil?
["000000"] * 4
when Numeric === width # all widths
[width, width, width, width]
when width.length == 2 # vert, horiz
[width[0], width[1], width[0], width[1]]
when width.length == 3 # top, horiz, bottom
[width[0], width[1], width[2], width[1]]
when width.length == 4 # top, right, bottom, left
[width[0], width[1], width[2], width[3]]
else
raise ArgumentError, ":border_width must be a string " +
"or an array [v,h] or [t,r,b,l]"
end
end
alias_method :border_widths=, :border_width=
def border_top_width
@borders.include?(:top) ? @border_widths[0] : 0
end
def border_top_width=(val)
@border_widths[0] = val
end
def border_right_width
@borders.include?(:right) ? @border_widths[1] : 0
end
def border_right_width=(val)
@border_widths[1] = val
end
def border_bottom_width
@borders.include?(:bottom) ? @border_widths[2] : 0
end
def border_bottom_width=(val)
@border_widths[2] = val
end
def border_left_width
@borders.include?(:left) ? @border_widths[3] : 0
end
def border_left_width=(val)
@border_widths[3] = val
end
# Sets the cell's minimum and maximum width. Deferred until requested
# because padding and size can change.
#
def set_width_constraints
@min_width ||= padding_left + padding_right
@max_width ||= @pdf.bounds.width
end
# Sets border line style on this cell. The argument can be one of:
#
# Possible values are: :solid, :dashed, :dotted
#
# * one value (sets all lines)
# * a two-element array [vertical, horizontal]
# * a three-element array [top, horizontal, bottom]
# * a four-element array [top, right, bottom, left]
#
def border_line=(line)
@border_lines = case
when line.nil?
[:solid] * 4
when line.length == 1 # all lines
[line[0]] * 4
when line.length == 2
[line[0], line[1], line[0], line[1]]
when line.length == 3
[line[0], line[1], line[2], line[1]]
when line.length == 4
[line[0], line[1], line[2], line[3]]
else
raise ArgumentError, "border_line must be one of :solid, :dashed, "
":dotted or an array [v,h] or [t,r,b,l]"
end
end
alias_method :border_lines=, :border_line=
def border_top_line
@borders.include?(:top) ? @border_lines[0] : 0
end
def border_top_line=(val)
@border_lines[0] = val
end
def border_right_line
@borders.include?(:right) ? @border_lines[1] : 0
end
def border_right_line=(val)
@border_lines[1] = val
end
def border_bottom_line
@borders.include?(:bottom) ? @border_lines[2] : 0
end
def border_bottom_line=(val)
@border_lines[2] = val
end
def border_left_line
@borders.include?(:left) ? @border_lines[3] : 0
end
def border_left_line=(val)
@border_lines[3] = val
end
# Draws the cell's background color.
#
def draw_background(pt)
if defined?(@background_color) && @background_color
@pdf.mask(:fill_color) do
@pdf.fill_color @background_color
@pdf.fill_rectangle pt, width, height
end
end
end
# Draws borders around the cell. Borders are centered on the bounds of
# the cell outside of any padding, so the caller is responsible for
# setting appropriate padding to ensure the border does not overlap with
# cell content.
#
def draw_borders(pt)
x, y = pt
@pdf.mask(:line_width, :stroke_color) do
@borders.each do |border|
idx = {:top => 0, :right => 1, :bottom => 2, :left => 3}[border]
border_color = @border_colors[idx]
border_width = @border_widths[idx]
border_line = @border_lines[idx]
next if border_width <= 0
# Left and right borders are drawn one-half border beyond the center
# of the corner, so that the corners end up square.
from, to = case border
when :top
[[x, y], [x+width, y]]
when :bottom
[[x, y-height], [x+width, y-height]]
when :left
[[x, y + (border_top_width / 2.0)],
[x, y - height - (border_bottom_width / 2.0)]]
when :right
[[x+width, y + (border_top_width / 2.0)],
[x+width, y - height - (border_bottom_width / 2.0)]]
end
case border_line
when :dashed
@pdf.dash border_width * 4
when :dotted
@pdf.dash border_width, :space => border_width * 2
when :solid
# normal line style
else
raise ArgumentError, "border_line must be :solid, :dotted or" +
" :dashed"
end
@pdf.line_width = border_width
@pdf.stroke_color = border_color
@pdf.stroke_line(from, to)
@pdf.undash
end
end
end
# Draws cell content within the cell's bounding box. Must be implemented
# in subclasses.
#
def draw_content
raise NotImplementedError, "subclasses must implement draw_content"
end
end
end
end