class HexaPDF::Layout::Frame

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:

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.

Attributes

available_height[R]

The available height for placing a box.

Also see the note in the x documentation for further information.

available_width[R]

The available width for placing a box.

Also see the note in the x documentation for further information.

bottom[R]

The y-coordinate of the bottom-left corner.

height[R]

The height of the frame.

left[R]

The x-coordinate of the bottom-left corner.

shape[R]

The shape of the frame, a Geom2D::PolygonSet consisting of rectilinear polygons.

width[R]

The width of the frame.

x[R]

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.

y[R]

The y-coordinate where the next box will be placed.

Also see the note in the x documentation for further information.

Public Class Methods

new(left, bottom, width, height, contour_line: nil) click to toggle source

Creates a new Frame object for the given rectangular area.

# File lib/hexapdf/layout/frame.rb, line 165
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

Public Instance Methods

contour_line() click to toggle source

The contour line of the frame, a Geom2D::PolygonSet consisting of arbitrary polygons.

# File lib/hexapdf/layout/frame.rb, line 340
def contour_line
  @contour_line || @shape
end
draw(canvas, box) click to toggle source

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.

# File lib/hexapdf/layout/frame.rb, line 229
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
find_next_region() click to toggle source

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.

# File lib/hexapdf/layout/frame.rb, line 301
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
fit(box) click to toggle source

Fits the given box into the current region of available space.

# File lib/hexapdf/layout/frame.rb, line 183
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
full?() click to toggle source

Returns true if the frame has no more space left.

# File lib/hexapdf/layout/frame.rb, line 335
def full?
  available_width == 0
end
remove_area(polygon) click to toggle source

Removes the given rectilinear polygon from both the frame's shape and the frame's contour line.

# File lib/hexapdf/layout/frame.rb, line 324
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
split(box) click to toggle source

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.

# File lib/hexapdf/layout/frame.rb, line 215
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
width_specification(offset = 0) click to toggle source

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.

# File lib/hexapdf/layout/frame.rb, line 356
def width_specification(offset = 0)
  WidthFromPolygon.new(contour_line, offset)
end

Private Instance Methods

create_rectangle(blx, bly, trx, try) click to toggle source

Creates a Geom2D::Polygon object representing the rectangle with the bottom left corner (blx, bly) and the top right corner (trx, try).

# File lib/hexapdf/layout/frame.rb, line 364
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
find_max_height_region() click to toggle source

Finds the region with the maximum height.

# File lib/hexapdf/layout/frame.rb, line 382
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
find_max_width_region() click to toggle source

Finds the region with the maximum width.

# File lib/hexapdf/layout/frame.rb, line 370
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
find_starting_point() click to toggle source

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.

# File lib/hexapdf/layout/frame.rb, line 417
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
sorted_horizontal_segments() click to toggle source

Returns the horizontal segments of the frame's shape, sorted by maximum y-, then minimum x-coordinate.

# File lib/hexapdf/layout/frame.rb, line 434
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
trim_shape() click to toggle source

Trims the frame's shape so that the next starting point is different.

# File lib/hexapdf/layout/frame.rb, line 399
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