class HexaPDF::Layout::TextLayouter

Arranges text and inline objects into lines according to a specified width and height as well as other options.

Features

Layouting Algorithm

Laying out text consists of three phases:

  1. The items are broken into pieces which are wrapped into Box, Glue or Penalty objects. Additional Penalty objects marking line breaking opportunities are inserted where needed. This step is done by the SimpleTextSegmentation module.

  2. The pieces are arranged into lines using a very simple algorithm that just puts the maximum number of consecutive pieces into each line. This step is done by the SimpleLineWrapping module.

  3. The lines of step two may actually not be whole lines but line fragments if the area has holes or other discontinuities. The fit method deals with those so that the line wrapping algorithm can be separate.

Constants

DummyLine

A dummy line class for use with variable width wrapping, and Style#line_spacing methods in case a line actually consists of multiple line fragments.

Attributes

style[R]

Public Class Methods

new(style = Style.new) click to toggle source

Creates a new TextLayouter object with the given style.

The style argument can either be a Style object or a hash of style options. See style for the properties that are used by the layouter.

# File lib/hexapdf/layout/text_layouter.rb, line 651
def initialize(style = Style.new)
  @style = (style.kind_of?(Style) ? style : Style.new(**style))
end

Public Instance Methods

fit(items, width, height) → result click to toggle source

Fits the items into the given area and returns a Result object with all the information.

The height argument is just a number specifying the maximum height that can be used.

The width argument can be one of the following:

**a number**

In this case the layed out lines have this number as maximum width. This is the standard case and means that the area in which the text is layed out is a rectangle.

**an array with an even number of numbers**

The array has to be of the form [offset, width, offset, width, …], so the even indices specify offsets (relative to the current position, not absolute offsets from the left), the odd indices widths. This allows laying out lines containing holes in them.

A simple example: [15, 100, 30, 40]. This means that a space of 15 on the left is never used, then comes text with a maximum width of 100, starting at the absolute offset 15, followed by a hole with a width of 30 and then text again with a width of 40, starting at the absolute offset 145 (=15 + 100 + 30).

**an object responding to call(height, line_height)**

The provided argument height is the bottom of last line (or 0 in case of the first line) and line_height is the height of the line to be layed out. The return value has to be of one of the forms above (i.e. a single number or an array of numbers) and should describe the area given these height restrictions.

This allows laying out text inside complex, arbitrarily formed shapes and can be used, for example, for flowing text around objects.

The text segmentation algorithm specified via style is applied to the items in case they are not already in segmented form. This also means that Result#remaining_items always contains segmented items.

# File lib/hexapdf/layout/text_layouter.rb, line 691
def fit(items, width, height)
  unless items.empty? || items[0].respond_to?(:type)
    items = style.text_segmentation_algorithm.call(items)
  end

  # result variables
  lines = []
  actual_height = 0
  rest = items

  # processing state variables
  indent = style.text_indent
  line_fragments = []
  line_height = 0
  previous_line = nil
  y_offset = 0
  width_spec = nil
  width_spec_index = 0
  width_block =
    if width.respond_to?(:call)
      last_actual_height = nil
      previous_line_height = nil
      proc do |cur_line|
        line_height = [line_height, cur_line.height || 0].max
        if last_actual_height != actual_height || previous_line_height != line_height
          gap = if previous_line
                  style.line_spacing.gap(previous_line, cur_line)
                else
                  0
                end
          spec = width.call(actual_height + gap, cur_line.height)
          spec = [0, spec] unless spec.kind_of?(Array)
          last_actual_height = actual_height
          previous_line_height = line_height
        else
          spec = width_spec
        end
        if spec == width_spec
          # no changes, just need to return the width of the current part
          width_spec[width_spec_index * 2 + 1] - (width_spec_index == 0 ? indent : 0)
        elsif line_fragments.each_with_index.all? {|l, i| l.width <= spec[i * 2 + 1] }
          # width_spec changed, parts can only get smaller but processed parts still fit
          width_spec = spec
          width_spec[width_spec_index * 2 + 1] - (width_spec_index == 0 ? indent : 0)
        else
          # width_spec changed and some processed part doesn't fit anymore, retry from start
          line_fragments.clear
          width_spec = spec
          width_spec_index = 0
          nil
        end
      end
    elsif width.kind_of?(Array)
      width_spec = width
      proc { width_spec[width_spec_index * 2 + 1] - (width_spec_index == 0 ? indent : 0) }
    else
      width_spec = [0, width]
      proc { width - indent }
    end

  while true
    too_wide_box = nil
    line_height = 0

    rest = style.text_line_wrapping_algorithm.call(rest, width_block) do |line, item|
      # make sure empty lines broken by mandatory paragraph breaks are not empty
      line << TextFragment.new([], style) if item&.type != :box && line.items.empty?

      # item didn't fit into first part, find next available part
      if line.items.empty? && line_fragments.empty?
        old_height = actual_height
        while item.width > width_block.call(item.item) && actual_height <= height
          width_spec_index += 1
          if width_spec_index >= width_spec.size / 2
            actual_height += item.height / 3
            width_spec_index = 0
          end
        end
        if actual_height + item.height <= height
          width_spec_index.times { line_fragments << Line.new }
          y_offset = actual_height - old_height
          next true
        else
          actual_height = old_height
          too_wide_box = item
          next nil
        end
      end

      # continue with line fragments of current line if there are still parts and items
      # available; also handles the case if at least the first fragment is not empty and a
      # single item didn't fit into at least one of the other parts
      line_fragments << line
      unless line_fragments.size == width_spec.size / 2 || !item || item.type == :penalty
        width_spec_index += 1
        next (width_spec_index == 1 ? :store_start_of_line : true)
      end

      combined_line = create_combined_line(line_fragments)
      new_height = actual_height + combined_line.height +
        (previous_line ? style.line_spacing.gap(previous_line, combined_line) : 0)

      if new_height <= height
        # valid line found, use it
        apply_offsets(line_fragments, width_spec, indent, previous_line, combined_line, y_offset)
        lines.concat(line_fragments)
        line_fragments.clear
        width_spec_index = 0
        indent = if item&.type == :penalty && item.penalty == Penalty::PARAGRAPH_BREAK
                   style.text_indent
                 else
                   0
                 end
        previous_line = combined_line
        actual_height = new_height
        line_height = 0
        y_offset = nil
        true
      else
        nil
      end
    end

    if too_wide_box && (too_wide_box.item.kind_of?(TextFragment) &&
                        too_wide_box.item.items.size > 1)
      rest[0..rest.index(too_wide_box)] = too_wide_box.item.items.map do |item|
        Box.new(TextFragment.new([item], too_wide_box.item.style))
      end
      too_wide_box = nil
    else
      status = (too_wide_box ? :box_too_wide : (rest.empty? ? :success : :height))
      break
    end
  end

  unless lines.empty?
    lines.first.y_offset += initial_baseline_offset(lines, height, actual_height)
  end

  Result.new(status, lines, rest)
end

Private Instance Methods

apply_offsets(line_frags, width_spec, indent, previous_line, combined_line, y_offset) click to toggle source

Applies the necessary x- and y-offsets to the line fragments.

Note that the offset for the first fragment of the first line is the top of the line since the initial_baseline_offset method applies the correct offset to it once layouting is completely done.

# File lib/hexapdf/layout/text_layouter.rb, line 852
def apply_offsets(line_frags, width_spec, indent, previous_line, combined_line, y_offset)
  cumulated_width = 0
  line_frags.each_with_index do |line, index|
    line.x_offset = cumulated_width + indent
    line.x_offset += width_spec[index * 2]
    line.x_offset += horizontal_alignment_offset(line, width_spec[index * 2 + 1] - indent)
    cumulated_width += width_spec[index * 2] + width_spec[index * 2 + 1]
    if index == 0
      line.y_offset = if y_offset
                        y_offset + combined_line.y_max -
                          (previous_line ? previous_line.y_min : line.y_max)
                      else
                        style.line_spacing.baseline_distance(previous_line, combined_line)
                      end
      indent = 0
    end
  end
end
create_combined_line(line_frags) click to toggle source

Creates a line combining all items from the given line fragments for height calculations.

# File lib/hexapdf/layout/text_layouter.rb, line 836
def create_combined_line(line_frags)
  if line_frags.size == 1
    line_frags[0]
  else
    calc = Line::HeightCalculator.new
    line_frags.each {|l| l.items.each {|i| calc << i } }
    y_min, y_max, = calc.result
    DummyLine.new(y_min, y_max)
  end
end
horizontal_alignment_offset(line, available_width) click to toggle source

Returns the horizontal offset from the left side, based on the align style option.

# File lib/hexapdf/layout/text_layouter.rb, line 884
def horizontal_alignment_offset(line, available_width)
  case style.align
  when :left then 0
  when :center then (available_width - line.width) / 2
  when :right then available_width - line.width
  when :justify then (justify_line(line, available_width); 0)
  end
end
initial_baseline_offset(lines, height, actual_height) click to toggle source

Returns the initial baseline offset from the top, based on the valign style option.

# File lib/hexapdf/layout/text_layouter.rb, line 872
def initial_baseline_offset(lines, height, actual_height)
  case style.valign
  when :top
    lines.first.y_max
  when :center
    (height - actual_height) / 2.0 + lines.first.y_max
  when :bottom
    (height - actual_height) + lines.first.y_max
  end
end
justify_line(line, width) click to toggle source

Justifies the given line.

# File lib/hexapdf/layout/text_layouter.rb, line 894
def justify_line(line, width)
  return if line.ignore_justification? || (width - line.width).abs < 0.001

  indexes = []
  sum = 0.0
  line.items.each_with_index do |item, item_index|
    next if item.kind_of?(InlineBox)
    item.items.each_with_index do |glyph, glyph_index|
      if !glyph.kind_of?(Numeric) && glyph.str == ' '
        sum += glyph.width * item.style.scaled_font_size
        indexes << item_index << glyph_index
      end
    end
  end

  if sum > 0
    adjustment = (width - line.width) / sum
    i = indexes.length - 2
    while i >= 0
      frag = line.items[indexes[i]]
      value = -frag.items[indexes[i + 1]].width * adjustment
      if frag.items.frozen?
        value = HexaPDF::Layout::TextFragment.new([value], frag.style)
        line.items.insert(indexes[i], value)
      else
        frag.items.insert(indexes[i + 1], value)
        frag.clear_cache
      end
      i -= 2
    end
    line.clear_cache
  end
end