class HexaPDF::Layout::WidthFromPolygon

Utility class for generating width specifications for TextLayouter#fit from polygons.

Public Class Methods

new(polygon, offset = 0) click to toggle source

Creates a new object for the given polygon (or polygon set) and immediately prepares it so that call can be used.

The offset argument specifies the vertical offset from the top at which calculations should start.

# File lib/hexapdf/layout/width_from_polygon.rb, line 47
def initialize(polygon, offset = 0)
  @polygon = polygon
  prepare(offset)
end

Public Instance Methods

call(height, line_height) click to toggle source

Returns the width specification for the given values with respect to the wrapped polygon.

# File lib/hexapdf/layout/width_from_polygon.rb, line 53
def call(height, line_height)
  width(@max_y - height - line_height, @max_y - height)
end

Private Instance Methods

prepare(offset) click to toggle source

Prepare the segments and other data for later use.

# File lib/hexapdf/layout/width_from_polygon.rb, line 199
def prepare(offset)
  @max_y = @polygon.bbox.max_y - offset
  @polygon_segments = if @polygon.nr_of_contours > 1
                        @polygon.polygons.map {|polygon| process_polygon(polygon) }
                      else
                        [process_polygon(@polygon)]
                      end
end
process_polygon(polygon) click to toggle source

Processes the given polygon segment by segment and returns an array with the following processing information for each segment of the polygon:

  • the segment itself

  • minimum y-value

  • maximum y-value

  • x-value corresponding to the minimum y-value

  • x-value corresponding to the maximum y-value

  • whether the segment is vertical

  • for non-vertical segments: slope and y-intercept of the segment

Additionally, the returned array is rotated sothat the data for the segment with the minimum x-value is the first item (without changing the order).

# File lib/hexapdf/layout/width_from_polygon.rb, line 221
def process_polygon(polygon)
  rotate_nr = 0
  min_x = Float::INFINITY
  segments = polygon.each_segment.reject(&:horizontal?)
  segments.map!.with_index do |segment, index|
    (rotate_nr = index; min_x = segment.min.x) if segment.min.x < min_x
    data = [segment]
    if segment.start_point.y < segment.end_point.y
      data.push(segment.start_point.y, segment.end_point.y,
                segment.start_point.x, segment.end_point.x)
    else
      data.push(segment.end_point.y, segment.start_point.y,
                segment.end_point.x, segment.start_point.x)
    end
    data.push(segment.vertical?)
    unless segment.vertical?
      data.push(segment.slope)
      data.push((segment.start_point.y - segment.slope * segment.start_point.x).to_f)
    end
    data
  end
  segments.rotate!(rotate_nr)
end
width(y1, y2) click to toggle source

Calculates the width specification for the area between the horizontal lines at y1 < y2.

The following algorithm is used: Given y1 < y2 as the horizontal lines between which text should be layed out, and a polygon set p that is not self-intersecting but may have arbitrarily nested holes:

  • Get all segments of the polygon set in sequence, removing the horizontal segments in the process (done in prepare).

  • Make sure that the first segment represents a left-most outside-inside transition, rotate array of segments (separate for each polygon) if necessary. (done in prepare)

  • For the segments of each polygon do separately:

    • Ignore all segments except those with min_y < y2 and max_y > y1.

    • Determine the min_x and max_x of the segment within y1 <= y2.

    • If the segment crosses both, y1 and y2, store min_x/max_x and this segment is finished. Otherwise traverse the segments in-order to find the next crossing, updating min_x/max_x in the process. If it crosses the other line, the result is the same as if a single segment had crossed both lines. Otherwise the result depends on whether the segment sequence represents an outside-inside transition (it is ignored) or inside-outside transition (store two pairs min_x/min_x and max_x/max_x).

  • Order stored x-values.

  • For each pair [a_min, a_max], [b_min, b_max]

    • if inside (index is even): calculate width = b_min - a_max

    • if outside: calculate offset = b_max - a_min

  • Prepend a0_max for first offset and remove all offset-width pairs where width is zero.

# File lib/hexapdf/layout/width_from_polygon.rb, line 91
def width(y1, y2)
  result = []

  @polygon_segments.each do |segments|
    temp_result = []
    status = if segments.first[0].start_point.y > y2 || segments.first[0].start_point.y < y1
               :outside
             else
               :inside
             end

    segments.each do |_segment, miny, maxy, minyx, maxyx, vertical, slope, intercept|
      next unless miny < y2 && maxy > y1

      if vertical
        min_x = max_x = minyx
      else
        min_x = (miny <= y1 ? (y1 - intercept) / slope : (miny <= y2 ? minyx : maxyx))
        max_x = (maxy >= y2 ? (y2 - intercept) / slope : (miny >= y1 ? minyx : maxyx))
        min_x, max_x = max_x, min_x if min_x > max_x
      end

      if miny <= y1 && maxy >= y2 # segment crosses both lines
        temp_result << [min_x, max_x, :crossed_both]
      elsif miny <= y1 # segment crosses bottom line
        if status == :outside
          temp_result << [min_x, max_x, :crossed_bottom]
          status = :inside
        elsif temp_result.last
          temp_result.last[0] = min_x if temp_result.last[0] > min_x
          temp_result.last[1] = max_x if temp_result.last[1] < max_x
          temp_result.last[2] = :crossed_both if temp_result.last[2] == :crossed_top
          temp_result.last[2] = :crossed_bottom if temp_result.last[2] == :crossed_none
          status = :outside
        else
          temp_result << [min_x, max_x, :crossed_bottom]
          status = :outside
        end
      elsif maxy >= y2 # segment crosses top line
        if status == :outside
          temp_result << [min_x, max_x, :crossed_top]
          status = :inside
        elsif temp_result.last
          temp_result.last[0] = min_x if temp_result.last[0] > min_x
          temp_result.last[1] = max_x if temp_result.last[1] < max_x
          temp_result.last[2] = :crossed_both if temp_result.last[2] == :crossed_bottom
          temp_result.last[2] = :crossed_top if temp_result.last[2] == :crossed_none
          status = :outside
        else
          temp_result << [min_x, max_x, :crossed_top]
          status = :outside
        end
      elsif status == :inside && temp_result.last # segment crosses no line
        temp_result.last[0] = min_x if temp_result.last[0] > min_x
        temp_result.last[1] = max_x if temp_result.last[1] < max_x
      else # first segment completely inside
        temp_result << [min_x, max_x, :crossed_none]
      end
    end

    if temp_result.empty? # Ignore degenerate results
      next
    elsif temp_result.size == 1
      # either polygon completely inside or just the top/bottom part, handle the same
      temp_result[0][2] = :crossed_top
    elsif temp_result[0][2] != :crossed_both && temp_result[-1][2] != :crossed_both
      # Handle case where first and last segments only crosses one line
      temp_result[0][0] = temp_result[-1][0] if temp_result[0][0] > temp_result[-1][0]
      temp_result[0][1] = temp_result[-1][1] if temp_result[0][1] < temp_result[-1][1]
      temp_result[0][2] = :crossed_both if temp_result[0][2] != temp_result[-1][2]
      temp_result.pop
    end

    result.concat(temp_result)
  end

  temp_result = result
  outside = true
  temp_result.sort_by! {|a| a[0] }.map! do |min, max, stat|
    if stat == :crossed_both
      outside = !outside
      [min, max]
    elsif outside
      []
    else
      [min, min, max, max]
    end
  end.flatten!
  temp_result.unshift(0, 0)

  i = 0
  result = []
  while i < temp_result.size - 2
    if i % 4 == 2 # inside the polygon, i.e. width (min2 - max1)
      if (width = temp_result[i + 2] - temp_result[i + 1]) > 0
        result << width
      else
        result.pop # remove last offset and don't add width
      end
    else # outside the polygon, i.e. offset (max2 - min1)
      result << temp_result[i + 3] - temp_result[i + 0]
    end
    i += 2
  end
  result.empty? ? [0, 0] : result
end