hexapdf 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -1
  3. data/CONTRIBUTERS +1 -1
  4. data/LICENSE +3 -0
  5. data/README.md +2 -1
  6. data/Rakefile +3 -1
  7. data/VERSION +1 -1
  8. data/examples/{hello_world.rb → 001-hello_world.rb} +0 -0
  9. data/examples/{graphics.rb → 002-graphics.rb} +1 -1
  10. data/examples/{arc.rb → 003-arcs.rb} +2 -2
  11. data/examples/{optimizing.rb → 004-optimizing.rb} +0 -0
  12. data/examples/{merging.rb → 005-merging.rb} +0 -0
  13. data/examples/{standard_pdf_fonts.rb → 006-standard_pdf_fonts.rb} +0 -0
  14. data/examples/{truetype.rb → 007-truetype.rb} +0 -0
  15. data/examples/{show_char_bboxes.rb → 008-show_char_bboxes.rb} +0 -0
  16. data/examples/{text_layouter_alignment.rb → 009-text_layouter_alignment.rb} +3 -3
  17. data/examples/{text_layouter_inline_boxes.rb → 010-text_layouter_inline_boxes.rb} +7 -9
  18. data/examples/{text_layouter_line_wrapping.rb → 011-text_layouter_line_wrapping.rb} +6 -5
  19. data/examples/{text_layouter_styling.rb → 012-text_layouter_styling.rb} +6 -8
  20. data/examples/013-text_layouter_shapes.rb +176 -0
  21. data/examples/014-text_in_polygon.rb +60 -0
  22. data/examples/{boxes.rb → 015-boxes.rb} +29 -21
  23. data/examples/016-frame_automatic_box_placement.rb +90 -0
  24. data/examples/017-frame_text_flow.rb +60 -0
  25. data/lib/hexapdf/cli/command.rb +4 -3
  26. data/lib/hexapdf/cli/files.rb +1 -1
  27. data/lib/hexapdf/cli/inspect.rb +0 -1
  28. data/lib/hexapdf/cli/merge.rb +1 -1
  29. data/lib/hexapdf/cli/modify.rb +1 -1
  30. data/lib/hexapdf/configuration.rb +2 -0
  31. data/lib/hexapdf/content/canvas.rb +3 -3
  32. data/lib/hexapdf/content/graphic_object.rb +1 -0
  33. data/lib/hexapdf/content/graphic_object/geom2d.rb +132 -0
  34. data/lib/hexapdf/dictionary.rb +7 -1
  35. data/lib/hexapdf/dictionary_fields.rb +35 -83
  36. data/lib/hexapdf/document.rb +9 -5
  37. data/lib/hexapdf/document/fonts.rb +1 -1
  38. data/lib/hexapdf/encryption/standard_security_handler.rb +1 -1
  39. data/lib/hexapdf/filter/ascii85_decode.rb +1 -1
  40. data/lib/hexapdf/filter/ascii_hex_decode.rb +1 -1
  41. data/lib/hexapdf/font/cmap/writer.rb +2 -2
  42. data/lib/hexapdf/font/true_type/builder.rb +1 -1
  43. data/lib/hexapdf/font/true_type/table.rb +1 -1
  44. data/lib/hexapdf/font/true_type/table/cmap.rb +1 -1
  45. data/lib/hexapdf/font/true_type/table/cmap_subtable.rb +3 -3
  46. data/lib/hexapdf/font/true_type/table/kern.rb +1 -1
  47. data/lib/hexapdf/font/true_type/table/post.rb +1 -1
  48. data/lib/hexapdf/font/type1/character_metrics.rb +1 -1
  49. data/lib/hexapdf/font/type1/font_metrics.rb +1 -1
  50. data/lib/hexapdf/image_loader/jpeg.rb +1 -1
  51. data/lib/hexapdf/image_loader/png.rb +2 -2
  52. data/lib/hexapdf/layout.rb +3 -0
  53. data/lib/hexapdf/layout/box.rb +64 -46
  54. data/lib/hexapdf/layout/frame.rb +348 -0
  55. data/lib/hexapdf/layout/inline_box.rb +2 -2
  56. data/lib/hexapdf/layout/line.rb +3 -3
  57. data/lib/hexapdf/layout/style.rb +81 -14
  58. data/lib/hexapdf/layout/text_box.rb +84 -0
  59. data/lib/hexapdf/layout/text_fragment.rb +8 -8
  60. data/lib/hexapdf/layout/text_layouter.rb +278 -169
  61. data/lib/hexapdf/layout/width_from_polygon.rb +246 -0
  62. data/lib/hexapdf/rectangle.rb +9 -9
  63. data/lib/hexapdf/stream.rb +2 -2
  64. data/lib/hexapdf/type.rb +1 -0
  65. data/lib/hexapdf/type/action.rb +1 -1
  66. data/lib/hexapdf/type/annotations/markup_annotation.rb +1 -1
  67. data/lib/hexapdf/type/catalog.rb +1 -1
  68. data/lib/hexapdf/type/cid_font.rb +2 -1
  69. data/lib/hexapdf/type/font.rb +0 -1
  70. data/lib/hexapdf/type/font_descriptor.rb +1 -1
  71. data/lib/hexapdf/type/font_simple.rb +3 -3
  72. data/lib/hexapdf/type/font_true_type.rb +8 -0
  73. data/lib/hexapdf/type/font_type0.rb +2 -1
  74. data/lib/hexapdf/type/font_type1.rb +7 -1
  75. data/lib/hexapdf/type/font_type3.rb +61 -0
  76. data/lib/hexapdf/type/graphics_state_parameter.rb +8 -8
  77. data/lib/hexapdf/type/image.rb +10 -0
  78. data/lib/hexapdf/type/page.rb +83 -10
  79. data/lib/hexapdf/version.rb +1 -1
  80. data/test/hexapdf/common_tokenizer_tests.rb +2 -2
  81. data/test/hexapdf/content/graphic_object/test_geom2d.rb +79 -0
  82. data/test/hexapdf/encryption/test_standard_security_handler.rb +1 -1
  83. data/test/hexapdf/font/test_true_type_wrapper.rb +1 -1
  84. data/test/hexapdf/font/test_type1_wrapper.rb +1 -1
  85. data/test/hexapdf/font/true_type/table/test_cmap.rb +1 -1
  86. data/test/hexapdf/font/true_type/table/test_directory.rb +1 -1
  87. data/test/hexapdf/font/true_type/table/test_head.rb +7 -3
  88. data/test/hexapdf/layout/test_box.rb +57 -15
  89. data/test/hexapdf/layout/test_frame.rb +313 -0
  90. data/test/hexapdf/layout/test_inline_box.rb +1 -1
  91. data/test/hexapdf/layout/test_style.rb +74 -0
  92. data/test/hexapdf/layout/test_text_box.rb +77 -0
  93. data/test/hexapdf/layout/test_text_layouter.rb +220 -239
  94. data/test/hexapdf/layout/test_width_from_polygon.rb +108 -0
  95. data/test/hexapdf/test_dictionary_fields.rb +22 -26
  96. data/test/hexapdf/test_document.rb +3 -3
  97. data/test/hexapdf/test_reference.rb +1 -0
  98. data/test/hexapdf/test_writer.rb +2 -2
  99. data/test/hexapdf/type/test_font_true_type.rb +25 -0
  100. data/test/hexapdf/type/test_font_type1.rb +6 -0
  101. data/test/hexapdf/type/test_font_type3.rb +26 -0
  102. data/test/hexapdf/type/test_image.rb +10 -0
  103. data/test/hexapdf/type/test_page.rb +114 -0
  104. data/test/test_helper.rb +1 -1
  105. metadata +65 -17
  106. data/examples/text_layouter_shapes.rb +0 -170
@@ -43,11 +43,11 @@ module HexaPDF
43
43
  class InlineBox
44
44
 
45
45
  # Creates an InlineBox that wraps a basic Box. All arguments (except +valign+) and the block
46
- # are passed to Box::new.
46
+ # are passed to Box::create.
47
47
  #
48
48
  # See ::new for the +valign+ argument.
49
49
  def self.create(valign: :baseline, **args, &block)
50
- new(Box.new(**args, &block), valign: valign)
50
+ new(Box.create(**args, &block), valign: valign)
51
51
  end
52
52
 
53
53
  # The vertical alignment of the box.
@@ -175,8 +175,8 @@ module HexaPDF
175
175
  # An optional vertical offset that should be taken into account when positioning the line.
176
176
  #
177
177
  # For the first line in a paragraph this describes the offset from the top of the box to the
178
- # top of the line. For all other lines it describes the offset from the previous baseline to
179
- # the baseline of this line.
178
+ # baseline of the line. For all other lines it describes the offset from the previous baseline
179
+ # to the baseline of this line.
180
180
  attr_accessor :y_offset
181
181
 
182
182
  # Creates a new Line object, adding all given items to it.
@@ -290,7 +290,7 @@ module HexaPDF
290
290
  #
291
291
  # Clears all cached values.
292
292
  #
293
- # This method needs to be called if the fragment's items are changed!
293
+ # This method needs to be called if the line's items are changed!
294
294
  def clear_cache
295
295
  @x_max = @y_min = @y_max = @text_y_min = @text_y_max = @width = nil
296
296
  self
@@ -138,12 +138,17 @@ module HexaPDF
138
138
  # The value for right.
139
139
  attr_accessor :right
140
140
 
141
+ # Creates a new Quad object. See #set for more information.
142
+ def initialize(obj)
143
+ set(obj)
144
+ end
145
+
141
146
  # :call-seq:
142
- # Quad.new(value)
143
- # Quad.new(array)
144
- # Quad.new(quad)
147
+ # quad.set(value)
148
+ # quad.set(array)
149
+ # quad.set(quad)
145
150
  #
146
- # Creates a new Quad object.
151
+ # Sets all values of the quad.
147
152
  #
148
153
  # * If a single value is provided that is neither a Quad nor an array, it is handled as if
149
154
  # an array with one value was given.
@@ -159,7 +164,7 @@ module HexaPDF
159
164
  # third value.
160
165
  # * Four or more values: Top is set to the first, right to the second, bottom to the third
161
166
  # and left to the fourth value.
162
- def initialize(obj)
167
+ def set(obj)
163
168
  case obj
164
169
  when Quad
165
170
  @top = obj.top
@@ -209,6 +214,8 @@ module HexaPDF
209
214
 
210
215
  # Draws the border onto the canvas, inside the rectangle (x, y, w, h).
211
216
  def draw(canvas, x, y, w, h)
217
+ return if none?
218
+
212
219
  canvas.save_graphics_state do
213
220
  if width.simple? && color.simple? && style.simple?
214
221
  draw_simple_border(canvas, x, y, w, h)
@@ -229,12 +236,11 @@ module HexaPDF
229
236
  miter_limit(10).
230
237
  line_cap_style(line_cap_style(:top))
231
238
 
239
+ canvas.rectangle(x, y, w, h).clip_path.end_path
232
240
  if style.top == :solid
233
241
  canvas.line_dash_pattern(0).
234
242
  rectangle(x + offset, y + offset, w - 2 * offset, h - 2 * offset).stroke
235
243
  else
236
- canvas.rectangle(x, y, w, h).clip_path.end_path
237
-
238
244
  canvas.line_dash_pattern(line_dash_pattern(:top, w)).
239
245
  line(x, y + h - offset, x + w, y + h - offset).
240
246
  line(x + w, y + offset, x, y + offset).stroke
@@ -336,7 +342,7 @@ module HexaPDF
336
342
  when :dotted
337
343
  # Adjust the gap so that full dots appear in the corners.
338
344
  w = width.send(edge)
339
- gap = (length - w).to_f / (length.to_f / (w * 2)).ceil
345
+ gap = [(length - w).to_f / (length.to_f / (w * 2)).ceil, 1].max
340
346
  HexaPDF::Content::LineDashPattern.new([0, gap], [gap - w * 0.5, 0].max)
341
347
  end
342
348
  end
@@ -358,7 +364,7 @@ module HexaPDF
358
364
  # The object resolved in this way needs to respond to #call(canvas, box) where +canvas+ is the
359
365
  # HexaPDF::Content::Canvas object on which it should be drawn and +box+ is a box-like object
360
366
  # (e.g. Box or TextFragment). The coordinate system is translated so that the origin is at the
361
- # lower right corner of the box during the drawing operations.
367
+ # bottom left corner of the box during the drawing operations.
362
368
  class Layers
363
369
 
364
370
  # Creates a new Layers object popuplated with the given +layers+.
@@ -762,6 +768,54 @@ module HexaPDF
762
768
  # A Layers object containing all the layers that should be drawn under the box; defaults to no
763
769
  # layers being drawn.
764
770
 
771
+ ##
772
+ # :method: position
773
+ # :call-seq:
774
+ # position(value = nil)
775
+ #
776
+ # Specifies how a box should be positioned in a frame. The property #position_hint provides
777
+ # additional, position specific data. Defaults to :default.
778
+ #
779
+ # Possible values:
780
+ #
781
+ # :default:: Position the box at the current position. The exact horizontal position is given
782
+ # via the position hint. Space to the left/right of the box can't be used for other
783
+ # boxes.
784
+ #
785
+ # :float:: Position the box at the current position but let it "float" so that the space to
786
+ # the left/right can still be used. The position hint specifies where the box should
787
+ # float.
788
+ #
789
+ # :absolute:: Position the box at an absolute position relative to the frame. The coordinates
790
+ # are given via the position hint.
791
+
792
+ ##
793
+ # :method: position_hint
794
+ # :call-seq:
795
+ # position_hint(value = nil)
796
+ #
797
+ # Specifies additional information on how a box should be positioned in a frame. The exact
798
+ # meaning depends on the value of the #position property.
799
+ #
800
+ # Possible values depending on the #position property:
801
+ #
802
+ # :default::
803
+ #
804
+ # :left:: (default) Align the box to the left side of the available region.
805
+ # :right:: Align the box to the right side of the available region.
806
+ # :center:: Horizontally center the box in the available region.
807
+ #
808
+ # :float::
809
+ #
810
+ # :left:: (default) Float the box to the left side of the available region.
811
+ # :right:: Float the box to the right side of the available region.
812
+ #
813
+ # :absolute::
814
+ #
815
+ # An array with the x- and y-coordinates of the bottom left corner of the absolutely
816
+ # positioned box. The coordinates are taken as being relative to the bottom left corner of
817
+ # the frame into which the box is drawn.
818
+
765
819
  [
766
820
  [:font, "raise HexaPDF::Error, 'No font set'"],
767
821
  [:font_size, 10],
@@ -796,9 +850,11 @@ module HexaPDF
796
850
  [:border, "Border.new", "Border.new(value)"],
797
851
  [:overlays, "Layers.new", "Layers.new(value)"],
798
852
  [:underlays, "Layers.new", "Layers.new(value)"],
853
+ [:position, :default],
854
+ [:position_hint, nil],
799
855
  ].each do |name, default, setter = "value", extra_args = ""|
800
856
  default = default.inspect unless default.kind_of?(String)
801
- module_eval(<<-EOF, __FILE__, __LINE__)
857
+ module_eval(<<-EOF, __FILE__, __LINE__ + 1)
802
858
  def #{name}(value = UNSET#{extra_args})
803
859
  value == UNSET ? (@#{name} ||= #{default}) : (@#{name} = #{setter}; self)
804
860
  end
@@ -836,7 +892,7 @@ module HexaPDF
836
892
  [:text_line_wrapping_algorithm, 'TextLayouter::SimpleLineWrapping'],
837
893
  ].each do |name, default|
838
894
  default = default.inspect unless default.kind_of?(String)
839
- module_eval(<<-EOF, __FILE__, __LINE__)
895
+ module_eval(<<-EOF, __FILE__, __LINE__ + 1)
840
896
  def #{name}(value = UNSET, &block)
841
897
  if value == UNSET && !block
842
898
  @#{name} ||= #{default}
@@ -914,12 +970,22 @@ module HexaPDF
914
970
 
915
971
  # The ascender of the font scaled appropriately.
916
972
  def scaled_font_ascender
917
- @ascender ||= font.wrapped_font.ascender * font.scaling_factor * font_size / 1000
973
+ @scaled_font_ascender ||= font.wrapped_font.ascender * font.scaling_factor * font_size / 1000
918
974
  end
919
975
 
920
976
  # The descender of the font scaled appropriately.
921
977
  def scaled_font_descender
922
- @descender ||= font.wrapped_font.descender * font.scaling_factor * font_size / 1000
978
+ @scaled_font_descender ||= font.wrapped_font.descender * font.scaling_factor * font_size / 1000
979
+ end
980
+
981
+ # The minimum y-coordinate, calculated using the scaled descender of the font.
982
+ def scaled_y_min
983
+ @scaled_y_min ||= scaled_font_descender + calculated_text_rise
984
+ end
985
+
986
+ # The maximum y-coordinate, calculated using the scaled descender of the font.
987
+ def scaled_y_max
988
+ @scaled_y_max ||= scaled_font_ascender + calculated_text_rise
923
989
  end
924
990
 
925
991
  # Returns the width of the item scaled appropriately (by taking font size, characters spacing,
@@ -946,7 +1012,8 @@ module HexaPDF
946
1012
  # ascender, descender.
947
1013
  def clear_cache
948
1014
  @scaled_font_size = @scaled_character_spacing = @scaled_word_spacing = nil
949
- @scaled_horizontal_scaling = @ascender = @descender = nil
1015
+ @scaled_horizontal_scaling = @scaled_font_ascender = @scaled_font_descender = nil
1016
+ @scaled_y_min = @scaled_y_max = nil
950
1017
  @scaled_item_widths.clear
951
1018
  end
952
1019
 
@@ -0,0 +1,84 @@
1
+ # -*- encoding: utf-8; frozen_string_literal: true -*-
2
+ #
3
+ #--
4
+ # This file is part of HexaPDF.
5
+ #
6
+ # HexaPDF - A Versatile PDF Creation and Manipulation Library For Ruby
7
+ # Copyright (C) 2014-2018 Thomas Leitner
8
+ #
9
+ # HexaPDF is free software: you can redistribute it and/or modify it
10
+ # under the terms of the GNU Affero General Public License version 3 as
11
+ # published by the Free Software Foundation with the addition of the
12
+ # following permission added to Section 15 as permitted in Section 7(a):
13
+ # FOR ANY PART OF THE COVERED WORK IN WHICH THE COPYRIGHT IS OWNED BY
14
+ # THOMAS LEITNER, THOMAS LEITNER DISCLAIMS THE WARRANTY OF NON
15
+ # INFRINGEMENT OF THIRD PARTY RIGHTS.
16
+ #
17
+ # HexaPDF is distributed in the hope that it will be useful, but WITHOUT
18
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
19
+ # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
20
+ # License for more details.
21
+ #
22
+ # You should have received a copy of the GNU Affero General Public License
23
+ # along with HexaPDF. If not, see <http://www.gnu.org/licenses/>.
24
+ #
25
+ # The interactive user interfaces in modified source and object code
26
+ # versions of HexaPDF must display Appropriate Legal Notices, as required
27
+ # under Section 5 of the GNU Affero General Public License version 3.
28
+ #
29
+ # In accordance with Section 7(b) of the GNU Affero General Public
30
+ # License, a covered work must retain the producer line in every PDF that
31
+ # is created or manipulated using HexaPDF.
32
+ #++
33
+ require 'hexapdf/layout/box'
34
+ require 'hexapdf/layout/text_layouter'
35
+
36
+ module HexaPDF
37
+ module Layout
38
+
39
+ # A TextBox is used for drawing text, either inside a rectangular box or by flowing it around
40
+ # objects of a Frame.
41
+ #
42
+ # This class uses TextLayouter behind the scenes to do the hard work.
43
+ class TextBox < Box
44
+
45
+ # Creates a new TextBox object with the given inline items (e.g. TextFragment and InlineBox
46
+ # objects).
47
+ def initialize(items, **kwargs)
48
+ super(kwargs)
49
+ @tl = TextLayouter.new(style)
50
+ @items = items
51
+ @result = nil
52
+ end
53
+
54
+ # Fits the text box into the Frame.
55
+ #
56
+ # Depending on the 'position' style property, the text is either fit into the rectangular area
57
+ # given by +available_width+ and +available_height+, or fit to the outline of the frame
58
+ # starting from the top.
59
+ def fit(available_width, available_height, frame)
60
+ @result = if style.position == :flow
61
+ @tl.fit(@items, frame.width_specification, frame.contour_line.bbox.height)
62
+ else
63
+ @tl.fit(@items, available_width, available_height)
64
+ end
65
+ @height = @result.height
66
+ @width = @result.lines.max_by(&:width)&.width || 0
67
+
68
+ success = (@result.status == :success)
69
+ @draw_block = success ? method(:draw_text) : nil
70
+ success
71
+ end
72
+
73
+ private
74
+
75
+ # Draws the text into the box.
76
+ def draw_text(canvas, _self)
77
+ return unless @result && !@result.lines.empty?
78
+ @result.draw(canvas, 0, @result.height)
79
+ end
80
+
81
+ end
82
+
83
+ end
84
+ end
@@ -49,7 +49,7 @@ module HexaPDF
49
49
  # The items of a text fragment may be frozen to indicate that the fragment is potentially used
50
50
  # multiple times.
51
51
  #
52
- # The rectangle with the lower-left corner (#x_min, #y_min) and the upper right corner (#x_max,
52
+ # The rectangle with the bottom left corner (#x_min, #y_min) and the top right corner (#x_max,
53
53
  # #y_max) describes the minimum bounding box of the whole text fragment and is usually *not*
54
54
  # equal to the box (0, 0)-(#width, #height).
55
55
  class TextFragment
@@ -129,7 +129,7 @@ module HexaPDF
129
129
  # * It is assumed that the text matrix is not rotated, skewed, etc. so that setting the text
130
130
  # position can be done using the optimal method.
131
131
  def draw(canvas, x, y, ignore_text_properties: false)
132
- style.underlays.draw(canvas, x, y + y_min, self)
132
+ style.underlays.draw(canvas, x, y + y_min, self) if style.underlays?
133
133
 
134
134
  # Set general font related graphics state if necessary
135
135
  unless ignore_text_properties
@@ -173,7 +173,7 @@ module HexaPDF
173
173
  end
174
174
  canvas.show_glyphs_only(items)
175
175
 
176
- if style.underline
176
+ if style.underline? && style.underline
177
177
  y_offset = style.calculated_underline_position
178
178
  canvas.save_graphics_state do
179
179
  canvas.stroke_color(style.fill_color).
@@ -185,7 +185,7 @@ module HexaPDF
185
185
  end
186
186
  end
187
187
 
188
- if style.strikeout
188
+ if style.strikeout? && style.strikeout
189
189
  y_offset = style.calculated_strikeout_position
190
190
  canvas.save_graphics_state do
191
191
  canvas.stroke_color(style.fill_color).
@@ -197,7 +197,7 @@ module HexaPDF
197
197
  end
198
198
  end
199
199
 
200
- style.overlays.draw(canvas, x, y + y_min, self)
200
+ style.overlays.draw(canvas, x, y + y_min, self) if style.overlays?
201
201
  end
202
202
 
203
203
  # The minimum x-coordinate of the first glyph.
@@ -212,12 +212,12 @@ module HexaPDF
212
212
 
213
213
  # The minimum y-coordinate, calculated using the scaled descender of the font.
214
214
  def y_min
215
- @y_min ||= style.scaled_font_descender + style.calculated_text_rise
215
+ style.scaled_y_min
216
216
  end
217
217
 
218
218
  # The maximum y-coordinate, calculated using the scaled ascender of the font.
219
219
  def y_max
220
- @y_max ||= style.scaled_font_ascender + style.calculated_text_rise
220
+ style.scaled_y_max
221
221
  end
222
222
 
223
223
  # The minimum y-coordinate of any item.
@@ -263,7 +263,7 @@ module HexaPDF
263
263
  #
264
264
  # This method needs to be called if the fragment's items or attributes are changed!
265
265
  def clear_cache
266
- @x_min = @x_max = @y_min = @y_max = @exact_y_min = @exact_y_max = @width = @height = nil
266
+ @x_min = @x_max = @exact_y_min = @exact_y_max = @width = @height = nil
267
267
  self
268
268
  end
269
269
 
@@ -50,9 +50,11 @@ module HexaPDF
50
50
  #
51
51
  # * The first line may be indented by setting Style#text_indent which may also be negative.
52
52
  #
53
+ # * Text can be fitted into arbitrarily shaped areas, even containing holes.
54
+ #
53
55
  # == Layouting Algorithm
54
56
  #
55
- # Laying out text consists of two phases:
57
+ # Laying out text consists of three phases:
56
58
  #
57
59
  # 1. The items are broken into pieces which are wrapped into Box, Glue or Penalty objects.
58
60
  # Additional Penalty objects marking line breaking opportunities are inserted where needed.
@@ -61,6 +63,10 @@ module HexaPDF
61
63
  # 2. The pieces are arranged into lines using a very simple algorithm that just puts the maximum
62
64
  # number of consecutive pieces into each line. This step is done by the SimpleLineWrapping
63
65
  # module.
66
+ #
67
+ # 3. The lines of step two may actually not be whole lines but line fragments if the area has
68
+ # holes or other discontinuities. The #fit method deals with those so that the line wrapping
69
+ # algorithm can be separate.
64
70
  class TextLayouter
65
71
 
66
72
  using NumericRefinements
@@ -273,7 +279,8 @@ module HexaPDF
273
279
  # Implementation of a simple line wrapping algorithm.
274
280
  #
275
281
  # The algorithm arranges the given items so that the maximum number is put onto each line,
276
- # taking the differences of Box, Glue and Penalty items into account.
282
+ # taking the differences of Box, Glue and Penalty items into account. It is not as advanced as
283
+ # say Knuth's line wrapping algorithm in that it doesn't optimize paragraphs.
277
284
  class SimpleLineWrapping
278
285
 
279
286
  # :call-seq:
@@ -302,6 +309,12 @@ module HexaPDF
302
309
  # item is a box item, this single item didn't fit into the available width; the caller has
303
310
  # to handle this situation, e.g. by stopping.
304
311
  #
312
+ # In case of varying widths, the +width_block+ may also return +nil+ in which case the
313
+ # algorithm should revert back to a stored item index and then start as if beginning a new
314
+ # line. Which index to use is told the algorithm through the special return value
315
+ # +:store_start_of_line+ of the yielded-to block. When this return value is used, the
316
+ # current start of the line index should be stored for later use.
317
+ #
305
318
  # After the algorithm is finished, it returns the unused items.
306
319
  def self.call(items, width_block, &block)
307
320
  obj = new(items, width_block)
@@ -390,7 +403,7 @@ module HexaPDF
390
403
 
391
404
  # Performs the line wrapping with variable widths.
392
405
  def variable_width_wrapping
393
- index = 0
406
+ index = @stored_index = 0
394
407
 
395
408
  while (item = @items[index])
396
409
  case item.type
@@ -399,6 +412,12 @@ module HexaPDF
399
412
  if new_height > @line_height
400
413
  @line_height = new_height
401
414
  @available_width = @width_block.call(@line_height)
415
+ if !@available_width || @width > @available_width
416
+ index = (@available_width ? @beginning_of_line_index : @stored_index)
417
+ item = @items[index]
418
+ reset_after_line_break_variable_width(index, @line_height)
419
+ redo
420
+ end
402
421
  end
403
422
  if add_box_item(item.item)
404
423
  @height_calc << item.item
@@ -407,19 +426,19 @@ module HexaPDF
407
426
  index = reset_line_to_last_breakpoint_state
408
427
  item = @items[index]
409
428
  end
410
- break unless yield(create_line, item)
411
- reset_after_line_break(index)
429
+ break unless (action = yield(create_line, item))
430
+ reset_after_line_break_variable_width(index, 0, action)
412
431
  redo
413
432
  end
414
433
  when :glue
415
434
  unless add_glue_item(item.item, index)
416
- break unless yield(create_line, item)
417
- reset_after_line_break(index + 1)
435
+ break unless (action = yield(create_line, item))
436
+ reset_after_line_break_variable_width(index + 1, 0, action)
418
437
  end
419
438
  when :penalty
420
439
  if item.penalty <= -Penalty::INFINITY
421
- break unless yield(create_unjustified_line, item)
422
- reset_after_line_break(index + 1)
440
+ break unless (action = yield(create_unjustified_line, item))
441
+ reset_after_line_break_variable_width(index + 1, 0, action)
423
442
  elsif item.penalty >= Penalty::INFINITY
424
443
  @break_prohibited_state = true
425
444
  add_box_item(item.item) if item.width > 0
@@ -510,8 +529,9 @@ module HexaPDF
510
529
  end
511
530
 
512
531
  # Resets the line state variables to their initial values. The +index+ specifies the items
513
- # index of the first item on the new line.
514
- def reset_after_line_break(index)
532
+ # index of the first item on the new line. The +line_height+ specifies the line height to
533
+ # use for getting the available width.
534
+ def reset_after_line_break(index, line_height = 0)
515
535
  @beginning_of_line_index = index
516
536
  @line_items.clear
517
537
  @width = 0
@@ -519,155 +539,247 @@ module HexaPDF
519
539
  @last_breakpoint_index = index
520
540
  @last_breakpoint_line_items_index = 0
521
541
  @break_prohibited_state = false
522
- @available_width = @width_block.call(0)
542
+ @available_width = @width_block.call(line_height)
543
+ end
523
544
 
524
- @line_height = 0
545
+ # Specialized reset method for variable width wrapping.
546
+ #
547
+ # * The arguments +index+ and +line_height+ are also passed to #reset_after_line_break.
548
+ #
549
+ # * If the +action+ argument is +:store_start_of_line+, the stored item index is reset to
550
+ # the index of the first item of the line.
551
+ def reset_after_line_break_variable_width(index, line_height, action = :none)
552
+ @stored_index = @beginning_of_line_index if action == :store_start_of_line
553
+ @line_height = line_height
525
554
  @height_calc.reset
555
+ reset_after_line_break(index, line_height)
526
556
  end
527
557
 
528
558
  end
529
559
 
530
- # Creates a new TextLayouter object for the given text and returns it.
531
- #
532
- # See ::new for information on +height+.
533
- #
534
- # The style that gets applied to the text and the layout itself can be specified using
535
- # additional options, of which font is mandatory.
536
- def self.create(text, width:, height: nil, x_offsets: nil, **options)
537
- frag = TextFragment.create(text, **options)
538
- new(items: [frag], width: width, height: height, x_offsets: x_offsets, style: frag.style)
560
+ # Encapsulates the result of layouting items using a TextLayouter and provides a method for
561
+ # drawing the result (i.e. the layed out lines) on a canvas.
562
+ class Result
563
+
564
+ # The status after layouting the items:
565
+ #
566
+ # +:success+:: There are no remaining items.
567
+ # +:box_too_wide+:: A single text or inline box was too wide to fit alone on a line.
568
+ # +:height+:: There was not enough height for all items to layout.
569
+ #
570
+ # Even if the result is not +:success+, the layouting may still be successful depending on
571
+ # the usage. For example, if we expect that there may be too many items to fit, +:height+ is
572
+ # still a success.
573
+ attr_reader :status
574
+
575
+ # Array of layed out lines.
576
+ attr_reader :lines
577
+
578
+ # The actual height of all layed out lines (this includes a possible offset for the first
579
+ # line).
580
+ attr_reader :height
581
+
582
+ # The remaining items that couldn't be layed out.
583
+ attr_reader :remaining_items
584
+
585
+ # Creates a new Result structure.
586
+ def initialize(status, lines, remaining_items)
587
+ @status = status
588
+ @lines = lines
589
+ @height = @lines.sum(&:y_offset) - (@lines.last&.y_min || 0)
590
+ @remaining_items = remaining_items
591
+ end
592
+
593
+ # Draws the layed out lines onto the canvas with the top-left corner being at [x, y].
594
+ def draw(canvas, x, y)
595
+ last_item = nil
596
+ canvas.save_graphics_state do
597
+ # Best effort for leading in case we have an evenly spaced paragraph
598
+ canvas.leading(@lines[1].y_offset) if @lines.size > 1
599
+ @lines.each_with_index do |line, index|
600
+ y -= @lines[index].y_offset
601
+ line_x = x + line.x_offset
602
+ line.each do |item, item_x, item_y|
603
+ if item.kind_of?(TextFragment)
604
+ item.draw(canvas, line_x + item_x, y + item_y,
605
+ ignore_text_properties: last_item&.style == item.style)
606
+ last_item = item
607
+ elsif !item.empty?
608
+ canvas.restore_graphics_state
609
+ item.draw(canvas, line_x + item_x, y + item_y)
610
+ canvas.save_graphics_state
611
+ last_item = nil
612
+ end
613
+ end
614
+ end
615
+ end
616
+ end
617
+
539
618
  end
540
619
 
541
620
  # The style to be applied.
542
621
  #
543
622
  # Only the following properties are used: Style#text_indent, Style#align, Style#valign,
544
- # Style#text_segmentation_algorithm, Style#text_line_wrapping_algorithm
623
+ # Style#line_spacing, Style#text_segmentation_algorithm, Style#text_line_wrapping_algorithm
545
624
  attr_reader :style
546
625
 
547
- # The items (TextFragment and InlineBox objects) that should be layed out.
548
- attr_reader :items
549
-
550
- # Array of Line objects describing the layed out lines.
551
- #
552
- # The array is only valid after #fit was called.
553
- attr_reader :lines
554
-
555
- # The actual height of the layed out text. Can be +nil+ if the items have not been layed out
556
- # yet, i.e. if #fit has not been called.
557
- attr_reader :actual_height
558
-
559
- # Creates a new TextLayouter object with the given width containing the given items.
560
- #
561
- # The width can either be a simple number specifying a fixed width, or an object that responds
562
- # to #call(height, line_height) where +height+ is the bottom of last line and +line_height+ is
563
- # the height of the line to be layed out. The return value should be the available width given
564
- # these height restrictions.
565
- #
566
- # The optional +x_offsets+ argument works like +width+ but can be used to specify (varying)
567
- # offsets from the left side (e.g. when the left side of the text should follow a certain
568
- # shape).
569
- #
570
- # The height is optional and if not specified means that the text layout has infinite height.
626
+ # Creates a new TextLayouter object with the given style.
571
627
  #
572
628
  # The +style+ argument can either be a Style object or a hash of style options. See #style for
573
629
  # the properties that are used by the layouter.
574
- def initialize(items: [], width:, height: nil, x_offsets: nil, style: Style.new)
630
+ def initialize(style = Style.new)
575
631
  @style = (style.kind_of?(Style) ? style : Style.new(style))
576
- @lines = []
577
- self.items = items
578
- @width = width
579
- @height = height || Float::INFINITY
580
- @x_offsets = x_offsets && (x_offsets.respond_to?(:call) ? x_offsets : proc { x_offsets })
581
- end
582
-
583
- # Sets the items to be arranged by the text layouter, clearing the internal state.
584
- #
585
- # If the items array contains items before text segmentation, the text segmentation algorithm
586
- # is automatically applied.
587
- def items=(items)
588
- unless items.empty? || items[0].respond_to?(:type)
589
- items = style.text_segmentation_algorithm.call(items)
590
- end
591
- @items = items.freeze
592
- @lines.clear
593
- @actual_height = nil
594
632
  end
595
633
 
596
634
  # :call-seq:
597
- # text_layouter.fit -> [remaining_items, reason]
635
+ # text_layouter.fit(items, width, height) -> result
636
+ #
637
+ # Fits the items into the given area and returns a Result object with all the information.
598
638
  #
599
- # Fits the items into the set area and returns the remaining items as well as the reason why
600
- # there are remaining items.
639
+ # The +height+ argument is just a number specifying the maximum height that can be used.
601
640
  #
602
- # The reason can be +:success+ if there are no remaining items, +:box+ if a single text or
603
- # inline box item is too wide to fit alone on a line, or +:height+ if there was not enough
604
- # height for all items.
641
+ # The +width+ argument can be one of the following:
605
642
  #
606
- # The fitted lines can be retrieved via #lines and the total height via #actual_height.
643
+ # **a number**::
644
+ # In this case the layed out lines have this number as maximum width. This is the standard
645
+ # case and means that the area in which the text is layed out is a rectangle.
607
646
  #
608
- # Note: If no height has been set and variable line widths are used, no search for a possible
609
- # vertical offset is done in case a single item doesn't fit.
647
+ # **an array with an even number of numbers**::
648
+ # The array has to be of the form [offset, width, offset, width, ...], so the even indices
649
+ # specify offsets (relative to the current position, not absolute offsets from the left),
650
+ # the odd indices widths. This allows laying out lines containing holes in them.
610
651
  #
611
- # This method is automatically called as part of the drawing routine but it can also be used
612
- # by itself to determine the actual height of the layed out text.
613
- def fit
614
- @lines.clear
615
- @actual_height = 0
652
+ # A simple example: [15, 100, 30, 40]. This means that a space of 15 on the left is never
653
+ # used, then comes text with a maximum width of 100, starting at the absolute offset 15,
654
+ # followed by a hole with a width of 30 and then text again with a width of 40, starting
655
+ # at the absolute offset 145 (=15 + 100 + 30).
656
+ #
657
+ # **an object responding to #call(height, line_height)**::
658
+ #
659
+ # The provided argument +height+ is the bottom of last line (or 0 in case of the first
660
+ # line) and +line_height+ is the height of the line to be layed out. The return value has
661
+ # to be of one of the forms above (i.e. a single number or an array of numbers) and should
662
+ # describe the area given these height restrictions.
663
+ #
664
+ # This allows laying out text inside complex, arbitrarily formed shapes and can be used,
665
+ # for example, for flowing text around objects.
666
+ #
667
+ # The text segmentation algorithm specified via #style is applied to the items in case they
668
+ # are not already in segmented form. This also means that Result#remaining_items always
669
+ # contains segmented items.
670
+ def fit(items, width, height)
671
+ unless items.empty? || items[0].respond_to?(:type)
672
+ items = style.text_segmentation_algorithm.call(items)
673
+ end
674
+
675
+ # result variables
676
+ lines = []
677
+ actual_height = 0
678
+ rest = items
616
679
 
617
- rest = @items
680
+ # processing state variables
681
+ indent = style.text_indent
682
+ line_fragments = []
683
+ line_height = 0
684
+ last_line = nil
618
685
  y_offset = 0
619
- indent = (style.text_indent != 0 ? style.text_indent : 0)
620
- width_block = if @width.respond_to?(:call)
621
- proc {|h| @width.call(@actual_height, h) - indent }
622
- else
623
- proc { @width - indent }
624
- end
686
+ width_spec = nil
687
+ width_spec_index = 0
688
+ width_block =
689
+ if width.respond_to?(:call)
690
+ last_actual_height = nil
691
+ last_line_height = nil
692
+ proc do |h|
693
+ line_height = [line_height, h || 0].max
694
+ if last_actual_height != actual_height || last_line_height != line_height
695
+ spec = width.call(actual_height, line_height)
696
+ spec = [0, spec] unless spec.kind_of?(Array)
697
+ last_actual_height = actual_height
698
+ last_line_height = line_height
699
+ else
700
+ spec = width_spec
701
+ end
702
+ if spec == width_spec
703
+ # no changes, just need to return the width of the current part
704
+ width_spec[width_spec_index * 2 + 1] - (width_spec_index == 0 ? indent : 0)
705
+ elsif line_fragments.each_with_index.all? {|l, i| l.width <= spec[i * 2 + 1] }
706
+ # width_spec changed, parts can only get smaller but processed parts still fit
707
+ width_spec = spec
708
+ width_spec[width_spec_index * 2 + 1] - (width_spec_index == 0 ? indent : 0)
709
+ else
710
+ # width_spec changed and some processed part doesn't fit anymore, retry from start
711
+ line_fragments.clear
712
+ width_spec = spec
713
+ width_spec_index = 0
714
+ nil
715
+ end
716
+ end
717
+ elsif width.kind_of?(Array)
718
+ width_spec = width
719
+ proc { width_spec[width_spec_index * 2 + 1] - (width_spec_index == 0 ? indent : 0) }
720
+ else
721
+ width_spec = [0, width]
722
+ proc { width - indent }
723
+ end
625
724
 
626
725
  while true
627
726
  too_wide_box = nil
628
727
 
629
728
  rest = style.text_line_wrapping_algorithm.call(rest, width_block) do |line, item|
729
+ # make sure empty lines broken by mandatory paragraph breaks are not empty
630
730
  line << TextFragment.new([], style) if item&.type != :box && line.items.empty?
631
- new_height = @actual_height + line.height +
632
- (@lines.empty? ? 0 : style.line_spacing.gap(@lines.last, line))
633
731
 
634
- if new_height > @height
635
- nil
636
- elsif !line.items.empty?
732
+ # item didn't fit into first part, find next available part
733
+ if line.items.empty? && line_fragments.empty?
734
+ old_height = actual_height
735
+ while item.width > width_block.call(item.height) && actual_height <= height
736
+ width_spec_index += 1
737
+ if width_spec_index >= width_spec.size / 2
738
+ actual_height += item.height / 3
739
+ width_spec_index = 0
740
+ end
741
+ end
742
+ if actual_height + item.height <= height
743
+ width_spec_index.times { line_fragments << Line.new }
744
+ y_offset = actual_height - old_height
745
+ next true
746
+ else
747
+ actual_height = old_height
748
+ too_wide_box = item
749
+ next nil
750
+ end
751
+ end
752
+
753
+ # continue with line fragments of current line if there are still parts and items
754
+ # available; also handles the case if at least the first fragment is not empty and a
755
+ # single item didn't fit into at least one of the other parts
756
+ line_fragments << line
757
+ unless line_fragments.size == width_spec.size / 2 || !item || item.type == :penalty
758
+ width_spec_index += 1
759
+ next (width_spec_index == 1 ? :store_start_of_line : true)
760
+ end
761
+
762
+ combined_line = create_combined_line(line_fragments)
763
+ new_height = actual_height + combined_line.height +
764
+ (last_line ? style.line_spacing.gap(last_line, combined_line) : 0)
765
+
766
+ if new_height <= height
637
767
  # valid line found, use it
638
- cur_width = width_block.call(line.height)
639
- line.x_offset = indent + horizontal_alignment_offset(line, cur_width)
640
- line.x_offset += @x_offsets.call(@actual_height, line.height) if @x_offsets
641
- line.y_offset = if y_offset
642
- y_offset + (@lines.last ? -@lines.last.y_min + line.y_max : 0)
643
- else
644
- style.line_spacing.baseline_distance(@lines.last, line)
645
- end
646
- @actual_height = new_height
647
- @lines << line
648
- y_offset = nil
768
+ apply_offsets(line_fragments, width_spec, indent, last_line, combined_line, y_offset)
769
+ lines.concat(line_fragments)
770
+ line_fragments.clear
771
+ width_spec_index = 0
649
772
  indent = if item&.type == :penalty && item.penalty == Penalty::PARAGRAPH_BREAK
650
773
  style.text_indent
651
774
  else
652
775
  0
653
776
  end
777
+ last_line = combined_line
778
+ actual_height = new_height
779
+ line_height = 0
780
+ y_offset = nil
654
781
  true
655
- elsif @height != Float::INFINITY
656
- # some height left but item didn't fit on the line, search downwards for usable space
657
- old_height = @actual_height
658
- while item.width > width_block.call(item.height) && @actual_height <= @height
659
- @actual_height += item.height / 3
660
- end
661
- if @actual_height + item.height <= @height
662
- y_offset = @actual_height - old_height
663
- true
664
- else
665
- @actual_height = old_height
666
- too_wide_box = item
667
- nil
668
- end
669
782
  else
670
- too_wide_box = item
671
783
  nil
672
784
  end
673
785
  end
@@ -679,74 +791,71 @@ module HexaPDF
679
791
  end
680
792
  too_wide_box = nil
681
793
  else
682
- reason = (too_wide_box ? :box : (rest.empty? ? :success : :height))
794
+ status = (too_wide_box ? :box_too_wide : (rest.empty? ? :success : :height))
683
795
  break
684
796
  end
685
797
  end
686
798
 
687
- [rest, reason]
799
+ unless lines.empty?
800
+ lines.first.y_offset += initial_baseline_offset(lines, height, actual_height)
801
+ end
802
+
803
+ Result.new(status, lines, rest)
688
804
  end
689
805
 
690
- # :call-seq:
691
- # tl.draws(canvas, x, y, fit: :if_needed) -> [remaining_items, reason] or nil
692
- #
693
- # Draws the layed out text onto the canvas with the top-left corner being at [x, y].
694
- #
695
- # Depending on the value of +fit+ the text may also be fitted:
696
- #
697
- # * If +true+, then #fit is always called.
698
- # * If +:if_needed+, then #fit is only called if it has not been called before.
699
- # * If +false+, then #fit is never called.
806
+ private
807
+
808
+ # :nodoc:
700
809
  #
701
- # If #fit was called, its return value is returned. Otherwise +nil+ is returned.
702
- def draw(canvas, x, y, fit: :if_needed)
703
- fit_result = self.fit if fit == true || (!@actual_height && fit == :if_needed)
704
- return fit_result if @lines.empty?
705
-
706
- last_item = nil
707
- canvas.save_graphics_state do
708
- if @lines.size > 1
709
- canvas.leading(style.line_spacing.baseline_distance(@lines[0], @lines[1]))
710
- end
711
- y -= initial_baseline_offset + @lines.first.y_offset
712
- @lines.each_with_index do |line, index|
713
- line_x = x + line.x_offset
714
- line.each do |item, item_x, item_y|
715
- if item.kind_of?(TextFragment)
716
- item.draw(canvas, line_x + item_x, y + item_y,
717
- ignore_text_properties: last_item&.style == item.style)
718
- last_item = item
719
- elsif !item.empty?
720
- canvas.restore_graphics_state
721
- item.draw(canvas, line_x + item_x, y + item_y)
722
- canvas.save_graphics_state
723
- last_item = nil
724
- end
725
- end
726
- y -= @lines[index + 1].y_offset if @lines[index + 1]
727
- end
810
+ # A dummy line class for use with Style#line_spacing methods in case a line actually consists
811
+ # of multiple line fragments.
812
+ DummyLine = Struct.new(:y_min, :y_max) do
813
+ def height
814
+ y_max - y_min
728
815
  end
816
+ end
729
817
 
730
- fit_result
818
+ # Creates a line combining all items from the given line fragments for height calculations.
819
+ def create_combined_line(line_frags)
820
+ if line_frags.size == 1
821
+ line_frags[0]
822
+ else
823
+ calc = Line::HeightCalculator.new
824
+ line_frags.each {|l| l.items.each {|i| calc << i } }
825
+ y_min, y_max, = calc.result
826
+ DummyLine.new(y_min, y_max)
827
+ end
731
828
  end
732
829
 
733
- private
830
+ # Applies the necessary x- and y-offsets to the line fragments.
831
+ def apply_offsets(line_frags, width_spec, indent, last_line, combined_line, y_offset)
832
+ cumulated_width = 0
833
+ line_frags.each_with_index do |line, index|
834
+ line.x_offset = cumulated_width + indent
835
+ line.x_offset += width_spec[index * 2]
836
+ line.x_offset += horizontal_alignment_offset(line, width_spec[index * 2 + 1] - indent)
837
+ cumulated_width += width_spec[index * 2] + width_spec[index * 2 + 1]
838
+ if index == 0
839
+ line.y_offset = if y_offset
840
+ y_offset + combined_line.y_max -
841
+ (last_line ? last_line.y_min : line.y_max)
842
+ else
843
+ style.line_spacing.baseline_distance(last_line, combined_line)
844
+ end
845
+ end
846
+ indent = 0
847
+ end
848
+ end
734
849
 
735
850
  # Returns the initial baseline offset from the top, based on the valign style option.
736
- def initial_baseline_offset
851
+ def initial_baseline_offset(lines, height, actual_height)
737
852
  case style.valign
738
853
  when :top
739
- @lines.first.y_max
854
+ lines.first.y_max
740
855
  when :center
741
- if @height == Float::INFINITY
742
- raise HexaPDF::Error, "Can't vertically align when using unlimited height"
743
- end
744
- (@height - @actual_height) / 2.0 + @lines.first.y_max
856
+ (height - actual_height) / 2.0 + lines.first.y_max
745
857
  when :bottom
746
- if @height == Float::INFINITY
747
- raise HexaPDF::Error, "Can't vertically align when using unlimited height"
748
- end
749
- (@height - @actual_height) + @lines.first.y_max
858
+ (height - actual_height) + lines.first.y_max
750
859
  end
751
860
  end
752
861