# -*- encoding: utf-8; frozen_string_literal: true -*- # #-- # This file is part of HexaPDF. # # HexaPDF - A Versatile PDF Creation and Manipulation Library For Ruby # Copyright (C) 2014-2023 Thomas Leitner # # HexaPDF is free software: you can redistribute it and/or modify it # under the terms of the GNU Affero General Public License version 3 as # published by the Free Software Foundation with the addition of the # following permission added to Section 15 as permitted in Section 7(a): # FOR ANY PART OF THE COVERED WORK IN WHICH THE COPYRIGHT IS OWNED BY # THOMAS LEITNER, THOMAS LEITNER DISCLAIMS THE WARRANTY OF NON # INFRINGEMENT OF THIRD PARTY RIGHTS. # # HexaPDF is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public # License for more details. # # You should have received a copy of the GNU Affero General Public License # along with HexaPDF. If not, see . # # The interactive user interfaces in modified source and object code # versions of HexaPDF must display Appropriate Legal Notices, as required # under Section 5 of the GNU Affero General Public License version 3. # # In accordance with Section 7(b) of the GNU Affero General Public # License, a covered work must retain the producer line in every PDF that # is created or manipulated using HexaPDF. # # If the GNU Affero General Public License doesn't fit your need, # commercial licenses are available at . #++ require 'hexapdf/layout/box' require 'hexapdf/layout/frame' module HexaPDF module Layout # A TableBox allows placing boxes in a table. # # A table box instance can be fit into a rectangular area. The widths of the columns is # determined by the #column_widths definition. This means that there is no auto-sizing # supported. # # If some rows don't fit into the provided area, the table is split. The style of the original # table is also applied to the split box. # # # == Table Cell # # Each table cell is a Box instance and can have an associated style, e.g. for creating borders # around the cell contents. It is also possible to create cells that span more than one row or # column. By default a cell has a solid, black, 1pt border and a padding of 5pt on all sides. # # It is important to note that the drawing of cell borders (just the drawing, size calculations # are done as usual) are handled differently from standard box borders. While standard box # borders are drawn inside the box, cell borders are drawn on the bounds of the box. This means # that, visually, the borders of adjoining cells overlap, with the borders of cells to the right # and bottom being on top. # # To make sure that the cell borders are not outside of the table's bounds, the left and top # border widths of the top-left cell and the right and bottom border widths of the bottom-right # cell are taken into account when calculating the available space. # # # == Examples # # Let's start with a basic table: # # #>pdf-composer # cells = [[layout.text('A'), layout.text('B')], # [layout.text('C'), layout.text('D')]] # composer.table(cells) # # The HexaPDF::Document::Layout#table_box method accepts the cells as positional argument # instead of as keyword argument but all other arguments of ::new work the same. # # While the table box itself only allows box instances as cell contents, the layout helper # method also allows text which it transforms to text boxes. So this is the same as the above: # # #>pdf-composer # composer.table([['A', 'B'], ['C', 'D']]) # # The style of the cells can be customized, e.g. to avoid drawing borders: # # #>pdf-composer # cells = [[layout.text('A'), layout.text('B')], # [layout.text('C'), layout.text('D')]] # composer.table(cells, cell_style: {border: {width: 0}}) # # If the table doesn't fit completely, it is automatically split (in this case, the last row # gets moved to the second column): # # #>pdf-composer # cells = [[layout.text('A'), layout.text('B')], # [layout.text('C'), layout.text('D')], # [layout.text('E'), layout.text('F')]] # composer.column(height: 50) {|col| col.table(cells) } # # It is also possible to use row and column spans: # # #>pdf-composer # cells = [[{content: layout.text('A'), col_span: 2}, {content: layout.text('B'), row_span: 2}], # [{content: layout.text('C'), col_span: 2, row_span: 2}], # [layout.text('D')]] # composer.table(cells) # # Each table can have header rows and footer rows which are shown for all split parts: # # #>pdf-composer # header = lambda {|tb| [[{content: layout.text('Header', align: :center), col_span: 2}]] } # footer = lambda {|tb| [[layout.text('left'), layout.text('right', align: :right)]] } # cells = [[layout.text('A'), layout.text('B')], # [layout.text('C'), layout.text('D')], # [layout.text('E'), layout.text('F')]] # composer.column(height: 90) {|col| col.table(cells, header: header, footer: footer) } # # The cells can be styled using a callable object for more complex styling: # # #>pdf-composer # cells = [[layout.text('A'), layout.text('B')], # [layout.text('C'), layout.text('D')]] # block = lambda do |cell| # cell.style.background_color = # (cell.row == 0 && cell.column == 0 ? 'ffffaa' : 'ffffee') # end # composer.table(cells, cell_style: block) class TableBox < Box # Represents a single cell of the table. # # A cell is a container box that fits and draws its children with a BoxFitter. Its dimensions # (width and height) are not determined by its children but by the table layout algorithm. # Furthermore, its style can be used for drawing e.g. a cell border. # # Cell borders work similar to the separated borders model of CSS, i.e. each cell has its own # borders that do not overlap. class Cell < Box # The x-coordinate of the cell's top-left corner. # # The coordinate is relative to the table's content rectangle, with positive x-axis going to # the right and positive y-axis going to the bottom. # # This value is set by the parent Cells object during fitting and may therefore only be # relied on afterwards. attr_accessor :left # The y-coordinate of the cell's top-left corner. # # The coordinate is relative to the table's content rectangle, with positive x-axis going to # the right and positive y-axis going to the bottom. # # This value is set by the parent Cells object during fitting and may therefore only be # relied on afterwards. attr_accessor :top # The preferred width of the cell, determined during #fit. attr_reader :preferred_width # The preferred height of the cell, determined during #fit. attr_reader :preferred_height # The 0-based row number of the cell. attr_reader :row # The 0-based column number of the cell. attr_reader :column # The number of rows this cell spans. attr_reader :row_span # The number of columns this cell spans. attr_reader :col_span # The boxes to layout inside this cell. # # This may either be +nil+ (if the cell has no content), a single Box instance or an array # of Box instances. attr_accessor :children # Creates a new Cell instance. def initialize(row:, column:, children: nil, row_span: nil, col_span: nil, **kwargs) super(**kwargs, width: 0, height: 0) @children = children @row = row @column = column @row_span = row_span || 1 @col_span = col_span || 1 style.border.width.set(1) unless style.border? style.border.draw_on_bounds = true style.padding.set(5) unless style.padding? end # Returns +true+ if the cell has no content. def empty? super && (!@fit_results || @fit_results.empty?) end # Updates the height of the box to the given value. # # The +height+ has to be greater than or equal to the fitted height. def update_height(height) if height < @height raise HexaPDF::Error, "Given height needs to be at least as big as fitted height" end @height = height end # Fits the children of the table cell into the given rectangular area. def fit(available_width, available_height, frame) @width = available_width width = available_width - reserved_width height = available_height - reserved_height return false if width <= 0 || height <= 0 frame = Frame.new(0, 0, width, height, context: frame.context) case children when Box fit_result = frame.fit(children) @preferred_width = fit_result.x + fit_result.box.width + reserved_width @height = @preferred_height = fit_result.box.height + reserved_height @fit_results = [fit_result] @fit_successful = fit_result.success? when Array box_fitter = BoxFitter.new([frame]) children.each {|box| box_fitter.fit(box) } max_x_result = box_fitter.fit_results.max_by {|result| result.x + result.box.width } @preferred_width = max_x_result.x + max_x_result.box.width + reserved_width @height = @preferred_height = box_fitter.content_heights[0] + reserved_height @fit_results = box_fitter.fit_results @fit_successful = box_fitter.fit_successful? else @preferred_width = reserved_width @height = @preferred_height = reserved_height @fit_results = [] @fit_successful = true end end # :nodoc: def inspect "" end private # Draws the content of the cell. def draw_content(canvas, x, y) return if @fit_results.empty? # available_width is always equal to content_width but we need to adjust for the # difference in the y direction between fitting and drawing y -= (@fit_results[0].available_height - content_height) @fit_results.each do |fit_result| fit_result.x += x fit_result.y += y fit_result.draw(canvas) end end end # Represents the cells of a TableBox. # # This class is a wrapper around an array of arrays and provides some utility methods for # managing and styling the cells. # # == Table data transformation into correct form # # One of the main purposes of this class is to transform the cell data provided on # initialization into the representation a TableBox instance can work with. # # The +data+ argument for ::new is an array of arrays representing the rows of the table. Each # row array may contain one of the following items: # # * A single Box instance defining the content of the cell. # # * An array of Box instances defining the content of the cell. # # * A hash which defines the content of the cell as well as, optionally, additional # information through the following keys: # # +:content+:: The content for the cell. This may be a single Box or an array of Box # instances. # # +:row_span+:: An integer specifying the number of rows this cell should span. # # +:col_span+:: An integer specifying the number of columsn this cell should span. # # +:properties+:: A hash of properties (see Box#properties) to be set on the cell itself. # # All other key-value pairs are taken to be cell styling information (like # +:background_color+) and assigned to the cell style. # # Additionally, the first item in the +data+ argument is treated specially if it is not an # array: # # * If it is a hash, it is assumed to be style properties to be set on all created cell # instances. # # * If it is a callable object, it needs to accept a cell as argument and is called for all # created cell instances. # # Any properties or styling information retrieved from the respective item in +data+ takes # precedence over the above globally specified information. # # Here is an example input data array: # # data = [[box1, {col_span: 2, content: box2}, box3], # [box4, box5, {col_span: 2, row_span: 2, content: [box6.1, box6.2]}], # [box7, box8]] # # And this is what the table will look like: # # | box1 | box2 | box 3 | # | box4 | box5 | box6.1 box6.2 | # | box7 | box8 | | class Cells # Creates a new Cells instance with the given +data+ which cannot be changed afterwards. # # The optional +cell_style+ argument can either be a hash of style properties to be assigned # to every cell or a block accepting a cell for more control over e.g. style assignment. If # the +data+ has such a cell style as its first item, the +cell_style+ argument is not used. # # See the class documentation for details on the +data+ argument. def initialize(data, cell_style: nil) @cells = [] @number_of_columns = 0 assign_data(data, cell_style) end # Returns the cell (a Cell instance) in the given row and column. # # Note that the same cell instance may be returned for different (row, column) arguments if # the cell spans more than one row and/or column. def [](row, column) @cells[row]&.[](column) end # Returns the number of rows. def number_of_rows @cells.size end # Returns the number of columns. def number_of_columns @number_of_columns end # Iterates over each row. def each_row(&block) @cells.each(&block) end # Applies the given style properties to all cells and optionally yields all cells for more # complex customization. def style(**properties, &block) @cells.each do |columns| columns.each do |cell| cell.style.update(**properties) block&.call(cell) end end end # Fits all rows starting from +start_row+ into an area with the given +available_height+, # using the column information in +column_info+. Returns the used height as well as the row # index of the last row that fit (which may be -1 if no row fits). # # The +column_info+ argument needs to be an array of arrays of the form [x_pos, width] # containing the horizontal positions and widths of each column. # # The +frame+ argument is further handed down to the Cell instances for fitting. # # The fitting of a cell is done through the Cell#fit method which stores the result in the # cell itself. Furthermore, Cell#left and Cell#top are also assigned correctly. def fit_rows(start_row, available_height, column_info, frame) height = available_height last_fitted_row_index = -1 @cells[start_row..-1].each.with_index(start_row) do |columns, row_index| row_fit = true row_height = 0 columns.each_with_index do |cell, col_index| next if cell.row != row_index || cell.column != col_index available_cell_width = if cell.col_span > 1 column_info[cell.column, cell.col_span].map(&:last).sum else column_info[cell.column].last end unless cell.fit(available_cell_width, available_height, frame) row_fit = false break end cell.left = column_info[cell.column].first cell.top = height - available_height row_height = cell.preferred_height if row_height < cell.preferred_height end if row_fit seen = {} columns.each do |cell| next if seen[cell] cell.update_height(cell.row == row_index ? row_height : cell.height + row_height) seen[cell] = true end last_fitted_row_index = row_index available_height -= row_height else last_fitted_row_index = columns.min_by(&:row).row - 1 if height != available_height break end end [height - available_height, last_fitted_row_index] end # Draws the rows from +start_row+ to +end_row+ on the given +canvas+, with the top-left # corner of the resulting table being at (+x+, +y+). def draw_rows(start_row, end_row, canvas, x, y) @cells[start_row..end_row].each.with_index(start_row) do |columns, row_index| columns.each_with_index do |cell, col_index| next if cell.row != row_index || cell.column != col_index cell.draw(canvas, x + cell.left, y - cell.top - cell.height) end end end private # Assigns the +data+ to the individual cells, taking row and column spans into account. # # For details on the +cell_style+ argument see ::new. def assign_data(data, cell_style) cell_style = data.shift unless data[0].kind_of?(Array) cell_style_block = if cell_style.kind_of?(Hash) lambda {|cell| cell.style.update(**cell_style) } else cell_style end data.each_with_index do |cols, row_index| # Only add new row array if it hasn't been added due to row spans before @cells << [] unless @cells[row_index] row = @cells[row_index] col_index = 0 cols.each do |content| # Ignore already filled in cells due to row/col spans col_index += 1 while row[col_index] children = content if content.kind_of?(Hash) children = content.delete(:content) row_span = content.delete(:row_span) col_span = content.delete(:col_span) properties = content.delete(:properties) style = content end cell = Cell.new(children: children, row: row_index, column: col_index, row_span: row_span, col_span: col_span) cell_style_block&.call(cell) cell.style.update(**style) if style cell.properties.update(properties) if properties row[col_index] = cell if cell.row_span > 1 || cell.col_span > 1 row_index.upto(row_index + cell.row_span - 1) do |r| @cells << [] unless @cells[r] col_index.upto(col_index + cell.col_span - 1) do |c| @cells[r][c] = cell end end end col_index += cell.col_span end @number_of_columns = col_index if @number_of_columns < col_index end end end # The Cells instance containing the data of the table. # # If this is an instance that was split from another one, the cells contain *all* the rows, # not just the ones for this split instance. # # Also see #start_row_index. attr_reader :cells # The Cells instance containing the header cells of the table. # # If this is a TableBox instance that was split from another one, the header cells are created # again through the use of +header+ block supplied to ::new. attr_reader :header_cells # The Cells instance containing the footer cells of the table. # # If this is a TableBox instance that was split from another one, the footer cells are created # again through the use of +footer+ block supplied to ::new. attr_reader :footer_cells # The column widths definition. # # See ::new for details. attr_reader :column_widths # The row index into the #cells from which this instance starts fitting the rows. # # This value is 0 if this instance was not split from another one. Otherwise, it contains the # correct start index. attr_reader :start_row_index # This value is -1 if #fit was not yet called. Otherwise it contains the row index of the last # row that could be fitted. attr_reader :last_fitted_row_index # Creates a new TableBox instance. # # +cells+:: # # This needs to be an array of arrays containing the data of the table. See Cells for more # information on the allowed contents. # # Alternatively, a Cells instance can be used. Note that in this case the +cell_style+ # argument is not used. # # +column_widths+:: # # An array defining the width of the columns of the table. If not set, defaults to an # empty array. # # Each entry in the array may either be a positive or negative number. A positive number # sets a fixed width for the respective column. # # A negative number specifies that the respective column is auto-sized. Such columns split # the remaining width (after substracting the widths of the fixed columns) proportionally # among them. For example, if the column width definition is [-1, -2, -2], the first # column is a fifth of the width and the other two columns are each two fifth of the # width. # # If the +cells+ definition has more columns than specified by +column_widths+, the # missing entries are assumed to be -1. # # +header+:: # # A callable object that needs to accept this TableBox instance as argument and that # returns an array of arrays containing the header rows. # # The header rows are shown for the table instance and all split boxes. # # +footer+:: # # A callable object that needs to accept this TableBox instance as argument and that # returns an array of arrays containing the footer rows. # # The footer rows are shown for the table instance and all split boxes. # # +cell_style+:: # # Contains styling information that should be applied to all header, body and footer # cells. # # This can either be a hash containing style properties or a callable object accepting a # cell as argument. def initialize(cells:, column_widths: nil, header: nil, footer: nil, cell_style: nil, **kwargs) super(**kwargs) @cell_style = cell_style @cells = cells.kind_of?(Cells) ? cells : Cells.new(cells, cell_style: @cell_style) @column_widths = column_widths || [] @start_row_index = 0 @last_fitted_row_index = -1 @header = header @header_cells = Cells.new(header.call(self), cell_style: @cell_style) if header @footer = footer @footer_cells = Cells.new(footer.call(self), cell_style: @cell_style) if footer end # Returns +true+ if not a single row could be fit. def empty? super && (!@last_fitted_row_index || @last_fitted_row_index < 0) end # Fits the table into the available space. def fit(available_width, available_height, frame) return false if (@initial_width > 0 && @initial_width > available_width) || (@initial_height > 0 && @initial_height > available_height) # Adjust reserved width/height to include space used by the edge cells for their border # since cell borders are drawn on the bounds and not inside. # This uses the top-left and bottom-right cells and so might not be correct in all cases. @cell_tl_border_width = @cells[0, 0].style.border.width cell_br_border_width = @cells[-1, -1].style.border.width rw = reserved_width + (@cell_tl_border_width.left + cell_br_border_width.right) / 2.0 rh = reserved_height + (@cell_tl_border_width.top + cell_br_border_width.bottom) / 2.0 width = (@initial_width > 0 ? @initial_width : available_width) - rw height = (@initial_height > 0 ? @initial_height : available_height) - rh used_height = 0 columns = calculate_column_widths(width) return false if columns.empty? @special_cells_fit_not_successful = false [@header_cells, @footer_cells].each do |special_cells| next unless special_cells special_used_height, last_fitted_row_index = special_cells.fit_rows(0, height, columns, frame) height -= special_used_height used_height += special_used_height @special_cells_fit_not_successful = (last_fitted_row_index != special_cells.number_of_rows - 1) return false if @special_cells_fit_not_successful end main_used_height, @last_fitted_row_index = @cells.fit_rows(@start_row_index, height, columns, frame) used_height += main_used_height @width = (@initial_width > 0 ? @initial_width : columns[-1].sum + rw) @height = (@initial_height > 0 ? @initial_height : used_height + rh) @fit_successful = (@last_fitted_row_index == @cells.number_of_rows - 1) end private # Calculates and returns the x-coordinates and widths of all columns based on the given total # available width. # # If it is not possible to fit all columns into the given +width+, an empty array is returned. def calculate_column_widths(width) @column_widths.concat([-1] * (@cells.number_of_columns - @column_widths.size)) fixed_width, variable_width = @column_widths.partition(&:positive?).map {|c| c.sum(&:abs) } rest_width = width - fixed_width return [] if rest_width <= 0 variable_width_unit = rest_width / variable_width.to_f position = 0 @column_widths.map do |column| result = column > 0 ? [position, column] : [position, column.abs * variable_width_unit] position += result[1] result end end # Splits the content of the column box. This method is called from Box#split. def split_content(_available_width, _available_height, _frame) if @special_cells_fit_not_successful || @last_fitted_row_index < 0 [nil, self] else box = create_split_box box.instance_variable_set(:@start_row_index, @last_fitted_row_index + 1) box.instance_variable_set(:@last_fitted_row_index, -1) box.instance_variable_set(:@special_cells_fit_not_successful, nil) header_cells = @header ? Cells.new(@header.call(self), cell_style: @cell_style) : nil box.instance_variable_set(:@header_cells, header_cells) footer_cells = @footer ? Cells.new(@footer.call(self), cell_style: @cell_style) : nil box.instance_variable_set(:@footer_cells, footer_cells) [self, box] end end # Draws the child boxes onto the canvas at position [x, y]. def draw_content(canvas, x, y) x += @cell_tl_border_width.left / 2.0 y += content_height - @cell_tl_border_width.top / 2.0 if @header_cells @header_cells.draw_rows(0, -1, canvas, x, y) y -= @header_cells[-1, 0].top + @header_cells[-1, 0].height end @cells.draw_rows(@start_row_index, @last_fitted_row_index, canvas, x, y) if @footer_cells y -= @cells[@last_fitted_row_index, 0].top + @cells[@last_fitted_row_index, 0].height @footer_cells.draw_rows(0, -1, canvas, x, y) end end end end end