#-- # PDF::Writer for Ruby. # http://rubyforge.org/projects/ruby-pdf/ # Copyright 2003 - 2005 Austin Ziegler. # # Licensed under a MIT-style licence. See LICENCE in the main distribution # for full licensing information. # # $Id$ #++ require 'pdf/writer' require 'transaction/simple/group' # This class will create tables with a relatively simple API and internal # implementation. class PDF::SimpleTable VERSION = '1.2.0' include Transaction::Simple # Defines formatting options for a column. class Column def initialize(name) @name = name yield self if block_given? end # The heading of the column. This should be an instance of # PDF::SimpleTable::Column::Heading. If it is not, it will be # converted into one. attr_accessor :heading def heading=(hh) #:nodoc: unless hh.kind_of?(Heading) hh = Heading.new(hh) end @heading = hh end # The name of the column. attr_reader :name # The width of the column. If this value is set, the column will be # exactly this number of units wide. attr_accessor :width # The data name that will be used to provide a hyperlink for values in # this column. attr_accessor :link_name # The justification of the column. May be :left, :right, :center, or # :full. attr_accessor :justification # Formatting options for heading rows. Each column can have a separate # heading value. class Heading def initialize(title = nil) @title = title yield self if block_given? end # Indicates that the heading should be rendered bold. attr_accessor :bold # The justification of the heading of the column. May be :left, # :center, :right, or :full. attr_accessor :justification # The title of the heading. If nothing is present, the name of the # column will be used when headings are displayed. attr_accessor :title end end def initialize @column_order = [] @data = [] @columns = {} @show_lines = :outer @show_headings = true @shade_rows = :shaded @shade_color = Color::RGB::Grey80 @shade_color2 = Color::RGB::Grey70 @shade_headings = false @shade_heading_color = Color::RGB::Grey90 @font_size = 10 @heading_font_size = 12 @title_font_size = 12 @title_gap = 5 @title_color = Color::RGB::Black @heading_color = Color::RGB::Black @text_color = Color::RGB::Black @line_color = Color::RGB::Black @position = :center @orientation = :center @bold_headings = false @cols = PDF::Writer::OHash.new @width = 0 @maximum_width = 0 @gap = 5 @row_gap = 2 @column_gap = 5 @header_gap = 0 @minimum_space = 0 @protect_rows = 1 @split_rows = false @inner_line_style = PDF::Writer::StrokeStyle.new(1) @outer_line_style = PDF::Writer::StrokeStyle.new(1) yield self if block_given? end # An array of Hash entries. Each row is a Hash where the keys are the # names of the columns as specified in #column_order and the values are # the values of the cell. attr_accessor :data # An array that defines the order of the columns in the table. The # values in this array are the column names in #data. The columns will # be presented in the order defined here. attr_accessor :column_order # An array that defines columns and column options for the table. The # entries should be PDF::SimpleTable::Column objects. attr_accessor :columns # The title to be put on the top of the table. attr_accessor :title # Whether to display the lines on the table or not. Valid values are: # # :none:: Displays no lines. # :outer:: Displays outer lines only. *Default* # :inner:: Displays inner lines only. # :all:: Displays all lines, inner and outer. attr_accessor :show_lines # Displays the headings for the table if +true+. The default is +true+. attr_accessor :show_headings # Controls row shading. # # :none:: No row shading; all rows are the standard # background colour. # :shaded:: Alternate lines will be shaded; half of the rows # will be the standard background colour; the rest # of the rows will be shaded with #shade_color. # *Default* # :striped:: Alternate lines will be shaded; half of the rows # will be shaded with #shade_color; the rest of the # rows will be shaded with #shade_color2. attr_accessor :shade_rows # The main row shading colour. Defaults to Color::RGB::Grey80. Used with # #shade_rows of :shaded and :striped. attr_accessor :shade_color # The alternate row shading colour, used with #shade_rows of # :striped. Defaults to Color::RGB::Grey70. attr_accessor :shade_color2 # Places a background colour in the heading if +true+. attr_accessor :shade_headings # Defines the colour of the background shading for the heading if # #shade_headings is +true+. Default is Color::RGB::Grey90. attr_accessor :shade_heading_color # The font size of the data cells, in points. Defaults to 10 points. attr_accessor :font_size # The font size of the heading cells, in points. Defaults to 12 points. attr_accessor :heading_font_size # The font size of the title, in points. Defaults to 12 points. attr_accessor :title_font_size # The gap, in PDF units, between the title and the table. Defaults to 5 # units. attr_accessor :title_gap # The text colour of the title. Defaults to Color::RGB::Black. attr_accessor :title_color # The text colour of the heading. Defaults to Color::RGB::Black. attr_accessor :heading_color # The text colour of the body cells. Defaults to Color::RGB::Black. attr_accessor :text_color # The colour of the table lines. Defaults to Color::RGB::Black. attr_accessor :line_color # The +x+ position of the table. This will be one of: # # :left:: Aligned with the left margin. # :right:: Aligned with the right margin. # :center:: Centered between the margins. *Default*. # offset:: The absolute position of the table, relative from # the left margin. attr_accessor :position # The orientation of the table relative to #position. # # :left:: The table is to the left of #position. # :right:: The table is to the right of #position. # :center:: The table is centred at #position. # offset:: The left of the table is offset from #position. attr_accessor :orientation # Makes the heading text bold if +true+. Defaults to +false+. attr_accessor :bold_headings # Specifies the width of the table. If the table is smaller than the # provided width, columns are proportionally stretched to fit the width # of the table. If the table is wider than the provided width, columns # are proportionally shrunk to fit the width of the table. Content may # need to wrap in this case. # # Defaults to zero, which indicates that the size whould be determined # automatically based on the content and the margins. attr_accessor :width # Specifies the maximum width of the table. The table will not grow # larger than this width under any circumstances. # # Defaults to zero, which indicates that there is no maximum width # (aside from the margin size). attr_accessor :maximum_width # The space, in PDF user units, added to the top and bottom of each row # between the text and the lines of the cell. Default 2 units. attr_accessor :row_gap # The space, in PDF user units, on the left and right sides of each # cell. Default 5 units. attr_accessor :column_gap # The minimum space between the bottom of each row and the bottom # margin. If the amount of space is less than this, a new page will be # started. Default is 100 PDF user units. attr_accessor :minimum_space # The number of rows to hold with the heading on the page. If there are # less than this number of rows on the page, then move the whole lot # onto the next page. Default is one row. attr_accessor :protect_rows # Allows a table's rows to be split across page boundaries if +true+. # This defaults to +false+. attr_accessor :split_rows # The number of PDF user units to leave open at the top of a page after # a page break. This is typically used for a repeating page header, etc. # Defaults to zero units. attr_accessor :header_gap # Defines the inner line style. The default style is a solid line with a # thickness of 1 unit. attr_accessor :inner_line_style # Defines the outer line style. The default style is a solid line with a # thickness of 1 unit. attr_accessor :outer_line_style # Render the table on the PDF::Writer document provided. def render_on(pdf) if @column_order.empty? raise TypeError, PDF::Writer::Lang[:simpletable_columns_undefined] end if @data.empty? raise TypeError, PDF::Writer::Lang[:simpletable_data_empty] end low_y = descender = y0 = y1 = y = nil @cols = PDF::Writer::OHash.new @column_order.each do |name| col = @columns[name] if col @cols[name] = col else @cols[name] = PDF::SimpleTable::Column.new(name) end end @gap = 2 * @column_gap max_width = __find_table_max_width__(pdf) pos, t, x, adjustment_width, set_width = __find_table_positions__(pdf, max_width) # if max_width is specified, and the table is too wide, and the width # has not been set, then set the width. if @width.zero? and @maximum_width.nonzero? and ((t - x) > @maximum_width) @width = @maximum_width end if @width and (adjustment_width > 0) and (set_width < @width) # First find the current widths of the columns involved in this # mystery cols0 = PDF::Writer::OHash.new cols1 = PDF::Writer::OHash.new xq = presentWidth = 0 last = nil pos.each do |name, colpos| if @cols[last].nil? or @cols[last].width.nil? or @cols[last].width <= 0 unless last.nil? or last.empty? cols0[last] = colpos - xq - @gap presentWidth += (colpos - xq - @gap) end else cols1[last] = colpos - xq end last = name xq = colpos end # cols0 contains the widths of all the columns which are not set needed_width = @width - set_width # If needed width is negative then add it equally to each column, # else get more tricky. if presentWidth < needed_width diff = (needed_width - presentWidth) / cols0.size.to_f cols0.each_key { |name| cols0[name] += diff } else cnt = 0 loop do break if (presentWidth <= needed_width) or (cnt >= 100) cnt += 1 # insurance policy # Find the widest columns and the next to widest width aWidest = [] nWidest = widest = 0 cols0.each do |name, w| if w > widest aWidest = [ name ] nWidest = widest widest = w elsif w == widest aWidest << name end end # Then figure out what the width of the widest columns would # have to be to take up all the slack. newWidestWidth = widest - (presentWidth - needed_width) / aWidest.size.to_f if newWidestWidth > nWidest aWidest.each { |name| cols0[name] = newWidestWidth } presentWidth = needed_width else # There is no space, reduce the size of the widest ones down # to the next size down, and we will go round again aWidest.each { |name| cols0[name] = nWidest } presentWidth -= (widest - nWidest) * aWidest.size end end end # cols0 now contains the new widths of the constrained columns. now # need to update the pos and max_width arrays xq = 0 pos.each do |name, colpos| pos[name] = xq if @cols[name].nil? or @cols[name].width.nil? or @cols[name].width <= 0 if not cols0[name].nil? xq += cols0[name] + @gap max_width[name] = cols0[name] end else xq += cols1[name] unless cols1[name].nil? end end t = x + @width pos[:__last_column__] = t end # now adjust the table to the correct location across the page case @position when :left xref = pdf.absolute_left_margin when :right xref = pdf.absolute_right_margin when :center xref = pdf.margin_x_middle else xref = @position end case @orientation when :left dx = xref - t when :right dx = xref when :center dx = xref - (t / 2.0) else dx = xref + @orientation end pos.each { |k, v| pos[k] = v + dx } base_x0 = x0 = x + dx base_x1 = x1 = t + dx base_left_margin = pdf.absolute_left_margin base_pos = pos.dup # Ok, just about ready to make me a table. pdf.fill_color @text_color pdf.stroke_color @shade_color middle = (x0 + x1) / 2.0 # Start a transaction. This transaction will be used to regress the # table if there are not enough rows protected. tg = Transaction::Simple::Group.new(pdf, self) tg.start_transaction(:table) moved_once = false if @protect_rows.nonzero? abortTable = true loop do # while abortTable break unless abortTable abortTable = false dm = pdf.absolute_left_margin - base_left_margin base_pos.each { |k, v| pos[k] = v + dm } x0 = base_x0 + dm x1 = base_x1 + dm middle = (x0 + x1) / 2.0 # If the title is set, then render it. unless @title.nil? or @title.empty? w = pdf.text_width(@title, @title_font_size) _y = pdf.y - pdf.font_height(@title_font_size) if _y < pdf.absolute_bottom_margin pdf.start_new_page # margins may have changed on the new page dm = pdf.absolute_left_margin - base_left_margin base_pos.each { |k, v| pos[k] = v + dm } x0 = base_x0 + dm x1 = base_x1 + dm middle = (x0 + x1) / 2.0 end pdf.y -= pdf.font_height(@title_font_size) pdf.fill_color @title_color pdf.add_text(middle - w / 2.0, pdf.y, title, @title_font_size) pdf.y -= @title_gap end # Margins may have changed on the new_page. dm = pdf.absolute_left_margin - base_left_margin base_pos.each { |k, v| pos[k] = v + dm } x0 = base_x0 + dm x1 = base_x1 + dm middle = (x0 + x1) / 2.0 y = pdf.y # simplifies the code a bit low_y = y if low_y.nil? or y < low_y # Make the table height = pdf.font_height @font_size descender = pdf.font_descender @font_size y0 = y + descender dy = 0 if @show_headings # This function will move the start of the table to a new page if # it does not fit on this one. hOID = __open_new_object__(pdf) if @shade_headings pdf.fill_color @heading_color _height, y = __table_column_headings__(pdf, pos, max_width, height, descender, @row_gap, @heading_font_size, y) pdf.fill_color @text_color y0 = y + _height y1 = y if @shade_headings pdf.close_object pdf.fill_color! @shade_heading_color pdf.rectangle(x0 - @gap / 2.0, y, x1 - x0, _height).fill pdf.reopen_object(hOID) pdf.close_object pdf.restore_state end # Margins may have changed on the new_page dm = pdf.absolute_left_margin - base_left_margin base_pos.each { |k, v| pos[k] = v + dm } x0 = base_x0 + dm x1 = base_x1 + dm middle = (x0 + x1) / 2.0 else y1 = y0 end first_line = true # open an object here so that the text can be put in over the # shading tOID = __open_new_object__(pdf) unless :none == @shade_rows cnt = 0 cnt = 1 unless @shade_headings newPage = false @data.each do |row| cnt += 1 # Start a transaction that will be used for this row to prevent it # from being split. unless @split_rows pageStart = pdf.pageset.size columnStart = pdf.column_number if pdf.columns? tg.start_transaction(:row) row_orig = row y_orig = y y0_orig = y0 y1_orig = y1 end # unless @split_rows ok = false second_turn = false loop do # while !abortTable and !ok break if abortTable or ok mx = 0 newRow = true loop do # while !abortTable and (newPage or newRow) break if abortTable or not (newPage or newRow) y -= height low_y = y if low_y.nil? or y < low_y if newPage or y < (pdf.absolute_bottom_margin + @minimum_space) # check that enough rows are with the heading moved_once = abortTable = true if @protect_rows.nonzero? and not moved_once and cnt <= @protect_rows y2 = y - mx + (2 * height) + descender - (newRow ? 1 : 0) * height unless :none == @show_lines y0 = y1 unless @show_headings __table_draw_lines__(pdf, pos, @gap, x0, x1, y0, y1, y2, @line_color, @inner_line_style, @outer_line_style, @show_lines) end unless :none == @shade_rows pdf.close_object pdf.restore_state end pdf.start_new_page pdf.save_state # and the margins may have changed, this is due to the # possibility of the columns being turned on as the columns are # managed by manipulating the margins dm = pdf.absolute_left_margin - base_left_margin base_pos.each { |k, v| pos[k] = v + dm } x0 = base_x0 + dm x1 = base_x1 + dm tOID = __open_new_object__(pdf) unless :none == @shade_rows pdf.fill_color! @text_color y = pdf.absolute_top_margin - @header_gap low_y = y y0 = y + descender mx = 0 if @show_headings old_y = y pdf.fill_color @heading_color _height, y = __table_column_headings__(pdf, pos, max_width, height, descender, @row_gap, @heading_font_size, y) pdf.fill_color @text_color y0 = y + _height y1 = y if @shade_headings pdf.fill_color! @shade_heading_color pdf.rectangle(x0 - @gap / 2, y, x1 - x0, _height).fill pdf.fill_color @heading_color __table_column_headings__(pdf, pos, max_width, height, descender, @row_gap, @heading_font_size, old_y) pdf.fill_color @text_color end dm = pdf.absolute_left_margin - base_left_margin base_pos.each { |k, v| pos[k] = v + dm } x0 = base_x0 + dm x1 = base_x1 + dm middle = (x0 + x1) / 2.0 else y1 = y0 end first_line = true y -= height low_y = y if low_y.nil? or y < low_y end newRow = false # Write the actual data. If these cells need to be split over # a page, then newPage will be set, and the remaining text # will be placed in leftOvers newPage = false leftOvers = PDF::Writer::OHash.new @cols.each do |name, column| pdf.pointer = y + height colNewPage = false unless row[name].nil? lines = row[name].to_s.split(/\n/) if column and column.link_name lines.map! do |kk| link = row[column.link_name] if link "#{kk}" else kk end end end else lines = [] end pdf.y -= @row_gap lines.each do |line| pdf.send(:preprocess_text, line) start = true loop do break if (line.nil? or line.empty?) and not start start = false _y = pdf.y - height if not colNewPage # a new page is required newPage = colNewPage = true if _y < pdf.absolute_bottom_margin if colNewPage if leftOvers[name].nil? leftOvers[name] = [line] else leftOvers[name] << "\n#{line}" end line = nil else if column and column.justification just = column.justification end just ||= :left pdf.y = _y line = pdf.add_text_wrap(pos[name], pdf.y, max_width[name], line, @font_size, just) end end end dy = y + height - pdf.y + @row_gap mx = dy - height * (newPage ? 1 : 0) if (dy - height * (newPage ? 1 : 0)) > mx end # Set row to leftOvers so that they will be processed onto the # new page row = leftOvers # Now add the shading underneath unless :none == @shade_rows pdf.close_object if (cnt % 2).zero? pdf.fill_color!(@shade_color) pdf.rectangle(x0 - @gap / 2.0, y + descender + height - mx, x1 - x0, mx).fill elsif (cnt % 2).nonzero? and :striped == @shade_rows pdf.fill_color!(@shade_color2) pdf.rectangle(x0 - @gap / 2.0, y + descender + height - mx, x1 - x0, mx).fill end pdf.reopen_object(tOID) end if :inner == @show_lines or :all == @show_lines # draw a line on the top of the block pdf.save_state pdf.stroke_color! @line_color if first_line pdf.stroke_style @outer_line_style first_line = false else pdf.stroke_style @inner_line_style end pdf.line(x0 - @gap / 2.0, y + descender + height, x1 - @gap / 2.0, y + descender + height).stroke pdf.restore_state end end y = y - mx + height pdf.y = y low_y = y if low_y.nil? or y < low_y # checking row split over pages unless @split_rows if (((pdf.pageset.size != pageStart) or (pdf.columns? and columnStart != pdf.column_number)) and not second_turn) # then we need to go back and try that again! newPage = second_turn = true tg.rewind_transaction(:row) row = row_orig low_y = y = y_orig y0 = y0_orig y1 = y1_orig ok = false dm = pdf.absolute_left_margin - base_left_margin base_pos.each { |k, v| pos[k] = v + dm } x0 = base_x0 + dm x1 = base_x1 + dm else tg.commit_transaction(:row) ok = true end else ok = true # don't go 'round the loop if splitting rows is allowed end end if abortTable # abort_transaction if not ok only the outer transaction should # be operational. tg.rewind_transaction(:table) pdf.start_new_page # fix a bug where a moved table will take up the whole page. low_y = nil pdf.save_state break end end end if low_y <= y y2 = low_y + descender else y2 = y + descender end unless :none == @show_lines y0 = y1 unless @show_headings __table_draw_lines__(pdf, pos, @gap, x0, x1, y0, y1, y2, @line_color, @inner_line_style, @outer_line_style, @show_lines) end # close the object for drawing the text on top unless :none == @shade_rows pdf.close_object pdf.restore_state end pdf.y = low_y # Table has been put on the page, the rows guarded as required; commit. tg.commit_transaction(:table) y rescue Exception => ex begin tg.abort_transaction(:table) if tg.transaction_open? rescue nil end raise ex end WIDTH_FACTOR = 1.01 # Find the maximum widths of the text within each column. Default to # zero. def __find_table_max_width__(pdf) max_width = PDF::Writer::OHash.new(-1) # Find the maximum cell widths based on the data and the headings. # Passing through the data multiple times is unavoidable as we must # do some analysis first. @data.each do |row| @cols.each do |name, column| w = pdf.text_width(row[name].to_s, @font_size) w *= PDF::SimpleTable::WIDTH_FACTOR max_width[name] = w if w > max_width[name] end end @cols.each do |name, column| title = column.heading.title if column.heading title ||= column.name w = pdf.text_width(title, @heading_font_size) w *= PDF::SimpleTable::WIDTH_FACTOR max_width[name] = w if w > max_width[name] end max_width end private :__find_table_max_width__ # Calculate the start positions of each of the columns. This is based # on max_width, but may be modified with column options. def __find_table_positions__(pdf, max_width) pos = PDF::Writer::OHash.new x = t = adjustment_width = set_width = 0 max_width.each do |name, w| pos[name] = t # If the column width has been specified then set that here, also # total the width avaliable for adjustment. if not @cols[name].nil? and not @cols[name].width.nil? and @cols[name].width > 0 t += @cols[name].width max_width[name] = @cols[name].width - @gap set_width += @cols[name].width else t += w + @gap adjustment_width += w set_width += @gap end end pos[:__last_column__] = t [pos, t, x, adjustment_width, set_width] end private :__find_table_positions__ # Uses ezText to add the text, and returns the height taken by the # largest heading. This page will move the headings to a new page if # they will not fit completely on this one transaction support will be # used to implement this. def __table_column_headings__(pdf, pos, max_width, height, descender, gap, size, y) mx = second_go = 0 start_page = pdf.pageset.size # y is the position at which the top of the table should start, so the # base of the first text, is y-height-gap-descender, but ezText starts # by dropping height. # The return from this function is the total cell height, including # gaps, and y is adjusted to be the postion of the bottom line. tg = Transaction::Simple::Group.new(pdf, self) tg.start_transaction(:column_headings) ok = false y -= gap loop do break if ok @cols.each do |name, column| pdf.pointer = y if column.heading justification = column.heading.justification bold = column.heading.bold title = column.heading.title end justification ||= :left bold ||= @bold_headings title ||= column.name title = "#{title}" if bold pdf.text(title, :font_size => size, :absolute_left => pos[name], :absolute_right => (max_width[name] + pos[name]), :justification => justification) dy = y - pdf.y mx = dy if dy > mx end y -= (mx + gap) - descender # y = y - mx - gap + descender # If this has been moved to a new page, then abort the transaction; # move to a new page, and put it there. Do not check on the second # time around to avoid an infinite loop. if (pdf.pageset.size != start_page and not second_go) tg.rewind_transaction(:column_headings) pdf.start_new_page save_state y = @y - gap - descender ok = false second_go = true mx = 0 else tg.commit_transaction(:column_headings) ok = true end end return [mx + gap * 2 - descender, y] rescue Exception => ex begin tg.abort_transaction(:column_headings) if tg.transaction_open?(:column_headings) rescue nil end raise ex end private :__table_column_headings__ def __table_draw_lines__(pdf, pos, gap, x0, x1, y0, y1, y2, col, inner, outer, opt = :outer) x0 = 1000 x1 = 0 pdf.stroke_color(col) cnt = 0 n = pos.size pos.each do |name, x| cnt += 1 if (cnt == 1 or cnt == n) pdf.stroke_style outer else pdf.stroke_style inner end pdf.line(x - gap / 2.0, y0, x - gap / 2.0, y2).stroke x1 = x if x > x1 x0 = x if x < x0 end pdf.stroke_style outer pdf.line(x0 - (gap / 2.0) - (outer.width / 2.0), y0, x1 - (gap / 2.0) + (outer.width / 2.0), y0).stroke # Only do the second line if it is different than the first AND each # row does not have a line on it. if y0 != y1 and @show_lines == :outer pdf.line(x0 - gap / 2.0, y1, x1 - gap / 2.0, y1).stroke end pdf.line(x0 - (gap / 2.0) - (outer.width / 2.0), y2, x1 - (gap / 2.0) + (outer.width / 2.0), y2).stroke end private :__table_draw_lines__ def __open_new_object__(pdf) pdf.save_state tOID = pdf.open_object pdf.close_object pdf.add_object(tOID) pdf.reopen_object(tOID) tOID end private :__open_new_object__ end