# -*- 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-2020 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/width_from_polygon' require 'geom2d/polygon' module HexaPDF module Layout # A Frame describes the available space for placing boxes and provides additional methods for # calculating the needed information for the actual placement. # # == Usage # # After a Frame object is initialized, it is ready for drawing boxes on it. # # The explicit way of drawing a box follows these steps: # # * Call #fit with the box to see if the box can fit into the currently selected region of # available space. If fitting is successful, the box can be drawn using #draw. # # The method #fit is also called for absolutely positioned boxes but since these boxes are not # subject to the normal constraints, the available space used is the width and height inside # the frame to the right and top of the bottom-left corner of the box. # # * If the box didn't fit, call #find_next_region to determine the next region for placing the # box. If a new region was found, start over with #fit. Otherwise the frame has no more space # for placing boxes. # # * Alternatively to calling #find_next_region it is also possible to call #split. This method # tries to split the box into two so that the first part fits into the current region. If # splitting is successful, the first box can be drawn (Make sure that the second box is # handled correctly). Otherwise, start over with #find_next_region. # # For applications where splitting is not necessary, an easier way is to just use #draw and # #find_next_region together, as #draw calls #fit if the box was not fit into the current # region. # # == Used Box Properties # # The style properties "position", "position_hint" and "margin" are taken into account when # fitting, splitting or drawing a box. Note that the margin is ignored if a box's side coincides # with the frame's original boundary. # # == Frame Shape and Contour Line # # A frame's shape is used to determine the available space for laying out boxes and its contour # line is used whenever text should be flown around objects. They are normally the same but can # differ if a box with an arbitrary contour line is drawn onto the frame. # # Initially, a frame has a rectangular shape. However, once boxes are added and the frame's # available area gets reduced, a frame may have a polygon set consisting of arbitrary # rectilinear polygons as shape. # # In contrast to the frame's shape its contour line may be a completely arbitrary polygon set. class Frame include Geom2D::Utils # Internal class for storing data of a fitted box. class FitData # The box that was fitted into the frame. attr_accessor :box # The available width for this particular box. attr_accessor :available_width # The available height for this particular box. attr_accessor :available_height # The left margin to use instead of +box.style.margin.left+. attr_accessor :margin_left # The right margin to use instead of +box.style.margin.right+. attr_accessor :margin_right # The top margin to use instead of +box.style.margin.top+. attr_accessor :margin_top # Initialize the object by calling #reset. def initialize reset end # Resets the object. def reset(box = nil, available_width = 0, available_height = 0) @box = box @available_width = available_width @available_height = available_height @margin_left = @margin_right = @margin_top = 0 end end # The x-coordinate of the bottom-left corner. attr_reader :left # The y-coordinate of the bottom-left corner. attr_reader :bottom # The width of the frame. attr_reader :width # The height of the frame. attr_reader :height # The shape of the frame, a Geom2D::PolygonSet consisting of rectilinear polygons. attr_reader :shape # The x-coordinate where the next box will be placed. # # Note: Since the algorithm for drawing takes the margin of a box into account, the actual # x-coordinate (and y-coordinate, available width and available height) might be different. attr_reader :x # The y-coordinate where the next box will be placed. # # Also see the note in the #x documentation for further information. attr_reader :y # The available width for placing a box. # # Also see the note in the #x documentation for further information. attr_reader :available_width # The available height for placing a box. # # Also see the note in the #x documentation for further information. attr_reader :available_height # Creates a new Frame object for the given rectangular area. def initialize(left, bottom, width, height, contour_line: nil) @left = left @bottom = bottom @width = width @height = height @contour_line = contour_line @shape = Geom2D::PolygonSet.new( [create_rectangle(left, bottom, left + width, bottom + height)] ) @x = left @y = bottom + height @available_width = width @available_height = height @region_selection = :max_height @fit_data = FitData.new end # Fits the given box into the current region of available space. def fit(box) aw = available_width ah = available_height @fit_data.reset(box, aw, ah) if full? false elsif box.style.position == :absolute x, y = box.style.position_hint box.fit(width - x, height - y, self) true else if box.style.margin? margin = box.style.margin ah -= margin.bottom unless float_equal(@y - ah, @bottom) ah -= @fit_data.margin_top = margin.top unless float_equal(@y, @bottom + @height) aw -= @fit_data.margin_right = margin.right unless float_equal(@x + aw, @left + @width) aw -= @fit_data.margin_left = margin.left unless float_equal(@x, @left) @fit_data.available_width = aw @fit_data.available_height = ah end box.fit(aw, ah, self) end end # Tries to split the (fitted) box into two parts, where the first part needs to fit into the # available space, and returns both parts. # # If the given box is not the last fitted box, #fit is called before splitting the box. # # See Box#split for further details. def split(box) fit(box) unless box == @fit_data.box boxes = box.split(@fit_data.available_width, @fit_data.available_height, self) @fit_data.reset unless boxes[0] == @fit_data.box boxes end # Draws the given (fitted) box onto the canvas at the frame's current position. Returns +true+ # if drawing was possible, +false+ otherwise. # # If the given box is not the last fitted box, #fit is called before drawing the box. # # After a box is successfully drawn, the frame's shape and contour line are adjusted to remove # the occupied area. def draw(canvas, box) unless box == @fit_data.box fit(box) || return end width = box.width height = box.height margin = box.style.margin if box.style.margin? if height == 0 @fit_data.reset return true end case box.style.position when :absolute x, y = box.style.position_hint x += left y += bottom rectangle = if box.style.margin? create_rectangle(x - margin.left, y - margin.bottom, x + width + margin.right, y + height + margin.top) else create_rectangle(x, y, x + width, y + height) end when :float x = @x + @fit_data.margin_left x += @fit_data.available_width - width if box.style.position_hint == :right y = @y - height - @fit_data.margin_top # We use the real margins from the box because they either have the desired effect or just # extend the rectangle outside the frame. rectangle = create_rectangle(x - (margin&.left || 0), y - (margin&.bottom || 0), x + width + (margin&.right || 0), @y) when :flow x = 0 y = @y - height rectangle = create_rectangle(left, y, left + self.width, @y) else x = case box.style.position_hint when :right @x + @fit_data.margin_left + @fit_data.available_width - width when :center max_margin = [@fit_data.margin_left, @fit_data.margin_right].max # If we have enough space left for equal margins, we center perfectly if available_width - width >= 2 * max_margin @x + (available_width - width) / 2.0 else @x + @fit_data.margin_left + (@fit_data.available_width - width) / 2.0 end else @x + @fit_data.margin_left end y = @y - height - @fit_data.margin_top rectangle = create_rectangle(left, y - (margin&.bottom || 0), left + self.width, @y) end box.draw(canvas, x, y) remove_area(rectangle) @fit_data.reset true end # Finds the next region for placing boxes. Returns +false+ if no useful region was found. # # This method should be called after drawing a box using #draw was not successful. It finds a # different region on each invocation. So if a box doesn't fit into the first region, this # method should be called again to find another region and to try again. # # The first tried region starts at the top-most, left-most vertex of the polygon and uses the # maximum width. The next tried region uses the maximum height. If both don't work, part of # the frame's shape is removed to try again. def find_next_region case @region_selection when :max_width find_max_width_region @region_selection = :max_height when :max_height x, y, aw, ah = @x, @y, @available_width, @available_height find_max_height_region if @x == x && @y == y && @available_width == aw && @available_height == ah trim_shape else @region_selection = :trim_shape end else trim_shape end @fit_data.reset available_width != 0 end # Removes the given *rectilinear* polygon from both the frame's shape and the frame's contour # line. def remove_area(polygon) @shape = Geom2D::Algorithms::PolygonOperation.run(@shape, polygon, :difference) if @contour_line @contour_line = Geom2D::Algorithms::PolygonOperation.run(@contour_line, polygon, :difference) end @region_selection = :max_width find_next_region end # Returns +true+ if the frame has no more space left. def full? available_width == 0 end # The contour line of the frame, a Geom2D::PolygonSet consisting of arbitrary polygons. def contour_line @contour_line || @shape end # Returns a width specification for the frame's contour line that can be used, for example, # with TextLayouter. # # Since not all text may start at the top of the frame, the offset argument can be used to # specify a vertical offset from the top of the frame where layouting should start. # # To be compatible with TextLayouter, the top left corner of the bounding box of the frame's # contour line is the origin of the coordinate system for the width specification, with # positive x-values to the right and positive y-values downwards. # # Depending on the complexity of the frame, the result may be any of the allowed width # specifications of TextLayouter#fit. def width_specification(offset = 0) WidthFromPolygon.new(contour_line, offset) end private # Creates a Geom2D::Polygon object representing the rectangle with the bottom left corner # (blx, bly) and the top right corner (trx, try). def create_rectangle(blx, bly, trx, try) Geom2D::Polygon(Geom2D::Point(blx, bly), Geom2D::Point(trx, bly), Geom2D::Point(trx, try), Geom2D::Point(blx, try)) end # Finds the region with the maximum width. def find_max_width_region return unless (segments = find_starting_point) x_right = @x + @available_width # Available height can be determined by finding the segment with the highest y-coordinate # which lies (maybe only partly) between the vertical lines @x and x_right. segments.select! {|s| s.max.x > @x && s.min.x < x_right } @available_height = @y - segments.last.start_point.y end # Finds the region with the maximum height. def find_max_height_region return unless (segments = find_starting_point) # Find segment with maximum y-coordinate directly below (@x,@y), this determines the # available height index = segments.rindex {|s| s.min.x <= @x && @x < s.max.x } y1 = segments[index].start_point.y @available_height = @y - y1 # Find segment with minium min.x coordinate whose y-coordinate is between y1 and @y and # min.x > @x, for getting the available width segments.select! {|s| s.min.x > @x && y1 <= s.start_point.y && s.start_point.y <= @y } segment = segments.min_by {|s| s.min.x } @available_width = segment.min.x - @x if segment end # Trims the frame's shape so that the next starting point is different. def trim_shape return unless (segments = find_starting_point) # Just use the second top-most segment # TODO: not the optimal solution! index = segments.rindex {|s| s.start_point.y < @y } y = segments[index].start_point.y remove_area(Geom2D::Polygon([left, y], [left + width, y], [left + width, @y], [left, @y])) end # Finds and sets the top-left point for the next region. This is always the top-most, # left-most vertex of the frame's shape. # # If successful, additionally sets the available width to the length of the segment containing # the point and returns the sorted horizontal segments except the top-most one. # # Otherwise, sets all region specific values to zero and returns +nil+. def find_starting_point segments = sorted_horizontal_segments if segments.empty? @x = @y = @available_width = @available_height = 0 return end top_segment = segments.pop @x = top_segment.min.x @y = top_segment.start_point.y @available_width = top_segment.length segments end # Returns the horizontal segments of the frame's shape, sorted by maximum y-, then minimum # x-coordinate. def sorted_horizontal_segments @shape.each_segment.select(&:horizontal?).sort! do |a, b| if a.start_point.y == b.start_point.y b.start_point.x <=> a.start_point.x else a.start_point.y <=> b.start_point.y end end end end end end