class HexaPDF::Layout::TextLayouter
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:
-
The items are broken into pieces which are wrapped into
Box
,Glue
orPenalty
objects. AdditionalPenalty
objects marking line breaking opportunities are inserted where needed. This step is done by theSimpleTextSegmentation
module. -
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. -
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
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
Public Class Methods
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
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) andline_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
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
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
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
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
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