# -*- 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-2024 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'
require 'hexapdf/layout/text_box'
require 'hexapdf/layout/text_fragment'
module HexaPDF
module Layout
# A ListBox arranges its children as unordered or ordered list items.
#
# The indentation of the contents from the left (#content_indentation) as well as the marker
# type of the items (#marker_type) can be specified. Additionally, it is possible to define the
# start number for ordered lists (#start_number) and the amount of spacing between items
# (#item_spacing).
#
# If the list box has padding and/or borders specified, they are handled like with any other
# box. This means they are around all items and their contents and are not used separately for
# each item.
#
# 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 list items will take the shape of the
# frame into account. This also means that the +available_width+ and +available_height+
# arguments are ignored.
class ListBox < Box
# Stores the information when fitting an item of the list box.
ItemResult = Struct.new(:box_fitter, :height, :marker, :marker_pos_x)
# The child boxes of this ListBox. They need to be finalized before #fit is called.
attr_reader :children
# The type of list item marker to be rendered before the list item contents.
#
# The following values are supported (and :disc is the default):
#
# :disc::
#
# Draws a filled disc for the items of the unordered list.
#
# #>pdf-composer100
# composer.box(:list, marker_type: :disc) do |list|
# list.lorem_ipsum_box(sentences: 1)
# end
#
# :circle::
#
# Draws an unfilled circle for the items of the unordered list.
#
# #>pdf-composer100
# composer.box(:list, marker_type: :circle) do |list|
# list.lorem_ipsum_box(sentences: 1)
# end
#
# :square::
#
# Draws a filled square for the items of the unordered list.
#
# #>pdf-composer100
# composer.box(:list, marker_type: :square) do |list|
# list.lorem_ipsum_box(sentences: 1)
# end
#
# :decimal::
#
# Draws the numbers in decimal form, starting from #start_number) for the items of
# the ordered list.
#
# #>pdf-composer100
# composer.box(:list, marker_type: :decimal) do |list|
# 5.times { list.lorem_ipsum_box(sentences: 1) }
# end
#
# custom marker::
#
# Additionally, it is possible to specify an object as value that responds to
# #call(document, box, index) where +document+ is the HexaPDF::Document, +box+ is the list
# box, and +index+ is the current item index, starting at 0. The return value needs to be a
# Box object which is then fit into the content indentation area and drawn.
#
# #>pdf-composer100
# image = lambda do |document, box, index|
# document.layout.image_box(machu_picchu, height: box.style.font_size)
# end
# composer.box(:list, marker_type: image) do |list|
# 2.times { list.lorem_ipsum_box(sentences: 1) }
# end
attr_reader :marker_type
# The start number when using a #marker_type that represents an ordered list.
#
# The default value for this is 1.
#
# Example:
#
# #>pdf-composer100
# composer.box(:list, marker_type: :decimal, start_number: 3) do |list|
# 2.times { list.lorem_ipsum_box(sentences: 1) }
# end
attr_reader :start_number
# The indentation of the list content in PDF points. The item marker will be inside this
# indentation.
#
# The default value is two times the font size.
#
# Example:
#
# #>pdf-composer100
# composer.box(:list) {|list| list.lorem_ipsum_box(sentences: 1) }
# composer.box(:list, content_indentation: 50) do |list|
# list.lorem_ipsum_box(sentences: 1)
# end
attr_reader :content_indentation
# The spacing between two consecutive list items.
#
# The default value is zero.
#
# Example:
#
# #>pdf-composer
# composer.box(:list, item_spacing: 10) do |list|
# 3.times { list.lorem_ipsum_box(sentences: 1) }
# end
attr_reader :item_spacing
# Creates a new ListBox object for the given child boxes in +children+.
def initialize(children: [], marker_type: :disc, content_indentation: nil, start_number: 1,
item_spacing: 0, **kwargs)
super(**kwargs)
@children = children
@marker_type = marker_type
@content_indentation = content_indentation || 2 * style.font_size
@start_number = start_number
@item_spacing = item_spacing
@results = nil
@results_item_marker_x = nil
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 list box.
def empty?
super && (!@results || @results.all? {|result| result.box_fitter.fit_results.empty? })
end
# Fits the list box into the current region of the frame.
def fit(available_width, available_height, frame)
@width = if @initial_width > 0
@initial_width
else
(style.position == :flow ? frame.width : available_width)
end
height = if @initial_height > 0
@initial_height - reserved_height
else
(style.position == :flow ? frame.y - frame.bottom : available_height) - reserved_height
end
width = @width - reserved_width
left = (style.position == :flow ? frame.left : frame.x) + reserved_width_left
top = frame.y - reserved_height_top
# The left side of the frame of an item is always indented, regardless of style.position
item_frame_left = left + @content_indentation
item_frame_width = width - @content_indentation
# We can remove the content indentation for a rectangle by just modifying left and width
unless style.position == :flow
left = item_frame_left
width = item_frame_width
end
@results = []
@children.each_with_index do |child, index|
item_result = ItemResult.new
shape = Geom2D::Polygon([left, top - height],
[left + width, top - height],
[left + width, top],
[left, top])
if style.position == :flow
shape = Geom2D::Algorithms::PolygonOperation.run(frame.shape, shape, :intersection)
remove_indent_from_frame_shape(shape) unless shape.polygons.empty?
end
item_frame = Frame.new(item_frame_left, top - height, item_frame_width, height,
shape: shape, context: frame.context)
if index != 0 || !split_box? || @split_box == :show_first_marker
box = item_marker_box(frame.document, index)
marker_frame = Frame.new(0, 0, content_indentation, height, context: frame.context)
break unless box.fit(content_indentation, height, marker_frame)
item_result.marker = box
item_result.marker_pos_x = item_frame.x - content_indentation
item_result.height = box.height
end
box_fitter = BoxFitter.new([item_frame])
Array(child).each {|ibox| box_fitter.fit(ibox) }
item_result.box_fitter = box_fitter
item_result.height = [item_result.height.to_i, box_fitter.content_heights[0]].max
@results << item_result
top -= item_result.height + item_spacing
height -= item_result.height + item_spacing
break if !box_fitter.fit_successful? || height <= 0
end
@height = @results.sum(&:height) + (@results.count - 1) * item_spacing + reserved_height
@draw_pos_x = frame.x + reserved_width_left
@draw_pos_y = frame.y - @height + reserved_height_bottom
@fit_successful = @results.all? {|r| r.box_fitter.fit_successful? } && @results.size == @children.size
end
private
# Removes the +content_indentation+ from the left side of the given shape (a Geom2D::PolygonSet).
def remove_indent_from_frame_shape(shape)
polygon_index = 0
data = []
# Determine the lower-left-most and upper-left-most vertices and their indices, together
# with the polygon index that holds them and the direction wrt to the indices from
# upper-left-most to lower-left-most.
shape.polygons.each_with_index do |polygon, pindex|
lower_vertex = upper_vertex = polygon[0]
lower_index = upper_index = 0
1.upto(polygon.nr_of_vertices - 1) do |i|
v = polygon[i]
if v.y < lower_vertex.y || (v.y == lower_vertex.y && v.x <= lower_vertex.x)
lower_vertex = v
lower_index = i
elsif v.y > upper_vertex.y || (v.y == upper_vertex.y && v.x <= upper_vertex.x)
upper_vertex = v
upper_index = i
end
end
direction = upper_vertex.x == polygon[(upper_index + 1) % polygon.nr_of_vertices].x ? 1 : -1
if data.empty? || data[0].x > lower_vertex.x
polygon_index = pindex
data = [lower_vertex, lower_index, upper_vertex, upper_index, direction]
end
end
# Now we have all the data to remove the indentation on the left side of the polygon. This
# is done by shifting all vertices between and including the lower-left-most and
# upper-left-most vertices to the right.
vertices = shape.polygons[polygon_index].to_a
point = data[2]
index = data[3]
while point != data[0]
vertices[index] = Geom2D::Point(point.x + content_indentation, point.y)
index = (index + data[4]) % vertices.size
point = vertices[index]
end
vertices[data[1]] = Geom2D::Point(data[0].x + content_indentation, data[0].y)
shape.polygons[polygon_index] = Geom2D::Polygon(*vertices)
end
# Splits the content of the list box. This method is called from Box#split.
def split_content(_available_width, _available_height, _frame)
remaining_boxes = @results[-1].box_fitter.remaining_boxes
first_is_split_box = remaining_boxes.first&.split_box?
children = (remaining_boxes.empty? ? [] : [remaining_boxes]) + @children[@results.size..-1]
box = create_split_box(split_box_value: first_is_split_box ? :hide_first_marker : :show_first_marker)
box.instance_variable_set(:@children, children)
box.instance_variable_set(:@start_number,
@start_number + @results.size + (first_is_split_box ? -1 : 0))
box.instance_variable_set(:@results, [])
[self, box]
end
# Creates a box for the item marker at the given item index, using #item_style to decide on
# its contents.
def item_marker_box(document, index)
return @marker_type.call(document, self, index) if @marker_type.kind_of?(Proc)
return @item_marker_box if defined?(@item_marker_box)
marker_style = {
font: style.font? ? style.font : document.fonts.add("Times"),
font_size: style.font_size || 10, fill_color: style.fill_color
}
fragment = case @marker_type
when :disc
TextFragment.create("•", marker_style)
when :circle
unless marker_style[:font].decode_codepoint("❍".ord).valid?
marker_style[:font] = document.fonts.add("ZapfDingbats")
end
TextFragment.create("❍", **marker_style,
font_size: style.font_size / 2.0,
text_rise: -style.font_size / 1.8)
when :square
unless marker_style[:font].decode_codepoint("■".ord).valid?
marker_style[:font] = document.fonts.add("ZapfDingbats")
end
TextFragment.create("■", **marker_style,
font_size: style.font_size / 2.0,
text_rise: -style.font_size / 1.8)
when :decimal
text = (@start_number + index).to_s << "."
TextFragment.create(text, marker_style)
else
raise HexaPDF::Error, "Unknown list marker type #{@marker_type.inspect}"
end
box = TextBox.new(items: [fragment], style: {text_align: :right, padding: [0, 5, 0, 0]})
@item_marker_box = box unless @marker_type == :decimal
box
end
# Draws the list items onto the canvas at position [x, y].
def draw_content(canvas, x, y)
translate = style.position != :flow && (x != @draw_pos_x || y != @draw_pos_y)
if translate
canvas.save_graphics_state
canvas.translate(x - @draw_pos_x, y - @draw_pos_y)
end
@results.each do |item_result|
box_fitter = item_result.box_fitter
if (marker = item_result.marker)
marker.draw(canvas, item_result.marker_pos_x,
box_fitter.frames[0].bottom + box_fitter.frames[0].height - marker.height)
end
box_fitter.fit_results.each {|result| result.draw(canvas) }
end
canvas.restore_graphics_state if translate
end
end
end
end