# -*- 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/box_fitter'
module HexaPDF
module Layout
# A ColumnBox arranges boxes in one or more columns.
#
# The number and width of the columns as well as the size of the gap between the columns can be
# modified. Additionally, the contents can either fill the columns one after the other or the
# columns can be made equally high.
#
# If the column box has padding and/or borders specified, they are handled like with any other
# box. This means they are around all columns and their contents and are not used separately for
# each column.
#
# The following style properties are used (additionally to those used by the parent class):
#
# Style#position::
# If this is set to :flow, the frames created for the columns will take the shape of the
# frame into account. This also means that the +available_width+ and +available_height+
# arguments are ignored.
class ColumnBox < Box
# The child boxes of this ColumnBox. They need to be finalized before #fit is called.
attr_reader :children
# The columns definition.
#
# If the value is an array, it needs to contain the widths of the columns. The size of the
# array determines the number of columns. Otherwise, if the value is an integer, the value
# defines the number of equally sized columns, i.e. a value of +N+ is equal to [-1]*N.
#
# If a negative integer is used for the width, the 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 definition is [-1, -2, -2], the first column is a fifth of
# the width and the other columns are each two fifth of the width.
#
# Examples:
#
# #>pdf-composer
# composer.box(:column, columns: 2, gaps: 10,
# children: [composer.document.layout.lorem_ipsum_box])
#
# ---
#
# #>pdf-composer
# composer.box(:column, columns: [50, -2, -1], gaps: [10, 5],
# children: [composer.document.layout.lorem_ipsum_box])
attr_reader :columns
# The size of the gaps between the columns.
#
# This is an array containing the width of the gaps. If there are more gaps than numbers in
# the array, the array is cycled.
#
# Examples: see #columns
attr_reader :gaps
# Determines whether the columns should all be equally high or not.
#
# Examples:
#
# #>pdf-composer
# composer.box(:column, children: [composer.document.layout.lorem_ipsum_box])
#
# ---
#
# #>pdf-composer
# composer.box(:column, equal_height: false,
# children: [composer.document.layout.lorem_ipsum_box])
attr_reader :equal_height
# Creates a new ColumnBox object for the given child boxes in +children+.
#
# +columns+::
#
# Can either simply integer specify the number of columns or be a full column definition
# (see #columns for details).
#
# +gaps+::
# Can either be a simply integer specifying the width between two columns or a full gap
# definition (see #gap for details).
#
# +equal_height+::
# If +true+, the #fit method tries to balance the columns in terms of their height.
# Otherwise the columns are filled from the left.
def initialize(children: [], columns: 2, gaps: 36, equal_height: true, **kwargs)
super(**kwargs)
@children = children
@columns = (columns.kind_of?(Array) ? columns : [-1] * columns)
@gaps = (gaps.kind_of?(Array) ? gaps : [gaps])
@equal_height = equal_height
end
# Returns +true+ as the 'position' style property value :flow is supported.
def supports_position_flow?
true
end
# Returns +true+ if no box was fitted into the columns.
def empty?
super && (!@box_fitter || @box_fitter.fit_results.empty?)
end
# Fits the column box into the available space.
#
# If the style property 'position' is set to :flow, the columns might not be rectangles but
# arbitrary (sets of) polygons since the +frame+s shape is taken into account.
def fit(available_width, available_height, frame)
initial_fit_successful = (@equal_height ? nil : false)
tries = 0
@width = if style.position == :flow
(@initial_width > 0 ? @initial_width : frame.width) - reserved_width
else
(@initial_width > 0 ? @initial_width : available_width) - reserved_width
end
height = if style.position == :flow
(@initial_height > 0 ? @initial_height : frame.height) - reserved_height
else
(@initial_height > 0 ? @initial_height : available_height) - reserved_height
end
columns = calculate_columns(@width)
return false if columns.empty?
left = (style.position == :flow ? frame.left : frame.x) + reserved_width_left
top = (style.position == :flow ? frame.bottom + frame.height : frame.y) - reserved_height_top
successful_height = height
unsuccessful_height = 0
while true
@box_fitter = BoxFitter.new
columns.each do |col_x, column_width|
column_left = left + col_x
column_bottom = top - height
if style.position == :flow
rect = Geom2D::Polygon([column_left, column_bottom],
[column_left + column_width, column_bottom],
[column_left + column_width, column_bottom + height],
[column_left, column_bottom + height])
shape = Geom2D::Algorithms::PolygonOperation.run(frame.shape, rect, :intersection)
end
column_frame = Frame.new(column_left, column_bottom, column_width, height, shape: shape)
@box_fitter << column_frame
end
children.each {|box| @box_fitter.fit(box) }
fit_successful = @box_fitter.fit_successful?
initial_fit_successful = fit_successful if initial_fit_successful.nil?
if fit_successful
successful_height = height if successful_height > height
elsif unsuccessful_height < height
unsuccessful_height = height
end
break if !initial_fit_successful || tries > 40 ||
(fit_successful && successful_height - unsuccessful_height < 10)
height = if successful_height - unsuccessful_height <= 5
successful_height
else
(successful_height + unsuccessful_height) / 2.0
end
tries += 1
end
@width = columns[-1].sum + reserved_width
@height = @box_fitter.content_heights.max + reserved_height
@draw_pos_x = frame.x + reserved_width_left
@draw_pos_y = frame.y - @height + reserved_height_bottom
@box_fitter.fit_successful?
end
private
# Calculates 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_columns(width)
number_of_columns = @columns.size
gaps = @gaps.cycle.take(number_of_columns - 1)
fixed_width, variable_width = @columns.partition(&:positive?).map {|c| c.sum(&:abs) }
rest_width = width - fixed_width - gaps.sum
return [] if rest_width <= 0
variable_width_unit = rest_width / variable_width.to_f
position = 0
@columns.map.with_index do |column, index|
result = if column > 0
[position, column]
else
[position, column.abs * variable_width_unit]
end
position += result[1] + (gaps[index] || 0)
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)
box = create_split_box
box.instance_variable_set(:@children, @box_fitter.remaining_boxes)
[self, box]
end
# Draws the child boxes onto the canvas at position [x, y].
def draw_content(canvas, x, y)
if style.position != :flow && (x != @draw_pos_x || y != @draw_pos_y)
canvas.translate(x - @draw_pos_x, y - @draw_pos_y) do
@box_fitter.fit_results.each {|result| result.draw(canvas) }
end
else
@box_fitter.fit_results.each {|result| result.draw(canvas) }
end
end
end
end
end