Class: HexaPDF::Layout::TextLayouter

Inherits:
Object
  • Object
show all
Defined in:
lib/hexapdf/layout/text_layouter.rb

Overview

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

Features

  • Existing line breaking characters inside of TextFragment objects are respected when fitting text. If this is not wanted, they have to be removed beforehand.

  • The first line may be indented by setting Style#text_indent which may also be negative.

  • Text can be fitted into arbitrarily shaped areas, even containing holes.

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.

Defined Under Namespace

Modules: SimpleTextSegmentation Classes: Box, DummyLine, Glue, Penalty, Result, SimpleLineWrapping

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(style = Style.new) ⇒ TextLayouter

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.



630
631
632
# File 'lib/hexapdf/layout/text_layouter.rb', line 630

def initialize(style = Style.new)
  @style = (style.kind_of?(Style) ? style : Style.new(style))
end

Instance Attribute Details

#styleObject (readonly)

The style to be applied.

Only the following properties are used: Style#text_indent, Style#align, Style#valign, Style#line_spacing, Style#text_segmentation_algorithm, Style#text_line_wrapping_algorithm



624
625
626
# File 'lib/hexapdf/layout/text_layouter.rb', line 624

def style
  @style
end

Instance Method Details

#fit(items, width, height) ⇒ Object

:call-seq:

text_layouter.fit(items, width, height) -> result

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.



670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
# File 'lib/hexapdf/layout/text_layouter.rb', line 670

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
  last_line = nil
  y_offset = 0
  width_spec = nil
  width_spec_index = 0
  width_block =
    if width.respond_to?(:call)
      last_actual_height = nil
      last_line_height = nil
      proc do |h|
        line_height = [line_height, h || 0].max
        if last_actual_height != actual_height || last_line_height != line_height
          spec = width.call(actual_height, line_height)
          spec = [0, spec] unless spec.kind_of?(Array)
          last_actual_height = actual_height
          last_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

    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.height) && 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 +
        (last_line ? style.line_spacing.gap(last_line, combined_line) : 0)

      if new_height <= height
        # valid line found, use it
        apply_offsets(line_fragments, width_spec, indent, last_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
        last_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