hexapdf 0.7.0 → 0.8.0

Sign up to get free protection for your applications and to get access to all the features.
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