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 of each paragraph 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.



678
679
680
# File 'lib/hexapdf/layout/text_layouter.rb', line 678

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#text_align, Style#text_valign, Style#line_spacing, Style#fill_horizontal, Style#text_segmentation_algorithm, Style#text_line_wrapping_algorithm



672
673
674
# File 'lib/hexapdf/layout/text_layouter.rb', line 672

def style
  @style
end

Instance Method Details

#fit(items, width, height, apply_first_text_indent: true, frame: nil) ⇒ Object

:call-seq:

text_layouter.fit(items, width, height, apply_first_text_indent: true) -> 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.

Optional arguments:

apply_first_text_indent

Specifies whether style.text_indent should be applied to the first line. This should be set to false if the items start with a continuation of a paragraph instead of starting a new paragraph (e.g. after a page break).

frame

If used with the document layout functionality, this should be the frame into which the text is laid out.



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
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
# File 'lib/hexapdf/layout/text_layouter.rb', line 729

def fit(items, width, height, apply_first_text_indent: true, frame: nil)
  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 = apply_first_text_indent ? style.text_indent : 0
  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, frame) 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?
        # item didn't fit because no more height is available
        next nil if actual_height + item.height > height
        # item fits but is followed by penalty item that didn't fit
        if item.width < width_block.call(item.item)
          too_wide_box = item
          next nil
        end

        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(too_wide_box.item.dup_attributes([item].freeze))
      end
      too_wide_box = nil
    else
      status = (too_wide_box ? :box_too_wide : (rest.empty? ? :success : :height))
      break
    end
  end

  unless lines.empty?
    # Apply baseline offset only for non-variable width text
    lines.first.y_offset += if width_block.arity == 1
                              lines.first.y_max
                            else
                              initial_baseline_offset(lines, height, actual_height)
                            end
  end

  Result.new(status, lines, rest)
end