Text Layouter - Shapes

The HexaPDF::Layout::TextLayouter class can be used to easily lay out text, not limiting the area to a rectangle but any shape. There is only one restriction: In the case of arbitrary shapes the vertical alignment has to be “top”.

Arbitrary shapes boil down to varying line widths and horizontal offsets from left. Imagine a circle: If text is fit in a circle, the line widths start at zero, getting larger and larger until the middle of the cirle. And then they get smaller until zero again. The x-values of the left half circle determine the horizontal offsets.

Both, the line widths and the horizontal offsets can be calculated given a certain height, and this is exactly what HexaPDF uses. If the width argument to HexaPDF::Layout::TextLayouter#fit is an object responding to #call (e.g. a lambda), it is used for determining the line widths and offsets.

This example shows text layed out in various shapes, using the above mentioned techniques.

Usage:
ruby text_layouter_shapes.rb
Resulting PDF:
text_layouter_shapes.pdf
Preview:

Code

require 'hexapdf'

doc = HexaPDF::Document.new
page = doc.pages.add
canvas = page.canvas
canvas.font("Times", size: 10, variant: :bold)
canvas.stroke_color("hp-blue-dark").line_width(0.2)
font = doc.fonts.add("Times")

sample_text = "Lorem ipsum dolor sit amet, con\u{00AD}sectetur
adipis\u{00AD}cing elit, sed do eiusmod tempor incididunt ut labore et
dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation
ullamco laboris nisi ut aliquip ex ea commodo consequat.
".tr("\n", ' ') * 10

items = doc.layout.text_fragments(sample_text, font: font)
layouter = HexaPDF::Layout::TextLayouter.new

########################################################################
# Circly things on the top
radius = 100
circle_top = 840
half_circle_width = lambda do |height, line_height|
  sum = height + line_height
  if sum <= radius * 2
    [Math.sqrt(radius**2 - (radius - height)**2),
     Math.sqrt([radius**2 - (radius - sum)**2, 0].max)].min
  else
    0
  end
end
circle = lambda do |height, line_height|
  w = half_circle_width.call(height, line_height)
  [radius - w, 2 * w]
end
left_half_circle = lambda do |height, line_height|
  w = half_circle_width.call(height, line_height)
  [radius - w, w]
end

# Left: right half circle
result = layouter.fit(items, half_circle_width, radius * 2)
result.draw(canvas, 0, circle_top)
canvas.circle(0, circle_top - radius, radius).stroke

# Center: full circle
layouter.style.text_align = :justify
result = layouter.fit(items, circle, radius * 2)
result.draw(canvas, page.box.width / 2.0 - radius, circle_top)
canvas.circle(page.box.width / 2.0, circle_top - radius, radius).stroke

# Right: left half circle
layouter.style.text_align = :right
result = layouter.fit(items, left_half_circle, radius * 2)
result.draw(canvas, page.box.width - radius, circle_top)
canvas.circle(page.box.width, circle_top - radius, radius).stroke


########################################################################
# Pointy, diamondy things in the middle

diamond_width = 100
diamond_top = circle_top - 2 * radius - 10
half_diamond_width = lambda do |height, line_height|
  sum = height + line_height
  if sum < diamond_width
    height
  else
    [diamond_width * 2 - sum, 0].max
  end
end
full_diamond = lambda do |height, line_height|
  w = half_diamond_width.call(height, line_height)
  [diamond_width - w, 2 * w]
end
left_half_diamond = lambda do |height, line_height|
  w = half_diamond_width.call(height, line_height)
  [diamond_width - w, w]
end

# Left: right half diamond
layouter.style.text_align = :left
result = layouter.fit(items, half_diamond_width, 2 * diamond_width)
result.draw(canvas, 0, diamond_top)
canvas.polyline(0, diamond_top, diamond_width, diamond_top - diamond_width,
                0, diamond_top - 2 * diamond_width).stroke

# Center: full diamond
layouter.style.text_align = :justify
result = layouter.fit(items, full_diamond, 2 * diamond_width)
left = page.box.width / 2.0 - diamond_width
result.draw(canvas, left, diamond_top)
canvas.polyline(left + diamond_width, diamond_top,
                left + 2 * diamond_width, diamond_top - diamond_width,
                left + diamond_width, diamond_top - 2 * diamond_width,
                left, diamond_top - diamond_width).close_subpath.stroke

# Right: left half diamond
layouter.style.text_align = :right
result = layouter.fit(items, left_half_diamond, 2 * diamond_width)
middle = page.box.width
result.draw(canvas, middle - diamond_width, diamond_top)
canvas.polyline(middle, diamond_top,
                middle - diamond_width, diamond_top - diamond_width,
                middle, diamond_top - 2 * diamond_width).stroke


########################################################################
# Sine wave thing next

sine_wave_height = 200.0
sine_wave_top = diamond_top - 2 * diamond_width - 10
sine_wave = lambda do |height, line_height|
  offset = [40 * Math.sin(2 * Math::PI * (height / sine_wave_height)),
            40 * Math.sin(2 * Math::PI * (height + line_height) / sine_wave_height)].max
  [offset, sine_wave_height + 100 + offset * -2]
end
layouter.style.text_align = :justify
result = layouter.fit(items, sine_wave, sine_wave_height)
middle = page.box.width / 2.0
result.draw(canvas, middle - (sine_wave_height + 100) / 2, sine_wave_top)

########################################################################
# And finally a house

house_top = sine_wave_top - sine_wave_height - 10
outer_width = 300.0
inner_width = 100.0
house = lambda do |height, line_height|
  sum = height + line_height
  first_part = (outer_width / 2 - inner_width / 2)
  if (0..first_part).cover?(sum)
    [-height, outer_width + height * 2]
  elsif (first_part..(first_part + inner_width)).cover?(height) ||
      (first_part..(first_part + inner_width)).cover?(sum)
    [0, first_part, inner_width, first_part]
  elsif sum <= outer_width
    outer_width
  else
    0
  end
end
layouter.style.text_align = :justify
result = layouter.fit(items, house, 200)

middle = page.box.width / 2.0
result.draw(canvas, middle - (outer_width / 2), house_top)

doc.write("text_layouter_shapes.pdf", optimize: true)