class HexaPDF::Layout::TextLayouter::SimpleLineWrapping

Implementation of a simple line wrapping algorithm.

The algorithm arranges the given items so that the maximum number is put onto each line, taking the differences of Box, Glue and Penalty items into account. It is not as advanced as say Knuth's line wrapping algorithm in that it doesn't optimize paragraphs.

Public Class Methods

call(items, width_block) {|line, item| block } → rest click to toggle source

Arranges the items into lines.

The width_block argument has to be a callable object that returns the width of the line:

  • If the line width doesn't depend on the height or the vertical position of the line (i.e. fixed line width), the width_block should have an arity of zero. However, this doesn't mean that the block is called only once; it is actually called before each new line (e.g. for varying line widths that don't depend on the line height; one common case is the indentation of the first line). This is the general case.

  • However, if lines should have varying widths (e.g. for flowing text around shapes), the width_block argument should be an object responding to call(line_like) where line_like is a Line-like object responding to y_min, y_max and height holding the values for the currently layed out line. The caller is responsible for tracking the height of the already layed out lines. This method involves more work and is therefore slower.

Regardless of whether varying line widths are used or not, each time a line is finished, it is yielded to the caller. The second argument item is the item that caused the line break (e.g. a Box, Glue or Penalty). The return value should be truthy if line wrapping should continue, or falsy if it should stop. If the yielded line is empty and the yielded item is a box item, this single item didn't fit into the available width; the caller has to handle this situation, e.g. by stopping.

In case of varying widths, the width_block may also return nil in which case the algorithm should revert back to a stored item index and then start as if beginning a new line. Which index to use is told the algorithm through the special return value :store_start_of_line of the yielded-to block. When this return value is used, the current start of the line index should be stored for later use.

After the algorithm is finished, it returns the unused items.

# File lib/hexapdf/layout/text_layouter.rb, line 337
def self.call(items, width_block, &block)
  obj = new(items, width_block)
  if width_block.arity == 1
    obj.variable_width_wrapping(&block)
  else
    obj.fixed_width_wrapping(&block)
  end
end
new(items, width_block) click to toggle source

Creates a new line wrapping object that arranges the items on lines with the given width.

# File lib/hexapdf/layout/text_layouter.rb, line 350
def initialize(items, width_block)
  @items = items
  @width_block = width_block
  @line_items = []
  @width = 0
  @glue_items = []
  @beginning_of_line_index = 0
  @last_breakpoint_index = 0
  @last_breakpoint_line_items_index = 0
  @break_prohibited_state = false

  @height_calc = Line::HeightCalculator.new
  @line = DummyLine.new(0, 0)

  @available_width = @width_block.call(@line)
end

Public Instance Methods

fixed_width_wrapping() { |create_line, item| ... } click to toggle source

Peforms line wrapping with a fixed width per line, with line height playing no role.

# File lib/hexapdf/layout/text_layouter.rb, line 368
def fixed_width_wrapping
  index = 0

  while (item = @items[index])
    case item.type
    when :box
      unless add_box_item(item.item)
        if @break_prohibited_state
          index = reset_line_to_last_breakpoint_state
          item = @items[index]
        end
        break unless yield(create_line, item)
        reset_after_line_break(index)
        redo
      end
    when :glue
      unless add_glue_item(item.item, index)
        break unless yield(create_line, item)
        reset_after_line_break(index + 1)
      end
    when :penalty
      if item.penalty <= -Penalty::INFINITY
        add_box_item(item.item) if item.width > 0
        break unless yield(create_unjustified_line, item)
        reset_after_line_break(index + 1)
      elsif item.penalty >= Penalty::INFINITY
        @break_prohibited_state = true
        add_box_item(item.item) if item.width > 0
      elsif item.width > 0
        if item_fits_on_line?(item)
          next_index = index + 1
          next_item = @items[next_index]
          next_item = @items[next_index += 1] while next_item&.type == :penalty
          if next_item && !item_fits_on_line?(next_item)
            @line_items.concat(@glue_items).push(item.item)
            @width += item.width
          end
          update_last_breakpoint(index)
        else
          @break_prohibited_state = true
        end
      else
        update_last_breakpoint(index)
      end
    end

    index += 1
  end

  line = create_unjustified_line
  last_line_used = true
  last_line_used = yield(line, nil) if item.nil? && !line.items.empty?

  item.nil? && last_line_used ? [] : @items[@beginning_of_line_index..-1]
end
variable_width_wrapping() { |create_line, item| ... } click to toggle source

Performs the line wrapping with variable widths.

# File lib/hexapdf/layout/text_layouter.rb, line 425
def variable_width_wrapping
  index = @stored_index = 0

  while (item = @items[index])
    case item.type
    when :box
      y_min, y_max, new_height = @height_calc.simulate_height(item.item)
      if new_height > @line.height
        @line.update(y_min, y_max)
        @available_width = @width_block.call(@line)
        if !@available_width || @width > @available_width
          index = (@available_width ? @beginning_of_line_index : @stored_index)
          item = @items[index]
          reset_after_line_break_variable_width(index)
          redo
        end
      end
      if add_box_item(item.item)
        @height_calc << item.item
      else
        if @break_prohibited_state
          index = reset_line_to_last_breakpoint_state
          item = @items[index]
        end
        break unless (action = yield(create_line, item))
        reset_after_line_break_variable_width(index, true, action)
        redo
      end
    when :glue
      unless add_glue_item(item.item, index)
        break unless (action = yield(create_line, item))
        reset_after_line_break_variable_width(index + 1, true, action)
      end
    when :penalty
      if item.penalty <= -Penalty::INFINITY
        add_box_item(item.item) if item.width > 0
        break unless (action = yield(create_unjustified_line, item))
        reset_after_line_break_variable_width(index + 1, true, action)
      elsif item.penalty >= Penalty::INFINITY
        @break_prohibited_state = true
        add_box_item(item.item) if item.width > 0
      elsif item.width > 0
        if item_fits_on_line?(item)
          next_index = index + 1
          next_item = @items[next_index]
          next_item = @items[next_index += 1] while next_item&.type == :penalty
          y_min, y_max, new_height = @height_calc.simulate_height(next_item.item)
          if next_item && @width + next_item.width > @width_block.call(DummyLine.new(y_min, y_max))
            @line_items.concat(@glue_items).push(item.item)
            @width += item.width
            # No need to clean up, since in the next iteration a line break occurs
          end
          update_last_breakpoint(index)
        else
          @break_prohibited_state = true
        end
      else
        update_last_breakpoint(index)
      end
    end

    index += 1
  end

  line = create_unjustified_line
  last_line_used = true
  last_line_used = yield(line, nil) if item.nil? && !line.items.empty?

  item.nil? && last_line_used ? [] : @items[@beginning_of_line_index..-1]
end

Private Instance Methods

add_box_item(item) click to toggle source

Adds the box item to the line items if it fits on the line.

Returns true if the item could be added and false otherwise.

# File lib/hexapdf/layout/text_layouter.rb, line 501
def add_box_item(item)
  return false unless @width + item.width <= @available_width
  @line_items.concat(@glue_items).push(item)
  @width += item.width
  @glue_items.clear
  true
end
add_glue_item(item, index) click to toggle source

Adds the glue item to the line items if it fits on the line.

Returns true if the item could be added and false otherwise.

# File lib/hexapdf/layout/text_layouter.rb, line 512
def add_glue_item(item, index)
  return false unless @width + item.width <= @available_width
  unless @line_items.empty? # ignore glue at beginning of line
    @glue_items << item
    @width += item.width
    update_last_breakpoint(index)
  end
  true
end
create_line() click to toggle source

Creates a Line object from the current line items.

# File lib/hexapdf/layout/text_layouter.rb, line 543
def create_line
  Line.new(@line_items)
end
create_unjustified_line() click to toggle source

Creates a Line object from the current line items that ignores line justification.

# File lib/hexapdf/layout/text_layouter.rb, line 548
def create_unjustified_line
  create_line.tap(&:ignore_justification!)
end
item_fits_on_line?(item) click to toggle source

Returns true if the item fits on the line.

# File lib/hexapdf/layout/text_layouter.rb, line 538
def item_fits_on_line?(item)
  @width + item.width <= @available_width
end
reset_after_line_break(index) click to toggle source

Resets the line state variables to their initial values. The index specifies the items index of the first item on the new line. The line_height specifies the line height to use for getting the available width.

# File lib/hexapdf/layout/text_layouter.rb, line 555
def reset_after_line_break(index)
  @beginning_of_line_index = index
  @line_items.clear
  @width = 0
  @glue_items.clear
  @last_breakpoint_index = index
  @last_breakpoint_line_items_index = 0
  @break_prohibited_state = false
  @available_width = @width_block.call(@line)
end
reset_after_line_break_variable_width(index, reset_line = false, action = :none) click to toggle source

Specialized reset method for variable width wrapping.

  • The arguments index and line_height are also passed to reset_after_line_break.

  • If the action argument is :store_start_of_line, the stored item index is reset to the index of the first item of the line.

# File lib/hexapdf/layout/text_layouter.rb, line 572
def reset_after_line_break_variable_width(index, reset_line = false, action = :none)
  @stored_index = @beginning_of_line_index if action == :store_start_of_line
  @line.update(0, 0) if reset_line
  @height_calc.reset
  reset_after_line_break(index)
end
reset_line_to_last_breakpoint_state() click to toggle source

Resets the line items array to contain only those items that were in it when the last breakpoint was encountered and returns the items' index of the last breakpoint.

# File lib/hexapdf/layout/text_layouter.rb, line 531
def reset_line_to_last_breakpoint_state
  @line_items.slice!(@last_breakpoint_line_items_index..-1)
  @break_prohibited_state = false
  @last_breakpoint_index
end
update_last_breakpoint(index) click to toggle source

Updates the information on the last possible breakpoint of the current line.

# File lib/hexapdf/layout/text_layouter.rb, line 523
def update_last_breakpoint(index)
  @break_prohibited_state = false
  @last_breakpoint_index = index
  @last_breakpoint_line_items_index = @line_items.size
end