# -*- 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/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 provided available width and height are 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', 'align', 'valign', 'margin' and 'mask_mode' 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 # # A frame's shape is used to determine the available space for laying out boxes. # # 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. # # It is also possible to provide a different initial shape on initialization. class Frame include HexaPDF::Utils # Stores the result of fitting a box in a Frame. class FitResult # The box that was fitted into the frame. attr_accessor :box # The horizontal position where the box will be drawn. attr_accessor :x # The vertical position where the box will be drawn. attr_accessor :y # The available width in the frame for this particular box. attr_accessor :available_width # The available height in the frame for this particular box. attr_accessor :available_height # The rectangle (a Geom2D::Rectangle object) that will be removed from the frame when # drawing the box. attr_accessor :mask # Initialize the result object for the given box. def initialize(box) @box = box @available_width = 0 @available_height = 0 @success = false end # Marks the fitting status as success. def success! @success = true end # Returns +true+ if fitting was successful. def success? @success end # Draws the #box onto the canvas at (#x, #y). # # The configuration option "debug" can be used to add visual debug output with respect to # box placement. def draw(canvas) doc = canvas.context.document if doc.config['debug'] name = "#{box.class} (#{x.to_i},#{y.to_i}-#{box.width.to_i}x#{box.height.to_i})" ocg = doc.optional_content.ocg(name) canvas.optional_content(ocg) do canvas.save_graphics_state do canvas.fill_color("green").stroke_color("darkgreen"). opacity(fill_alpha: 0.1, stroke_alpha: 0.2). draw(:geom2d, object: mask, path_only: true).fill_stroke end end doc.optional_content.default_configuration.add_ocg_to_ui(ocg, path: 'Debug') end box.draw(canvas, x, y) 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, either a Geom2D::Rectangle in the simple case or a # Geom2D::PolygonSet consisting of rectilinear polygons in the more complex case. 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 of the current region for placing a box. # # Also see the note in the #x documentation for further information. attr_reader :available_width # The available height of the current region for placing a box. # # Also see the note in the #x documentation for further information. attr_reader :available_height # The context object (a HexaPDF::Type::Page or HexaPDF::Type::Form) for which this frame # should be used. attr_reader :context # Creates a new Frame object for the given rectangular area. def initialize(left, bottom, width, height, shape: nil, context: nil) @left = left @bottom = bottom @width = width @height = height @shape = shape || create_rectangle(left, bottom, left + width, bottom + height) @context = context @x = left @y = bottom + height @available_width = width @available_height = height find_max_width_region if shape @region_selection = :max_height end # Returns the HexaPDF::Document instance (through #context) that is associated with this Frame # object or +nil+ if no context object has been set. def document @context&.document end # Fits the given box into the current region of available space and returns a FitResult # object. # # Fitting a box takes the style properties 'position', 'align', 'valign', 'margin', and # 'mask_mode' into account. # # Use the FitResult#success? method to determine whether fitting was successful. def fit(box) fit_result = FitResult.new(box) return fit_result if full? margin = box.style.margin if box.style.margin? position = if box.style.position != :flow || box.supports_position_flow? box.style.position else :default end if position.kind_of?(Array) x, y = box.style.position aw = width - x ah = height - y box.fit(aw, ah, self) fit_result.success! x += left y += bottom else aw = available_width ah = available_height margin_top = margin_right = margin_left = margin_bottom = 0 if margin aw -= margin_right = margin.right unless float_equal(@x + aw, @left + @width) aw -= margin_left = margin.left unless float_equal(@x, @left) ah -= margin_bottom = margin.bottom unless float_equal(@y - ah, @bottom) ah -= margin_top = margin.top unless float_equal(@y, @bottom + @height) end fit_result.success! if box.fit(aw, ah, self) width = box.width height = box.height case position when :default, :float x = case box.style.align when :left @x + margin_left when :right @x + margin_left + aw - width when :center max_margin = [margin_left, 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 + margin_left + (aw - width) / 2.0 end end y = case box.style.valign when :top @y - margin_top - height when :bottom @y - available_height + margin_bottom when :center max_margin = [margin_top, margin_bottom].max # If we have enough space left for equal margins, we center perfectly if available_height - height >= 2 * max_margin @y - height - (available_height - height) / 2.0 else @y - margin_top - height - (ah - height) / 2.0 end end when :flow x = 0 y = @y - height else raise HexaPDF::Error, "Invalid value '#{position}' for style property position" end end mask_mode = if box.style.mask_mode == :default case position when :default, :flow then :fill_frame_horizontal else :box end else box.style.mask_mode end rectangle = case mask_mode when :none create_rectangle(x, y, x, y) when :box if margin create_rectangle([left, x - (margin&.left || 0)].max, [bottom, y - (margin&.bottom || 0)].max, [left + self.width, x + box.width + (margin&.right || 0)].min, [bottom + self.height, y + box.height + (margin&.top || 0)].min) else create_rectangle(x, y, x + box.width, y + box.height) end when :fill_horizontal create_rectangle(@x, [bottom, y - (margin&.bottom || 0)].max, @x + available_width, [@y, y + box.height + (margin&.top || 0)].min) when :fill_frame_horizontal create_rectangle(left, [bottom, y - (margin&.bottom || 0)].max, left + self.width, @y) when :fill_vertical create_rectangle([@x, x - (margin&.left || 0)].max, @y - available_height, [@x + available_width, x + box.width + (margin&.right || 0)].min, @y) when :fill create_rectangle(@x, @y - available_height, @x + available_width, @y) end fit_result.available_width = aw fit_result.available_height = ah fit_result.x = x fit_result.y = y fit_result.mask = rectangle fit_result end # Tries to split the box of the given FitResult into two parts and returns both parts. # # See Box#split for further details. def split(fit_result) fit_result.box.split(fit_result.available_width, fit_result.available_height, self) end # Draws the box of the given FitResult onto the canvas at the fitted position. # # After a box is successfully drawn, the frame's shape is adjusted to remove the occupied # area. def draw(canvas, fit_result) return if fit_result.box.height == 0 || fit_result.box.width == 0 fit_result.draw(canvas) remove_area(fit_result.mask) end # Finds the next region for placing boxes. Returns +false+ if no useful region was found. # # This method should be called after fitting or drawing a box 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 if @shape.kind_of?(Geom2D::Rectangle) @x = @shape.x @y = @shape.y + @shape.height @available_width = @shape.width @available_height = @shape.height @region_selection = :trim_shape else find_max_width_region @region_selection = :max_height end 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 available_width != 0 end # Removes the given *rectilinear* polygon from the frame's shape. def remove_area(polygon) return if polygon.kind_of?(Geom2D::Rectangle) && (polygon.width == 0 || polygon.height == 0) @shape = if @shape.kind_of?(Geom2D::Rectangle) && polygon.kind_of?(Geom2D::Rectangle) && float_equal(@shape.x, polygon.x) && float_equal(@shape.width, polygon.width) && float_equal(@shape.y + @shape.height, polygon.y + polygon.height) if float_equal(@shape.height, polygon.height) Geom2D::PolygonSet() else Geom2D::Rectangle(@shape.x, @shape.y, @shape.width, @shape.height - polygon.height) end else Geom2D::Algorithms::PolygonOperation.run(@shape, 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 # Returns a width specification for the frame's shape 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 # shape 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(shape, 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::Rectangle(blx, bly, trx - blx, try - bly) 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 @x = @y = @available_width = @available_height = 0 return if @shape.kind_of?(Geom2D::Rectangle) || !(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 } segment = segments[index] y = segment.start_point.y polygon = if segment.min.x == @x # Trim the rectangular part from the left to the segment's length Geom2D::Polygon([@x, @y], [@x, y], [@x + segment.length, y], [@x + segment.length, @y]) else # Trim the whole slice between the two top-most segments Geom2D::Polygon([left, y], [left + width, y], [left + width, @y], [left, @y]) end remove_area(polygon) 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