lib/prawn/table.rb in prawn-0.12.0 vs lib/prawn/table.rb in prawn-0.13.0

- old
+ new

@@ -9,15 +9,17 @@ require 'prawn/table/cells' require 'prawn/table/cell' require 'prawn/table/cell/in_table' require 'prawn/table/cell/text' require 'prawn/table/cell/subtable' +require 'prawn/table/cell/image' +require 'prawn/table/cell/span_dummy' module Prawn class Document - + # Set up and draw a table on this document. A block can be given, which will # be run after cell setup but before layout and drawing. # # See the documentation on Prawn::Table for details on the arguments. # @@ -47,11 +49,11 @@ # # String:: # Produces a text cell. This is the most common usage. # Prawn::Table::Cell:: # If you have already built a Cell or have a custom subclass of Cell you - # want to use in a table, you can pass through Cell objects. + # want to use in a table, you can pass through Cell objects. # Prawn::Table:: # Creates a subtable (a table within a cell). You can use # Prawn::Document#make_table to create a table for use as a subtable # without immediately drawing it. See examples/table/bill.rb for a # somewhat complex use of subtables. @@ -69,21 +71,25 @@ # # +cell_style+:: # A hash of style options to style all cells. See the documentation on # Prawn::Table::Cell for all cell style options. # +header+:: - # If set to +true+, the first row will be repeated on every page. The - # header must be included as the first row of your data. Row numbering - # (for styling and other row-specific options) always indexes based on - # your data array. Whether or not you have a header, row(n) always refers - # to the nth element (starting from 0) of the +data+ array. - # +column_widths+:: + # If set to +true+, the first row will be repeated on every page. If set + # to an Integer, the first +x+ rows will be repeated on every page. Row + # numbering (for styling and other row-specific options) always indexes + # based on your data array. Whether or not you have a header, row(n) always + # refers to the nth element (starting from 0) of the +data+ array. + # +column_widths+:: # Sets widths for individual columns. Manually setting widths can give # better results than letting Prawn guess at them, as Prawn's algorithm # for defaulting widths is currently pretty boneheaded. If you experience # problems like weird column widths or CannotFit errors, try manually # setting widths on more columns. + # +position+:: + # Either :left (the default), :center, :right, or a number. Specifies the + # horizontal position of the table within its bounding box. If a number is + # provided, it specifies the distance in points from the left edge. # # = Initializer Block # # If a block is passed to methods that initialize a table # (Prawn::Table.new, Prawn::Document#table, Prawn::Document#make_table), it @@ -93,20 +99,20 @@ # inch (72 pt) wide: # # pdf.table(data) do |table| # table.rows(1..3).width = 72 # end - # + # # As with Prawn::Document#initialize, if the block has no arguments, it will # be evaluated in the context of the object itself. The above code could be # rewritten as: # # pdf.table(data) do # rows(1..3).width = 72 # end # - class Table + class Table # Set up a table on the given document. Arguments: # # +data+:: # A two-dimensional array of cell-like objects. See the "Data" section @@ -129,11 +135,11 @@ end set_column_widths set_row_heights position_cells - end + end # Number of rows in the table. # attr_reader :row_length @@ -143,22 +149,41 @@ # Manually set the width of the table. # attr_writer :width + # Position (:left, :right, :center, or a number indicating distance in + # points from the left edge) of the table within its parent bounds. + # + attr_writer :position + + # Returns a Prawn::Table::Cells object representing all of the cells in + # this table. + # + attr_reader :cells + + # Specify a callback to be called before each page of cells is rendered. + # The block is passed a Cells object containing all cells to be rendered on + # that page. You can change styling of the cells in this block, but keep in + # mind that the cells have already been positioned and sized. + # + def before_rendering_page(&block) + @before_rendering_page = block + end + # Returns the width of the table in PDF points. # def width @width ||= [natural_width, @pdf.bounds.width].min end # Sets column widths for the table. The argument can be one of the following # types: # - # +Array+:: + # +Array+:: # <tt>[w0, w1, w2, ...]</tt> (specify a width for each column) - # +Hash+:: + # +Hash+:: # <tt>{0 => w0, 1 => w1, ...}</tt> (keys are column names, values are # widths) # +Numeric+:: # +72+ (sets width for all columns) # @@ -180,12 +205,13 @@ def height cells.height end # If +true+, designates the first row as a header row to be repeated on - # every page. Does not change row numbering -- row numbers always index into - # the data array provided, with no modification. + # every page. If an integer, designates the number of rows to be treated + # as a header Does not change row numbering -- row numbers always index + # into the data array provided, with no modification. # attr_writer :header # Accepts an Array of alternating row colors to stripe the table. # @@ -219,90 +245,143 @@ end # Draws the table onto the document at the document's current y-position. # def draw - # The cell y-positions are based on an infinitely long canvas. The offset - # keeps track of how much we have to add to the original, theoretical - # y-position to get to the actual position on the current page. - offset = @pdf.y + with_position do + # The cell y-positions are based on an infinitely long canvas. The offset + # keeps track of how much we have to add to the original, theoretical + # y-position to get to the actual position on the current page. + offset = @pdf.y - # Reference bounds are the non-stretchy bounds used to decide when to - # flow to a new column / page. - ref_bounds = @pdf.reference_bounds + # Reference bounds are the non-stretchy bounds used to decide when to + # flow to a new column / page. + ref_bounds = @pdf.reference_bounds - last_y = @pdf.y + last_y = @pdf.y - # Determine whether we're at the top of the current bounds (margin box or - # bounding box). If we're at the top, we couldn't gain any more room by - # breaking to the next page -- this means, in particular, that if the - # first row is taller than the margin box, we will only move to the next - # page if we're below the top. Some floating-point tolerance is added to - # the calculation. - # - # Note that we use the actual bounds, not the reference bounds. This is - # because even if we are in a stretchy bounding box, flowing to the next - # page will not buy us any space if we are at the top. - if @pdf.y > @pdf.bounds.height + @pdf.bounds.absolute_bottom - 0.001 - # we're at the top of our bounds - started_new_page_at_row = 0 - else - started_new_page_at_row = -1 - - # If there isn't enough room left on the page to fit the first data row - # (excluding the header), start the table on the next page. - needed_height = row(0).height - needed_height += row(1).height if @header - if needed_height > @pdf.y - ref_bounds.absolute_bottom - @pdf.bounds.move_past_bottom - offset = @pdf.y + # Determine whether we're at the top of the current bounds (margin box or + # bounding box). If we're at the top, we couldn't gain any more room by + # breaking to the next page -- this means, in particular, that if the + # first row is taller than the margin box, we will only move to the next + # page if we're below the top. Some floating-point tolerance is added to + # the calculation. + # + # Note that we use the actual bounds, not the reference bounds. This is + # because even if we are in a stretchy bounding box, flowing to the next + # page will not buy us any space if we are at the top. + if @pdf.y > @pdf.bounds.height + @pdf.bounds.absolute_bottom - 0.001 + # we're at the top of our bounds started_new_page_at_row = 0 + else + started_new_page_at_row = -1 + + # If there isn't enough room left on the page to fit the first data row + # (excluding the header), start the table on the next page. + needed_height = row(0).height + if @header + if @header.is_a? Integer + needed_height += row(1..@header).height + else + needed_height += row(1).height + end + end + if needed_height > @pdf.y - ref_bounds.absolute_bottom + @pdf.bounds.move_past_bottom + offset = @pdf.y + started_new_page_at_row = 0 + end end - end - # Track cells to be drawn on this page. They will all be drawn when this - # page is finished. - cells_this_page = [] + # Duplicate each cell of the header row into @header_row so it can be + # modified in before_rendering_page callbacks. + if @header + @header_row = Cells.new + if @header.is_a? Integer + @header.times do |r| + row(r).each { |cell| @header_row[cell.row, cell.column] = cell.dup } + end + else + row(0).each { |cell| @header_row[cell.row, cell.column] = cell.dup } + end + end - @cells.each do |cell| - if cell.height > (cell.y + offset) - ref_bounds.absolute_bottom && - cell.row > started_new_page_at_row - # Ink all cells on the current page - Cell.draw_cells(cells_this_page) - cells_this_page = [] + # Track cells to be drawn on this page. They will all be drawn when this + # page is finished. + cells_this_page = [] - # start a new page or column - @pdf.bounds.move_past_bottom - draw_header unless cell.row == 0 - offset = @pdf.y - cell.y - started_new_page_at_row = cell.row - end - - # Don't modify cell.x / cell.y here, as we want to reuse the original - # values when re-inking the table. #draw should be able to be called - # multiple times. - x, y = cell.x, cell.y - y += offset + @cells.each do |cell| + if cell.height > (cell.y + offset) - ref_bounds.absolute_bottom && + cell.row > started_new_page_at_row + # Ink all cells on the current page + if defined?(@before_rendering_page) && @before_rendering_page + c = Cells.new(cells_this_page.map { |ci, _| ci }) + @before_rendering_page.call(c) + end + Cell.draw_cells(cells_this_page) + cells_this_page = [] - # Translate coordinates to the bounds we are in, since drawing is - # relative to the cursor, not ref_bounds. - x += @pdf.bounds.left_side - @pdf.bounds.absolute_left - y -= @pdf.bounds.absolute_bottom + # start a new page or column + @pdf.bounds.move_past_bottom + x_offset = @pdf.bounds.left_side - @pdf.bounds.absolute_left + if cell.row > 0 && @header + if @header.is_a? Integer + header_height = 0 + y_coord = @pdf.cursor + @header.times do |h| + additional_header_height = add_header(cells_this_page, x_offset, y_coord-header_height, cell.row-1, h) + header_height += additional_header_height + end + else + header_height = add_header(cells_this_page, x_offset, @pdf.cursor, cell.row-1) + end + else + header_height = 0 + end + offset = @pdf.y - cell.y - header_height + started_new_page_at_row = cell.row + end - # Set background color, if any. - if @row_colors && (!@header || cell.row > 0) - index = @header ? (cell.row - 1) : cell.row - cell.background_color = @row_colors[index % @row_colors.length] + # Don't modify cell.x / cell.y here, as we want to reuse the original + # values when re-inking the table. #draw should be able to be called + # multiple times. + x, y = cell.x, cell.y + y += offset + + # Translate coordinates to the bounds we are in, since drawing is + # relative to the cursor, not ref_bounds. + x += @pdf.bounds.left_side - @pdf.bounds.absolute_left + y -= @pdf.bounds.absolute_bottom + + # Set background color, if any. + if defined?(@row_colors) && @row_colors && (!@header || cell.row > 0) + # Ensure coloring restarts on every page (to make sure the header + # and first row of a page are not colored the same way). + if @header.is_a? Integer + rows = @header + elsif @header + rows = 1 + else + rows = 0 + end + index = cell.row - [started_new_page_at_row, rows].max + + cell.background_color ||= @row_colors[index % @row_colors.length] + end + + cells_this_page << [cell, [x, y]] + last_y = y end + # Draw the last page of cells + if defined?(@before_rendering_page) && @before_rendering_page + c = Cells.new(cells_this_page.map { |ci, _| ci }) + @before_rendering_page.call(c) + end + Cell.draw_cells(cells_this_page) - cells_this_page << [cell, [x, y]] - last_y = y + @pdf.move_cursor_to(last_y - @cells.last.height) end - # Draw the last page of cells - Cell.draw_cells(cells_this_page) - - @pdf.move_cursor_to(last_y - @cells.last.height) end # Calculate and return the constrained column widths, taking into account # each cell's min_width, max_width, and any user-specified constraints on # the table or column size. @@ -329,19 +408,19 @@ if width - natural_width < -epsilon # Shrink the table to fit the requested width. f = (width - cells.min_width).to_f / (natural_width - cells.min_width) (0...column_length).map do |c| - min, nat = column(c).min_width, column(c).width + min, nat = column(c).min_width, natural_column_widths[c] (f * (nat - min)) + min end elsif width - natural_width > epsilon # Expand the table to fit the requested width. f = (width - cells.width).to_f / (cells.max_width - cells.width) (0...column_length).map do |c| - nat, max = column(c).width, column(c).max_width + nat, max = natural_column_widths[c], column(c).max_width (f * (max - nat)) + nat end else natural_column_widths end @@ -349,11 +428,25 @@ end # Returns an array with the height of each row. # def row_heights - @natural_row_heights ||= (0...row_length).map{ |r| row(r).height } + @natural_row_heights ||= + begin + heights_by_row = Hash.new(0) + cells.each do |cell| + next if cell.is_a?(Cell::SpanDummy) + + # Split the height of row-spanned cells evenly by rows + height_per_row = cell.height.to_f / cell.rowspan + cell.rowspan.times do |i| + heights_by_row[cell.row + i] = + [heights_by_row[cell.row + i], height_per_row].max + end + end + heights_by_row.sort_by { |row, _| row }.map { |_, h| h } + end end protected # Converts the array of cellable objects given into instances of @@ -361,27 +454,84 @@ # know their own position in the table. # def make_cells(data) assert_proper_table_data(data) - cells = [] - - @row_length = data.length - @column_length = data.map{ |r| r.length }.max + cells = Cells.new - data.each_with_index do |row_cells, row_number| - row_cells.each_with_index do |cell_data, column_number| + row_number = 0 + data.each do |row_cells| + column_number = 0 + row_cells.each do |cell_data| + # If we landed on a spanned cell (from a rowspan above), continue + # until we find an empty spot. + column_number += 1 until cells[row_number, column_number].nil? + + # Build the cell and store it in the Cells collection. cell = Cell.make(@pdf, cell_data) - cell.extend(Cell::InTable) - cell.row = row_number - cell.column = column_number - cells << cell + cells[row_number, column_number] = cell + + # Add dummy cells for the rest of the cells in the span group. This + # allows Prawn to keep track of the horizontal and vertical space + # occupied in each column and row spanned by this cell, while still + # leaving the master (top left) cell in the group responsible for + # drawing. Dummy cells do not put ink on the page. + cell.rowspan.times do |i| + cell.colspan.times do |j| + next if i == 0 && j == 0 + + # It is an error to specify spans that overlap; catch this here + if cells[row_number + i, column_number + j] + raise Prawn::Errors::InvalidTableSpan, + "Spans overlap at row #{row_number + i}, " + + "column #{column_number + j}." + end + + dummy = Cell::SpanDummy.new(@pdf, cell) + cells[row_number + i, column_number + j] = dummy + cell.dummy_cells << dummy + end + end + + column_number += cell.colspan end + + row_number += 1 end + + # Calculate the number of rows and columns in the table, taking into + # account that some cells may span past the end of the physical cells we + # have. + @row_length = cells.map do |cell| + cell.row + cell.rowspan + end.max + + @column_length = cells.map do |cell| + cell.column + cell.colspan + end.max + cells end + # Add the header row(s) to the given array of cells at the given y-position. + # Number the row with the given +row+ index, so that the header appears (in + # any Cells built for this page) immediately prior to the first data row on + # this page. + # + # Return the height of the header. + # + def add_header(page_of_cells, x_offset, y, row, row_of_header=nil) + rows_to_operate_on = @header_row + rows_to_operate_on = @header_row.rows(row_of_header) if row_of_header + rows_to_operate_on.each do |cell| + cell.row = row + cell.dummy_cells.each {|c| c.row = row } + page_of_cells << [cell, [cell.x + x_offset, y]] + end + rows_to_operate_on.height + end + # Raises an error if the data provided cannot be converted into a valid # table. # def assert_proper_table_data(data) if data.nil? || data.empty? @@ -394,45 +544,46 @@ raise Prawn::Errors::InvalidTableData, "data must be a two dimensional array of cellable objects" end end - # If the table has a header, draw it at the current position. - # - def draw_header - if @header - y = @pdf.cursor - row(0).each do |cell| - cell.y = y - cell.draw - end - @pdf.move_cursor_to(y - row(0).height) - end - end - # Returns an array of each column's natural (unconstrained) width. # def natural_column_widths - @natural_column_widths ||= (0...column_length).map { |c| column(c).width } + @natural_column_widths ||= + begin + widths_by_column = Hash.new(0) + cells.each do |cell| + next if cell.is_a?(Cell::SpanDummy) + + # Split the width of colspanned cells evenly by columns + width_per_column = cell.width.to_f / cell.colspan + cell.colspan.times do |i| + widths_by_column[cell.column + i] = + [widths_by_column[cell.column + i], width_per_column].max + end + end + widths_by_column.sort_by { |col, _| col }.map { |_, w| w } + end end # Returns the "natural" (unconstrained) width of the table. This may be # extremely silly; for example, the unconstrained width of a paragraph of # text is the width it would assume if it were not wrapped at all. Could be # a mile long. # def natural_width - @natural_width ||= natural_column_widths.inject(0) { |sum, w| sum + w } + @natural_width ||= natural_column_widths.inject(0, &:+) end # Assigns the calculated column widths to each cell. This ensures that each # cell in a column is the same width. After this method is called, # subsequent calls to column_widths and width should return the finalized # values that will be used to ink the table. # def set_column_widths - column_widths.each_with_index do |w, col_num| + column_widths.each_with_index do |w, col_num| column(col_num).width = w end end # Assigns the row heights to each cell. This ensures that every cell in a @@ -445,18 +596,41 @@ # Set each cell's position based on the widths and heights of cells # preceding it. # def position_cells # Calculate x- and y-positions as running sums of widths / heights. - x_positions = column_widths.inject([0]) { |ary, x| + x_positions = column_widths.inject([0]) { |ary, x| ary << (ary.last + x); ary }[0..-2] x_positions.each_with_index { |x, i| column(i).x = x } # y-positions assume an infinitely long canvas starting at zero -- this # is corrected for in Table#draw, and page breaks are properly inserted. y_positions = row_heights.inject([0]) { |ary, y| ary << (ary.last - y); ary}[0..-2] y_positions.each_with_index { |y, i| row(i).y = y } + end + + # Sets up a bounding box to position the table according to the specified + # :position option, and yields. + # + def with_position + x = case defined?(@position) && @position || :left + when :left then return yield + when :center then (@pdf.bounds.width - width) / 2.0 + when :right then @pdf.bounds.width - width + when Numeric then @position + else raise ArgumentError, "unknown position #{@position.inspect}" + end + dy = @pdf.bounds.absolute_top - @pdf.y + final_y = nil + + @pdf.bounding_box([x, @pdf.bounds.top], :width => width) do + @pdf.move_down dy + yield + final_y = @pdf.y + end + + @pdf.y = final_y end private def epsilon