module PDF class Wrapper # Draws a basic table of text on the page. See the documentation for a detailed description of # how to control the table and its appearance. # # data:: a 2d array with the data for the columns, or a PDF::Wrapper::Table object # # == Options # # The only options available when rendering a table are those relating to its size and location. # All other options that relate to the content of the table and how it looks should be configured # on the PDF::Wrapper::Table object that is passed into this function. # # :left:: The x co-ordinate of the left-hand side of the table. Defaults to the current cursor location # :top:: The y co-ordinate of the top of the text. Defaults to the current cursor location # :width:: The width of the table. Defaults to the distance from the left of the table to the right margin def table(data, opts = {}) # TODO: add support for a table footer # - repeating each page, or just at the bottom? # - if it repeats, should it be different on each page? ie. a sum of that pages rows, etc. # TODO: maybe support for multiple data sets with group headers/footers. useful for subtotals, etc x, y = current_point options = {:left => x, :top => y } options.merge!(opts) options.assert_valid_keys(default_positioning_options.keys) if data.kind_of?(::PDF::Wrapper::Table) t = data else t = ::PDF::Wrapper::Table.new do |table| table.data = data end end t.width = options[:width] || points_to_right_margin(options[:left]) calc_table_dimensions t # move to the start of our table (the top left) move_to(options[:left], options[:top]) # draw the header cells draw_table_headers(t) if t.headers && (t.show_headers == :page || t.show_headers == :once) x, y = current_point # loop over each row in the table t.cells.each_with_index do |row, row_idx| # calc the height of the current row h = t.row_height(row_idx) if y + h > absolute_bottom_margin start_new_page y = margin_top # draw the header cells draw_table_headers(t) if t.headers && (t.show_headers == :page) x, y = current_point end # loop over each column in the current row row.each_with_index do |cell, col_idx| # calc the options and widths for this particular cell opts = t.options_for(col_idx, row_idx) w = t.col_width(col_idx) # paint it self.cell(cell.data, x, y, w, h, opts) x += w move_to(x, y) end # move to the start of the next row y += h x = options[:left] move_to(x, y) end end def calc_table_dimensions(t) # TODO: when calculating the min cell width, we basically want the width of the widest character. At the # moment I'm stripping all pango markup tags from the string, which means if any character is made # intentioanlly large, we'll miss it and it might not fit into our table cell. # TODO: allow column widths to be set manually # calculate the min and max width of every cell in the table t.cells.each_with_index do |row, row_idx| row.each_with_index do |cell, col_idx| opts = t.options_for(col_idx, row_idx).only(default_text_options.keys) padding = opts[:padding] || 3 cell.min_width = text_width(cell.data.to_s.dup.gsub(/<.+?>/,"").gsub(/\b|\B/,"\n"), opts) + (padding * 4) cell.max_width = text_width(cell.data, opts) + (padding * 4) end end # calculate the min and max width of every cell in the headers row if t.headers t.headers.each_with_index do |cell, col_idx| opts = t.options_for(col_idx, :headers).only(default_text_options.keys) padding = opts[:padding] || 3 cell.min_width = text_width(cell.data.to_s.dup.gsub(/<.+?>/,"").gsub(/\b|\B/,"\n"), opts) + (padding * 4) cell.max_width = text_width(cell.data, opts) + (padding * 4) end end # let the table decide on the actual widths it will use for each col t.calc_col_widths! # now that we know how wide each column will be, we can calculate the # height of every cell in the table t.cells.each_with_index do |row, row_idx| row.each_with_index do |cell, col_idx| opts = t.options_for(col_idx, row_idx).only(default_text_options.keys) padding = opts[:padding] || 3 cell.height = text_height(cell.data, t.col_width(col_idx) - (padding * 2), opts) + (padding * 2) end end # let the table calculate how high each row is going to be t.calc_row_heights! # perform the same height calcs for the header row if necesary if t.headers t.headers.each_with_index do |cell, col_idx| opts = t.options_for(col_idx, :headers).only(default_text_options.keys) padding = opts[:padding] || 3 cell.height = text_height(cell.data, t.col_width(col_idx) - (padding * 2), opts) + (padding * 2) end t.calc_headers_height! end end private :calc_table_dimensions def draw_table_headers(t) x, y = current_point origx = x h = t.headers_height t.headers.each_with_index do |cell, col_idx| # calc the options and widths for this particular header cell opts = t.options_for(col_idx, :headers) w = t.col_width(col_idx) # paint it self.cell(cell.data, x, y, w, h, opts) x += w move_to(x, y) end move_to(origx, y + h) end private :draw_table_headers # This class is used to hold all the data and options for a table that will # be added to a PDF::Wrapper document. Tables are a collection of cells, each # one rendered to the document using the Wrapper#cell function. # # To begin working with a table, pass in a 2d array of data to display, along # with optional headings, then pass the object to Wrapper#table # # table = Table.new do |t| # t.headers = ["Words", "Numbers"] # t.data = [['one', 1], # ['two', 2], # ['three',3]] # end # pdf.table(table) # # For all but the most basic tables, you will probably want to tweak at least # some of the options for some of the cells. The options available are the same # as those that are valid for the Wrapper#cell method, including things like font, # font size, color and alignment. # # Options can be specified at the table, column, row and cell level. When it comes time # to render each cell, the options are merged together so that cell options override row # ones, row ones override column ones and column ones override table wide ones. # # By default, no options are defined at all, and the document defaults will be used. # # For example: # # table = Table.new(:font_size => 10) do |t| # t.headers = ["Words", "Numbers"] # t.data = [['one', 1], # ['two', 2], # ['three',3]] # t.row_options 0, :color => :green # t.row_options 2, :color => :red # t.col_options 0, :color => :blue # t.cell_options 2, 2, :font_size => 18 # t.manual_column_width 2, 40 # end # pdf.table(table) # # == Displaying Headings # # By default, the column headings will be displayed at the top of the table, and at # the start of each new page the table wraps on to. Use the show_headers= option # to change this behaviour. Valid values are nil for never, :once for just the at the # top of the table, and :page for the default. # class Table attr_reader :cells#, :headers attr_accessor :width, :show_headers # def initialize(opts = {}) # default table options @table_options = opts @col_options = Hash.new({}) @row_options = Hash.new({}) @manual_col_widths = {} @header_options = {} @show_headers = :page yield self if block_given? self end # Specify the tables data. # # The single argument should be a 2d array like: # # [[ "one", "two"], # [ "one", "two"]] def data=(d) # TODO: raise an exception of the data rows aren't all the same size # TODO: ensure d is array-like @cells = d.collect do |row| row.collect do |str| Wrapper::Cell.new(str) end end end # Retrieve or set the table's optional column headers. # # With no arguments, the currents headers will be returned # # t.headers # => ["col one", "col two"] # # The first argument is an array of text to use as column headers # # t.headers ["col one", "col two] # # The optional second argument sets the cell options for the header # cells. See PDF::Wrapper#cell for a list of possible options. # # t.headers ["col one", "col two], :color => :block, :fill_color => :black # # If the options hash is left unspecified, the default table options will # be used. # def headers(h = nil, opts = {}) # TODO: raise an exception of the size of the array does not match the size # of the data row arrays # TODO: ensure h is array-like return @headers if h.nil? @headers = h.collect do |str| Wrapper::Cell.new(str) end @header_options = opts end def headers=(h) # TODO: remove this method at some point. Deprecation started on 10th August 2008. warn "WARNING: Table#headers=() is deprecated, headers should now be set along with header options using Table#headers()" headers h end # access a particular cell at location x, y def cell(col_idx, row_idx) @cells[row_idx, col_idx] end # Set or retrieve options that apply to every cell in the table. # For a list of valid options, see Wrapper#cell. # # WARNING. This method is deprecated. Table options should be passed to the # PDF::Wrapper::Table constructor instead def table_options(opts = nil) # TODO: remove this method at some point. Deprecation started on 10th August 2008. warn "WARNING: Table#table_options() is deprecated, please see the documentation for PDF::Wrapper::Table" @table_options = @table_options.merge(opts) if opts @table_options end # set or retrieve options that apply to header cells # For a list of valid options, see Wrapper#cell. # # WARNING. This method is deprecated. Header options should be passed to the # PDF::Wrapper::Table#headers method instead def header_options(opts = nil) # TODO: remove this method at some point. Deprecation started on 10th August 2008. warn "WARNING: Table#header_options() is deprecated, please see the documentation for PDF::Wrapper::Table" @header_options = @header_options.merge(opts) if opts @header_options end # set or retrieve options that apply to a single cell # For a list of valid options, see Wrapper#cell. def cell_options(col_idx, row_idx, opts = nil) raise ArgumentError, "#{col_idx},#{row_idx} is not a valid cell reference" unless @cells[row_idx] && @cells[row_idx][col_idx] @cells[row_idx][col_idx].options = @cells[row_idx][col_idx].options.merge(opts) if opts @cells[row_idx][col_idx].options end # set options that apply to 1 or more columns # For a list of valid options, see Wrapper#cell. # spec:: Which columns to add the options to. :odd, :even, a range, an Array of numbers or a number def col_options(spec, opts) each_column do |col_idx| if (spec == :even && (col_idx % 2) == 0) || (spec == :odd && (col_idx % 2) == 1) || (spec.class == Range && spec.include?(col_idx)) || (spec.class == Array && spec.include?(col_idx)) || (spec.respond_to?(:to_i) && spec.to_i == col_idx) @col_options[col_idx] = @col_options[col_idx].merge(opts) end end self end # Manually set the width for 1 or more columns # # spec:: Which columns to set the width for. :odd, :even, a range, an Array of numbers or a number # def manual_col_width(spec, width) width = width.to_f each_column do |col_idx| if (spec == :even && (col_idx % 2) == 0) || (spec == :odd && (col_idx % 2) == 1) || (spec.class == Range && spec.include?(col_idx)) || (spec.class == Array && spec.include?(col_idx)) || (spec.respond_to?(:to_i) && spec.to_i == col_idx) @manual_col_widths[col_idx] = width end end self end # set options that apply to 1 or more rows # For a list of valid options, see Wrapper#cell. # spec:: Which columns to add the options to. :odd, :even, a range, an Array of numbers or a number def row_options(spec, opts) each_row do |row_idx| if (spec == :even && (row_idx % 2) == 0) || (spec == :odd && (row_idx % 2) == 1) || (spec.class == Range && spec.include?(row_idx)) || (spec.class == Array && spec.include?(row_idx)) || (spec.respond_to?(:to_i) && spec.to_i == row_idx) @row_options[row_idx] = @col_options[row_idx].merge(opts) end end self end # calculate the combined options for a particular cell # # To get the options for a regular cell, use numbers to reference the exact cell: # # options_for(3, 3) # # To get options for a header cell, use :headers for the row: # # options_for(3, :headers) # def options_for(col_idx, row_idx = nil) opts = @table_options.dup opts.merge! @col_options[col_idx] if row_idx == :headers opts.merge! @header_options else opts.merge! @row_options[row_idx] opts.merge! @cells[row_idx][col_idx].options end opts end # Returns the required height for the headers row. # Essentially just the height of the tallest cell in the row. def headers_height raise "You must call calc_headers_height! before calling headers_height" if @headers_height.nil? @headers_height end # Returns the required height for a particular row. # Essentially just the height of the tallest cell in the row. def row_height(idx) raise "You must call calc_row_heights! before calling row_heights" if @row_heights.nil? @row_heights[idx] end # Returns the number of columns in the table def col_count @cells.first.size.to_f end # Returns the width of the specified column def col_width(idx) raise "You must call calc_col_widths! before calling col_width" if @col_widths.nil? @col_widths[idx] end # process the individual cell widths and decide on the resulting # width of each column in the table def calc_col_widths! @col_widths = calc_column_widths end # process the individual cell heights in the header and decide on the # resulting height of each row in the table def calc_headers_height! @headers_height = @headers.collect { |cell| cell.height }.compact.max end # process the individual cell heights and decide on the resulting # height of each row in the table def calc_row_heights! @row_heights = @cells.collect do |row| row.collect { |cell| cell.height }.compact.max end end # forget row and column dimensions def reset! @col_widths = nil @row_heights = nil end private # the main smarts behind deciding on the width of each column. If possible, # each cell will get the maximum amount of space it wants. If not, some # negotiation happens to find the best possible set of widths. def calc_column_widths raise "Can't calculate column widths without knowing the overall table width" if self.width.nil? check_cell_widths max_col_widths = {} min_col_widths = {} each_column do |col| min_col_widths[col] = cells_in_col(col).collect { |c| c.min_width}.max.to_f max_col_widths[col] = cells_in_col(col).collect { |c| c.max_width}.max.to_f end # add header cells to the mix if @headers @headers.each_with_index do |cell, idx| min_col_widths[idx] = [cell.min_width.to_f, min_col_widths[idx]].max max_col_widths[idx] = [cell.max_width.to_f, max_col_widths[idx]].max end end # override the min and max col widths with manual ones where appropriate # freeze the values so that the algorithm that adjusts the widths # leaves them untouched @manual_col_widths.each { |key, val| val.freeze } max_col_widths.merge! @manual_col_widths min_col_widths.merge! @manual_col_widths if min_col_widths.values.sum > self.width raise RuntimeError, "table content cannot fit into a table width of #{self.width}" end if max_col_widths.values.sum == self.width # every col gets the space it wants col_widths = max_col_widths.dup elsif max_col_widths.values.sum < self.width # every col gets the space it wants, and there's # still more room left. Distribute the extra room evenly col_widths = grow_col_widths(max_col_widths.dup, max_col_widths, true) else # there's not enough room for every col to get as much space # as it wants, so work our way down until it fits col_widths = grow_col_widths(min_col_widths.dup, max_col_widths, false) end col_widths end # check to ensure every cell has a minimum and maximum cell width defined def check_cell_widths @cells.each do |row| row.each_with_index do |cell, col_idx| raise "Every cell must have a min_width defined before being rendered to a document" if cell.min_width.nil? raise "Every cell must have a max_width defined before being rendered to a document" if cell.max_width.nil? if @manual_col_widths[col_idx] && cell.min_width > @manual_col_widths[col_idx] raise "Manual width for col #{col_idx} is too low" end end end if @headers @headers.each_with_index do |cell, col_idx| raise "Every header cell must have a min_width defined before being rendered to a document" if cell.min_width.nil? raise "Every header cell must have a max_width defined before being rendered to a document" if cell.max_width.nil? if @manual_col_widths[col_idx] && cell.min_width > @manual_col_widths[col_idx] raise "Manual width for col #{col_idx} is too low" end end end end # iterate over each column in the table def each_column(&block) (0..(col_count-1)).each do |col| yield col end end # iterate over each row in the table def each_row(&block) (0..(@cells.size-1)).each do |row| yield row end end # an array of all the cells in the specified column def cells_in_col(idx) @cells.collect {|row| row[idx]} end # an array of all the cells in the specified row def cells_in_row(idx) @cells[idx] end # if the widths of every column are less than the total width # of the table, grow them to make use of it. # # col_widths - the current hash of widths for each column index # max_col_widths - the maximum width each column desires # past_max - can the width of a colum grow beyond its maximum desired def grow_col_widths(col_widths, max_col_widths, past_max = false) loop do each_column do |idx| col_widths[idx] += 0.3 unless col_widths[idx].frozen? col_widths[idx].freeze if col_widths[idx] >= max_col_widths[idx] && past_max == false break if col_widths.values.sum >= self.width end break if col_widths.values.sum >= self.width end col_widths end end # A basic container to hold the required information for each cell class Cell attr_accessor :data, :options, :height, :min_width, :max_width def initialize(str) @data = str @options = {} end end end end