Class: HexaPDF::Layout::TextBox

Inherits:
Object
  • Object
show all
Defined in:
lib/hexapdf/layout/text_box.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.

Layouting Algorithm

Laying out text consists of two phases:

  1. The items of the text box 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.

Defined Under Namespace

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

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(items: [], width:, height: nil, x_offsets: nil, style: Style.new) ⇒ TextBox

Creates a new TextBox object with the given width containing the given items.

The width can either be a simple number specifying a fixed width, or an object that responds to #call(height, line_height) where height is the bottom of last line and line_height is the height of the line to be layed out. The return value should be the available width given these height restrictions.

The optional x_offsets argument works like width but can be used to specify (varying) offsets from the left of the box (e.g. when the left side of the text should follow a certain shape).

The height is optional and if not specified means that the text box has infinite height.



546
547
548
549
550
551
552
553
# File 'lib/hexapdf/layout/text_box.rb', line 546

def initialize(items: [], width:, height: nil, x_offsets: nil, style: Style.new)
  @style = style
  @lines = []
  self.items = items
  @width = width
  @height = height || Float::INFINITY
  @x_offsets = x_offsets && (x_offsets.respond_to?(:call) ? x_offsets : proc { x_offsets })
end

Instance Attribute Details

#actual_heightObject (readonly)

The actual height of the text box. Can be nil if the items have not been layed out yet, i.e. if #fit has not been called.



532
533
534
# File 'lib/hexapdf/layout/text_box.rb', line 532

def actual_height
  @actual_height
end

#itemsObject

The items (TextFragment and InlineBox objects) of the text box that should be layed out.



523
524
525
# File 'lib/hexapdf/layout/text_box.rb', line 523

def items
  @items
end

#linesObject (readonly)

Array of LineFragment objects describing the lines of the text box.

The array is only valid after #fit was called.



528
529
530
# File 'lib/hexapdf/layout/text_box.rb', line 528

def lines
  @lines
end

#styleObject (readonly)

The style to be applied.

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



520
521
522
# File 'lib/hexapdf/layout/text_box.rb', line 520

def style
  @style
end

Class Method Details

.create(text, width:, height: nil, x_offsets: nil, **options) ⇒ Object

Creates a new TextBox object for the given text and returns it.

See ::new for information on height.

The style of the text box can be specified using additional options, of which font is mandatory.



511
512
513
514
# File 'lib/hexapdf/layout/text_box.rb', line 511

def self.create(text, width:, height: nil, x_offsets: nil, **options)
  frag = TextFragment.create(text, **options)
  new(items: [frag], width: width, height: height, x_offsets: x_offsets, style: frag.style)
end

Instance Method Details

#draw(canvas, x, y, fit: :if_needed) ⇒ Object

Draws the text box onto the canvas with the top-left corner being at [x, y].

Depending on the value of fit the text may also be fitted:

  • If true, then #fit is always called.

  • If :if_needed, then #fit is only called if it has been called before.

  • If false, then #fit is never called.



644
645
646
647
648
649
650
651
652
653
654
655
656
# File 'lib/hexapdf/layout/text_box.rb', line 644

def draw(canvas, x, y, fit: :if_needed)
  self.fit if fit == true || (!@actual_height && fit == :if_needed)
  return if @lines.empty?

  canvas.save_graphics_state do
    y -= initial_baseline_offset + @lines.first.y_offset
    @lines.each_with_index do |line, index|
      line_x = x + line.x_offset
      line.each {|item, item_x, item_y| item.draw(canvas, line_x + item_x, y + item_y) }
      y -= @lines[index + 1].y_offset if @lines[index + 1]
    end
  end
end

#fitObject

:call-seq:

text_box.fit  -> [remaining_items, actual_height]

Fits the items into the text box and returns the remaining items as well as the actual height needed.

Note: If the text box height has not been set and variable line widths are used, no search for a possible vertical offset is done in case a single item doesn’t fit.

This method is automatically called as part of the drawing routine but it can also be used by itself to determine the actual height of the text box.



579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
# File 'lib/hexapdf/layout/text_box.rb', line 579

def fit
  @lines.clear
  @actual_height = 0
  y_offset = 0

  items = @items
  if style.text_indent != 0
    items = [Box.new(InlineBox.new(style.text_indent, 0) { })].concat(items)
  end

  if @width.respond_to?(:call)
    width_arg = proc {|h| @width.call(@actual_height, h)}
    width_block = @width
  else
    width_arg = @width
    width_block = proc { @width }
  end

  rest = style.text_line_wrapping_algorithm.call(items, width_arg) do |line, item|
    line << TextFragment.new(items: [], style: style) if item.nil? && line.items.empty?
    new_height = @actual_height + line.height +
      (@lines.empty? ? 0 : style.line_spacing.gap(@lines.last, line))

    if new_height <= @height && !line.items.empty?
      # valid line found, use it
      cur_width = width_block.call(@actual_height, line.height)
      line.x_offset = horizontal_alignment_offset(line, cur_width)
      line.x_offset += @x_offsets.call(@actual_height, line.height) if @x_offsets
      line.y_offset =  if y_offset
                         y_offset + (@lines.last ? -@lines.last.y_min + line.y_max : 0)
                       else
                         style.line_spacing.baseline_distance(@lines.last, line)
                       end
      @actual_height = new_height
      @lines << line
      y_offset = nil
      true
    elsif new_height <= @height && @height != Float::INFINITY
      # some height left but item didn't fit on the line, search downwards for usable space
      new_height = @actual_height
      while item.width > width_block.call(new_height, item.height) && new_height <= @height
        new_height += item.height / 3
      end
      if new_height + item.height <= @height
        y_offset = new_height - @actual_height
        @actual_height = new_height
        true
      else
        nil
      end
    else
      nil
    end
  end

  [rest, @actual_height]
end