hexapdf 0.5.0 → 0.6.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 (122) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +76 -2
  3. data/CONTRIBUTERS +1 -1
  4. data/Rakefile +1 -1
  5. data/VERSION +1 -1
  6. data/examples/boxes.rb +68 -0
  7. data/examples/graphics.rb +12 -12
  8. data/examples/{text_box_alignment.rb → text_layouter_alignment.rb} +14 -14
  9. data/examples/text_layouter_inline_boxes.rb +66 -0
  10. data/examples/{text_box_line_wrapping.rb → text_layouter_line_wrapping.rb} +9 -10
  11. data/examples/{text_box_shapes.rb → text_layouter_shapes.rb} +58 -54
  12. data/examples/text_layouter_styling.rb +125 -0
  13. data/examples/truetype.rb +5 -7
  14. data/lib/hexapdf/cli/command.rb +1 -0
  15. data/lib/hexapdf/configuration.rb +170 -106
  16. data/lib/hexapdf/content/canvas.rb +41 -36
  17. data/lib/hexapdf/content/graphics_state.rb +15 -0
  18. data/lib/hexapdf/content/operator.rb +1 -1
  19. data/lib/hexapdf/dictionary.rb +20 -8
  20. data/lib/hexapdf/dictionary_fields.rb +8 -6
  21. data/lib/hexapdf/document.rb +25 -26
  22. data/lib/hexapdf/document/fonts.rb +4 -4
  23. data/lib/hexapdf/document/images.rb +2 -2
  24. data/lib/hexapdf/document/pages.rb +16 -16
  25. data/lib/hexapdf/encryption/security_handler.rb +41 -9
  26. data/lib/hexapdf/filter/flate_decode.rb +1 -1
  27. data/lib/hexapdf/filter/lzw_decode.rb +1 -1
  28. data/lib/hexapdf/filter/predictor.rb +7 -1
  29. data/lib/hexapdf/font/true_type/font.rb +20 -0
  30. data/lib/hexapdf/font/type1/font.rb +23 -0
  31. data/lib/hexapdf/font_loader.rb +1 -0
  32. data/lib/hexapdf/font_loader/from_configuration.rb +2 -3
  33. data/lib/hexapdf/font_loader/from_file.rb +65 -0
  34. data/lib/hexapdf/image_loader/png.rb +2 -2
  35. data/lib/hexapdf/layout.rb +3 -2
  36. data/lib/hexapdf/layout/box.rb +146 -0
  37. data/lib/hexapdf/layout/inline_box.rb +40 -31
  38. data/lib/hexapdf/layout/{line_fragment.rb → line.rb} +12 -13
  39. data/lib/hexapdf/layout/style.rb +630 -41
  40. data/lib/hexapdf/layout/text_fragment.rb +80 -12
  41. data/lib/hexapdf/layout/{text_box.rb → text_layouter.rb} +164 -109
  42. data/lib/hexapdf/number_tree_node.rb +1 -1
  43. data/lib/hexapdf/parser.rb +4 -1
  44. data/lib/hexapdf/revisions.rb +11 -4
  45. data/lib/hexapdf/stream.rb +8 -9
  46. data/lib/hexapdf/tokenizer.rb +5 -3
  47. data/lib/hexapdf/type.rb +3 -0
  48. data/lib/hexapdf/type/action.rb +56 -0
  49. data/lib/hexapdf/type/actions.rb +52 -0
  50. data/lib/hexapdf/type/actions/go_to.rb +52 -0
  51. data/lib/hexapdf/type/actions/go_to_r.rb +54 -0
  52. data/lib/hexapdf/type/actions/launch.rb +73 -0
  53. data/lib/hexapdf/type/actions/uri.rb +65 -0
  54. data/lib/hexapdf/type/annotation.rb +85 -0
  55. data/lib/hexapdf/type/annotations.rb +51 -0
  56. data/lib/hexapdf/type/annotations/link.rb +70 -0
  57. data/lib/hexapdf/type/annotations/markup_annotation.rb +70 -0
  58. data/lib/hexapdf/type/annotations/text.rb +81 -0
  59. data/lib/hexapdf/type/catalog.rb +3 -1
  60. data/lib/hexapdf/type/embedded_file.rb +6 -11
  61. data/lib/hexapdf/type/file_specification.rb +4 -6
  62. data/lib/hexapdf/type/font.rb +3 -1
  63. data/lib/hexapdf/type/font_descriptor.rb +18 -16
  64. data/lib/hexapdf/type/form.rb +3 -1
  65. data/lib/hexapdf/type/graphics_state_parameter.rb +3 -1
  66. data/lib/hexapdf/type/image.rb +4 -2
  67. data/lib/hexapdf/type/info.rb +2 -5
  68. data/lib/hexapdf/type/names.rb +2 -5
  69. data/lib/hexapdf/type/object_stream.rb +2 -1
  70. data/lib/hexapdf/type/page.rb +14 -1
  71. data/lib/hexapdf/type/page_tree_node.rb +9 -6
  72. data/lib/hexapdf/type/resources.rb +2 -5
  73. data/lib/hexapdf/type/trailer.rb +2 -5
  74. data/lib/hexapdf/type/viewer_preferences.rb +2 -5
  75. data/lib/hexapdf/type/xref_stream.rb +3 -1
  76. data/lib/hexapdf/version.rb +1 -1
  77. data/test/hexapdf/common_tokenizer_tests.rb +3 -1
  78. data/test/hexapdf/content/test_canvas.rb +29 -3
  79. data/test/hexapdf/content/test_graphics_state.rb +11 -0
  80. data/test/hexapdf/content/test_operator.rb +3 -2
  81. data/test/hexapdf/document/test_fonts.rb +8 -8
  82. data/test/hexapdf/document/test_images.rb +4 -12
  83. data/test/hexapdf/document/test_pages.rb +7 -7
  84. data/test/hexapdf/encryption/test_security_handler.rb +1 -5
  85. data/test/hexapdf/filter/test_predictor.rb +40 -12
  86. data/test/hexapdf/font/true_type/test_font.rb +16 -0
  87. data/test/hexapdf/font/type1/test_font.rb +30 -0
  88. data/test/hexapdf/font_loader/test_from_file.rb +29 -0
  89. data/test/hexapdf/font_loader/test_standard14.rb +4 -3
  90. data/test/hexapdf/layout/test_box.rb +104 -0
  91. data/test/hexapdf/layout/test_inline_box.rb +24 -10
  92. data/test/hexapdf/layout/{test_line_fragment.rb → test_line.rb} +9 -9
  93. data/test/hexapdf/layout/test_style.rb +519 -31
  94. data/test/hexapdf/layout/test_text_fragment.rb +136 -15
  95. data/test/hexapdf/layout/{test_text_box.rb → test_text_layouter.rb} +224 -144
  96. data/test/hexapdf/layout/test_text_shaper.rb +1 -1
  97. data/test/hexapdf/test_configuration.rb +12 -6
  98. data/test/hexapdf/test_dictionary.rb +27 -2
  99. data/test/hexapdf/test_dictionary_fields.rb +10 -1
  100. data/test/hexapdf/test_document.rb +14 -13
  101. data/test/hexapdf/test_parser.rb +12 -0
  102. data/test/hexapdf/test_revisions.rb +34 -0
  103. data/test/hexapdf/test_stream.rb +1 -1
  104. data/test/hexapdf/test_type.rb +18 -0
  105. data/test/hexapdf/test_writer.rb +2 -2
  106. data/test/hexapdf/type/actions/test_launch.rb +24 -0
  107. data/test/hexapdf/type/actions/test_uri.rb +23 -0
  108. data/test/hexapdf/type/annotations/test_link.rb +19 -0
  109. data/test/hexapdf/type/annotations/test_markup_annotation.rb +22 -0
  110. data/test/hexapdf/type/annotations/test_text.rb +38 -0
  111. data/test/hexapdf/type/test_annotation.rb +38 -0
  112. data/test/hexapdf/type/test_file_specification.rb +0 -7
  113. data/test/hexapdf/type/test_info.rb +0 -5
  114. data/test/hexapdf/type/test_page.rb +14 -0
  115. data/test/hexapdf/type/test_page_tree_node.rb +4 -1
  116. data/test/hexapdf/type/test_trailer.rb +0 -4
  117. data/test/test_helper.rb +6 -3
  118. metadata +36 -15
  119. data/examples/text_box_inline_boxes.rb +0 -56
  120. data/examples/text_box_styling.rb +0 -72
  121. data/test/hexapdf/type/test_embedded_file.rb +0 -16
  122. data/test/hexapdf/type/test_names.rb +0 -9
@@ -70,14 +70,38 @@ module HexaPDF
70
70
 
71
71
  # The style to be applied.
72
72
  #
73
- # Only the following properties are used: Style#font, Style#font_size,
74
- # Style#horizontal_scaling, Style#character_spacing, Style#word_spacing and Style#text_rise.
73
+ # Only the following properties are used:
74
+ #
75
+ # * Style#font
76
+ # * Style#font_size
77
+ # * Style#horizontal_scaling
78
+ # * Style#character_spacing
79
+ # * Style#word_spacing
80
+ # * Style#text_rise
81
+ # * Style#text_rendering_mode
82
+ # * Style#subscript
83
+ # * Style#superscript
84
+ # * Style#underline
85
+ # * Style#strikeout
86
+ # * Style#fill_color
87
+ # * Style#fill_alpha
88
+ # * Style#stroke_color
89
+ # * Style#stroke_alpha
90
+ # * Style#stroke_width
91
+ # * Style#stroke_cap_style
92
+ # * Style#stroke_join_style
93
+ # * Style#stroke_miter_limit
94
+ # * Style#stroke_dash_pattern
95
+ # * Style#underlay_callback
96
+ # * Style#overlay_callback
75
97
  attr_reader :style
76
98
 
77
99
  # Creates a new TextFragment object with the given items and style.
100
+ #
101
+ # The argument +style+ can either be a Style object or a hash of style options.
78
102
  def initialize(items:, style: Style.new)
79
103
  @items = items || []
80
- @style = style
104
+ @style = (style.kind_of?(Style) ? style : Style.new(style))
81
105
  end
82
106
 
83
107
  # Draws the text onto the canvas at the given position.
@@ -85,13 +109,57 @@ module HexaPDF
85
109
  # Before the text is drawn using HexaPDF::Content;:Canvas#show_glyphs, the text properties
86
110
  # mentioned in the description of #style are set.
87
111
  def draw(canvas, x, y)
112
+ return if items.empty?
113
+
114
+ style.underlays.draw(canvas, x, y + y_min, self)
115
+
116
+ # Set general font related graphics state
88
117
  canvas.move_text_cursor(offset: [x, y])
89
- canvas.font(style.font, size: style.font_size).
118
+ canvas.font(style.font, size: style.calculated_font_size).
90
119
  horizontal_scaling(style.horizontal_scaling).
91
120
  character_spacing(style.character_spacing).
92
121
  word_spacing(style.word_spacing).
93
- text_rise(style.text_rise)
122
+ text_rise(style.calculated_text_rise).
123
+ text_rendering_mode(style.text_rendering_mode)
124
+
125
+ # Set fill and/or stroke related graphics state
126
+ canvas.opacity(fill_alpha: style.fill_alpha, stroke_alpha: style.stroke_alpha)
127
+ trm = canvas.text_rendering_mode
128
+ if trm.value.even? # text is filled
129
+ canvas.fill_color(style.fill_color)
130
+ end
131
+ if trm == :stroke || trm == :fill_stroke || trm == :stroke_clip || trm == :fill_stroke_clip
132
+ canvas.stroke_color(style.stroke_color).
133
+ line_width(style.stroke_width).
134
+ line_cap_style(style.stroke_cap_style).
135
+ line_join_style(style.stroke_join_style).
136
+ miter_limit(style.stroke_miter_limit).
137
+ line_dash_pattern(style.stroke_dash_pattern)
138
+ end
139
+
94
140
  canvas.show_glyphs_only(items)
141
+
142
+ if style.underline
143
+ y_offset = style.calculated_underline_position
144
+ canvas.stroke_color(style.fill_color).
145
+ line_width(style.calculated_underline_thickness).
146
+ line_cap_style(:butt).
147
+ line_dash_pattern(0).
148
+ line(x, y + y_offset, x + width, y + y_offset).
149
+ stroke
150
+ end
151
+
152
+ if style.strikeout
153
+ y_offset = style.calculated_strikeout_position
154
+ canvas.stroke_color(style.fill_color).
155
+ line_width(style.calculated_strikeout_thickness).
156
+ line_cap_style(:butt).
157
+ line_dash_pattern(0).
158
+ line(x, y + y_offset, x + width, y + y_offset).
159
+ stroke
160
+ end
161
+
162
+ style.overlays.draw(canvas, x, y + y_min, self)
95
163
  end
96
164
 
97
165
  # The minimum x-coordinate of the first glyph.
@@ -106,24 +174,24 @@ module HexaPDF
106
174
 
107
175
  # The minimum y-coordinate, calculated using the scaled descender of the font.
108
176
  def y_min
109
- @y_min ||= style.scaled_font_descender + style.text_rise
177
+ @y_min ||= style.scaled_font_descender + style.calculated_text_rise
110
178
  end
111
179
 
112
180
  # The maximum y-coordinate, calculated using the scaled ascender of the font.
113
181
  def y_max
114
- @y_max ||= style.scaled_font_ascender + style.text_rise
182
+ @y_max ||= style.scaled_font_ascender + style.calculated_text_rise
115
183
  end
116
184
 
117
185
  # The minimum y-coordinate of any item.
118
186
  def exact_y_min
119
- @exact_y_min ||= (@items.min_by(&:y_min)&.y_min || 0) * style.font_size / 1000.0 +
120
- style.text_rise
187
+ @exact_y_min ||= (@items.min_by(&:y_min)&.y_min || 0) *
188
+ style.calculated_font_size / 1000.0 + style.calculated_text_rise
121
189
  end
122
190
 
123
191
  # The maximum y-coordinate of any item.
124
192
  def exact_y_max
125
- @exact_y_max ||= (@items.max_by(&:y_max)&.y_max || 0) * style.font_size / 1000.0 +
126
- style.text_rise
193
+ @exact_y_max ||= (@items.max_by(&:y_max)&.y_max || 0) *
194
+ style.calculated_font_size / 1000.0 + style.calculated_text_rise
127
195
  end
128
196
 
129
197
  # The width of the text fragment.
@@ -148,7 +216,7 @@ module HexaPDF
148
216
 
149
217
  # Returns the vertical alignment inside a line which is always :text for text fragments.
150
218
  #
151
- # See LineFragment for details.
219
+ # See Line for details.
152
220
  def valign
153
221
  :text
154
222
  end
@@ -34,7 +34,7 @@
34
34
  require 'hexapdf/error'
35
35
  require 'hexapdf/layout/text_fragment'
36
36
  require 'hexapdf/layout/inline_box'
37
- require 'hexapdf/layout/line_fragment'
37
+ require 'hexapdf/layout/line'
38
38
  require 'hexapdf/layout/numeric_refinements'
39
39
 
40
40
  module HexaPDF
@@ -54,14 +54,14 @@ module HexaPDF
54
54
  #
55
55
  # Laying out text consists of two phases:
56
56
  #
57
- # 1. The items of the text box are broken into pieces which are wrapped into Box, Glue or
58
- # Penalty objects. Additional Penalty objects marking line breaking opportunities are
59
- # inserted where needed. This step is done by the SimpleTextSegmentation module.
57
+ # 1. The items are broken into pieces which are wrapped into Box, Glue or Penalty objects.
58
+ # Additional Penalty objects marking line breaking opportunities are inserted where needed.
59
+ # This step is done by the SimpleTextSegmentation module.
60
60
  #
61
61
  # 2. The pieces are arranged into lines using a very simple algorithm that just puts the maximum
62
62
  # number of consecutive pieces into each line. This step is done by the SimpleLineWrapping
63
63
  # module.
64
- class TextBox
64
+ class TextLayouter
65
65
 
66
66
  using NumericRefinements
67
67
 
@@ -81,6 +81,11 @@ module HexaPDF
81
81
  @item.width
82
82
  end
83
83
 
84
+ # The height of the item.
85
+ def height
86
+ @item.height
87
+ end
88
+
84
89
  # Returns :box.
85
90
  def type
86
91
  :box
@@ -127,6 +132,12 @@ module HexaPDF
127
132
  # All numbers greater than this one are deemed infinite.
128
133
  INFINITY = 1000
129
134
 
135
+ # The penalty value for a mandatory paragraph break.
136
+ PARAGRAPH_BREAK = -INFINITY - 1_000_000
137
+
138
+ # The penalty value for a mandatory line break.
139
+ LINE_BREAK = -INFINITY - 1_000_001
140
+
130
141
  # The penalty for breaking at this point.
131
142
  attr_reader :penalty
132
143
 
@@ -148,8 +159,11 @@ module HexaPDF
148
159
  :penalty
149
160
  end
150
161
 
151
- # Singleton object describing a Penalty for a mandatory break.
152
- MandatoryBreak = new(-Penalty::INFINITY)
162
+ # Singleton object describing a Penalty for a mandatory paragraph break.
163
+ MandatoryParagraphBreak = new(PARAGRAPH_BREAK)
164
+
165
+ # Singleton object describing a Penalty for a mandatory line break.
166
+ MandatoryLineBreak = new(LINE_BREAK)
153
167
 
154
168
  # Singleton object describing a Penalty for a prohibited break.
155
169
  ProhibitedBreak = new(Penalty::INFINITY)
@@ -217,11 +231,13 @@ module HexaPDF
217
231
  glues[item.style] ||=
218
232
  Glue.new(TextFragment.new(items: [glyph].freeze, style: item.style))
219
233
  result << glues[item.style]
220
- when "\n", "\v", "\f", "\u{85}", "\u{2028}", "\u{2029}"
221
- result << Penalty::MandatoryBreak
234
+ when "\n", "\v", "\f", "\u{85}", "\u{2029}"
235
+ result << Penalty::MandatoryParagraphBreak
236
+ when "\u{2028}"
237
+ result << Penalty::MandatoryLineBreak
222
238
  when "\r"
223
239
  if item.items[i + 1]&.kind_of?(Numeric) || item.items[i + 1].str != "\n"
224
- result << Penalty::MandatoryBreak
240
+ result << Penalty::MandatoryParagraphBreak
225
241
  end
226
242
  when '-'
227
243
  result << Penalty::Standard
@@ -255,32 +271,35 @@ module HexaPDF
255
271
  class SimpleLineWrapping
256
272
 
257
273
  # :call-seq:
258
- # SimpleLineWrapping.call(items, available_width) {|line, item| block } -> rest
274
+ # SimpleLineWrapping.call(items, width_block) {|line, item| block } -> rest
259
275
  #
260
276
  # Arranges the items into lines.
261
277
  #
262
- # The +available_width+ argument can either be a simple number or a callable object:
278
+ # The +width_block+ argument has to be a callable object that returns the width of the line:
263
279
  #
264
- # * If all lines should have the same width, the +available_width+ argument should be a
265
- # number. This is the general case.
280
+ # * If the line width doesn't depend on the height or the vertical position of the line
281
+ # (i.e. fixed line width), the +width_block+ should have an arity of zero. However, this
282
+ # doesn't mean that the block is called only once; it is actually called before each new
283
+ # line (e.g. for varying line widths that don't depend on the line height; one common case
284
+ # is the indentation of the first line). This is the general case.
266
285
  #
267
- # * However, if lines should have varying lengths (e.g. for flowing text around shapes), the
268
- # +available_width+ argument should be an object responding to #call(line_height) where
286
+ # * However, if lines should have varying widths (e.g. for flowing text around shapes), the
287
+ # +width_block+ argument should be an object responding to #call(line_height) where
269
288
  # +line_height+ is the height of the currently layed out line. The caller is responsible
270
- # for tracking the height of the already layed out lines. The result of the method call
271
- # should be the available width.
289
+ # for tracking the height of the already layed out lines. This method involves more work
290
+ # and is therefore slower.
272
291
  #
273
292
  # Regardless of whether varying line widths are used or not, each time a line is finished,
274
- # it is yielded to the caller. The second argument +item+ is the TextFragment or InlineBox
275
- # that doesn't fit anymore, or +nil+ in case of mandatory line breaks or when the line break
276
- # occured at a glue item. If the yielded line is empty and the yielded item is not +nil+,
277
- # this single item doesn't fit into the available width; the caller has to handle this
278
- # situation, e.g. by stopping.
293
+ # it is yielded to the caller. The second argument +item+ is the item that caused the line
294
+ # break (e.g. a Box, Glue or Penalty). The return value should be truthy if line wrapping
295
+ # should continue, or falsy if it should stop. If the yielded line is empty and the yielded
296
+ # item is a box item, this single item didn't fit into the available width; the caller has
297
+ # to handle this situation, e.g. by stopping.
279
298
  #
280
299
  # After the algorithm is finished, it returns the unused items.
281
- def self.call(items, available_width, &block)
282
- obj = new(items, available_width)
283
- if available_width.respond_to?(:call)
300
+ def self.call(items, width_block, &block)
301
+ obj = new(items, width_block)
302
+ if width_block.arity == 1
284
303
  obj.variable_width_wrapping(&block)
285
304
  else
286
305
  obj.fixed_width_wrapping(&block)
@@ -291,9 +310,10 @@ module HexaPDF
291
310
 
292
311
  # Creates a new line wrapping object that arranges the +items+ on lines with the given
293
312
  # width.
294
- def initialize(items, available_width)
313
+ def initialize(items, width_block)
295
314
  @items = items
296
- @available_width = @width_block = available_width
315
+ @width_block = width_block
316
+ @available_width = @width_block.call(0)
297
317
  @line_items = []
298
318
  @width = 0
299
319
  @glue_items = []
@@ -302,11 +322,11 @@ module HexaPDF
302
322
  @last_breakpoint_line_items_index = 0
303
323
  @break_prohibited_state = false
304
324
 
305
- @height_calc = LineFragment::HeightCalculator.new
325
+ @height_calc = Line::HeightCalculator.new
306
326
  @line_height = 0
307
327
  end
308
328
 
309
- # Peforms the line wrapping with a fixed width.
329
+ # Peforms line wrapping with a fixed width per line, with line height playing no role.
310
330
  def fixed_width_wrapping
311
331
  index = 0
312
332
 
@@ -318,18 +338,18 @@ module HexaPDF
318
338
  index = reset_line_to_last_breakpoint_state
319
339
  item = @items[index]
320
340
  end
321
- break unless yield(create_line, item.item)
341
+ break unless yield(create_line, item)
322
342
  reset_after_line_break(index)
323
343
  redo
324
344
  end
325
345
  when :glue
326
346
  unless add_glue_item(item.item, index)
327
- break unless yield(create_line, nil)
347
+ break unless yield(create_line, item)
328
348
  reset_after_line_break(index + 1)
329
349
  end
330
350
  when :penalty
331
351
  if item.penalty <= -Penalty::INFINITY
332
- break unless yield(create_unjustified_line, nil)
352
+ break unless yield(create_unjustified_line, item)
333
353
  reset_after_line_break(index + 1)
334
354
  elsif item.penalty >= Penalty::INFINITY
335
355
  @break_prohibited_state = true
@@ -365,7 +385,6 @@ module HexaPDF
365
385
  # Performs the line wrapping with variable widths.
366
386
  def variable_width_wrapping
367
387
  index = 0
368
- @available_width = @width_block.call(@line_height)
369
388
 
370
389
  while (item = @items[index])
371
390
  case item.type
@@ -382,18 +401,18 @@ module HexaPDF
382
401
  index = reset_line_to_last_breakpoint_state
383
402
  item = @items[index]
384
403
  end
385
- break unless yield(create_line, item.item)
404
+ break unless yield(create_line, item)
386
405
  reset_after_line_break(index)
387
406
  redo
388
407
  end
389
408
  when :glue
390
409
  unless add_glue_item(item.item, index)
391
- break unless yield(create_line, nil)
410
+ break unless yield(create_line, item)
392
411
  reset_after_line_break(index + 1)
393
412
  end
394
413
  when :penalty
395
414
  if item.penalty <= -Penalty::INFINITY
396
- break unless yield(create_unjustified_line, nil)
415
+ break unless yield(create_unjustified_line, item)
397
416
  reset_after_line_break(index + 1)
398
417
  elsif item.penalty >= Penalty::INFINITY
399
418
  @break_prohibited_state = true
@@ -474,12 +493,12 @@ module HexaPDF
474
493
  @width + item.width <= @available_width
475
494
  end
476
495
 
477
- # Creates a LineFragment object from the current line items.
496
+ # Creates a Line object from the current line items.
478
497
  def create_line
479
- LineFragment.new(@line_items)
498
+ Line.new(@line_items)
480
499
  end
481
500
 
482
- # Creates a LineFragment object from the current line items that ignores line justification.
501
+ # Creates a Line object from the current line items that ignores line justification.
483
502
  def create_unjustified_line
484
503
  create_line.tap(&:ignore_justification!)
485
504
  end
@@ -494,6 +513,7 @@ module HexaPDF
494
513
  @last_breakpoint_index = index
495
514
  @last_breakpoint_line_items_index = 0
496
515
  @break_prohibited_state = false
516
+ @available_width = @width_block.call(0)
497
517
 
498
518
  @line_height = 0
499
519
  @height_calc.reset
@@ -502,12 +522,12 @@ module HexaPDF
502
522
  end
503
523
 
504
524
 
505
- # Creates a new TextBox object for the given text and returns it.
525
+ # Creates a new TextLayouter object for the given text and returns it.
506
526
  #
507
527
  # See ::new for information on +height+.
508
528
  #
509
- # The style of the text box can be specified using additional options, of which font is
510
- # mandatory.
529
+ # The style that gets applied to the text and the layout itself can be specified using
530
+ # additional options, of which font is mandatory.
511
531
  def self.create(text, width:, height: nil, x_offsets: nil, **options)
512
532
  frag = TextFragment.create(text, **options)
513
533
  new(items: [frag], width: width, height: height, x_offsets: x_offsets, style: frag.style)
@@ -519,19 +539,19 @@ module HexaPDF
519
539
  # Style#text_segmentation_algorithm, Style#text_line_wrapping_algorithm
520
540
  attr_reader :style
521
541
 
522
- # The items (TextFragment and InlineBox objects) of the text box that should be layed out.
542
+ # The items (TextFragment and InlineBox objects) that should be layed out.
523
543
  attr_reader :items
524
544
 
525
- # Array of LineFragment objects describing the lines of the text box.
545
+ # Array of Line objects describing the layed out lines.
526
546
  #
527
547
  # The array is only valid after #fit was called.
528
548
  attr_reader :lines
529
549
 
530
- # The actual height of the text box. Can be +nil+ if the items have not been layed out yet,
531
- # i.e. if #fit has not been called.
550
+ # The actual height of the layed out text. Can be +nil+ if the items have not been layed out
551
+ # yet, i.e. if #fit has not been called.
532
552
  attr_reader :actual_height
533
553
 
534
- # Creates a new TextBox object with the given width containing the given items.
554
+ # Creates a new TextLayouter object with the given width containing the given items.
535
555
  #
536
556
  # The width can either be a simple number specifying a fixed width, or an object that responds
537
557
  # to #call(height, line_height) where +height+ is the bottom of last line and +line_height+ is
@@ -539,12 +559,15 @@ module HexaPDF
539
559
  # these height restrictions.
540
560
  #
541
561
  # The optional +x_offsets+ argument works like +width+ but can be used to specify (varying)
542
- # offsets from the left of the box (e.g. when the left side of the text should follow a
543
- # certain shape).
562
+ # offsets from the left side (e.g. when the left side of the text should follow a certain
563
+ # shape).
564
+ #
565
+ # The height is optional and if not specified means that the text layout has infinite height.
544
566
  #
545
- # The height is optional and if not specified means that the text box has infinite height.
567
+ # The +style+ argument can either be a Style object or a hash of style options. See #style for
568
+ # the properties that are used by the layouter.
546
569
  def initialize(items: [], width:, height: nil, x_offsets: nil, style: Style.new)
547
- @style = style
570
+ @style = (style.kind_of?(Style) ? style : Style.new(style))
548
571
  @lines = []
549
572
  self.items = items
550
573
  @width = width
@@ -552,7 +575,7 @@ module HexaPDF
552
575
  @x_offsets = x_offsets && (x_offsets.respond_to?(:call) ? x_offsets : proc { x_offsets })
553
576
  end
554
577
 
555
- # Sets the items to be arranged by the text box, clearing the internal state.
578
+ # Sets the items to be arranged by the text layouter, clearing the internal state.
556
579
  #
557
580
  # If the items array contains items before text segmentation, the text segmentation algorithm
558
581
  # is automatically applied.
@@ -566,80 +589,105 @@ module HexaPDF
566
589
  end
567
590
 
568
591
  # :call-seq:
569
- # text_box.fit -> [remaining_items, actual_height]
592
+ # text_layouter.fit -> [remaining_items, reason]
570
593
  #
571
- # Fits the items into the text box and returns the remaining items as well as the actual
572
- # height needed.
594
+ # Fits the items into the set area and returns the remaining items as well as the reason why
595
+ # there are remaining items.
573
596
  #
574
- # Note: If the text box height has not been set and variable line widths are used, no search
575
- # for a possible vertical offset is done in case a single item doesn't fit.
597
+ # The reason can be +:success+ if there are no remaining items, +:box+ if a single text or
598
+ # inline box item is too wide to fit alone on a line, or +:height+ if there was not enough
599
+ # height for all items.
600
+ #
601
+ # The fitted lines can be retrieved via #lines and the total height via #actual_height.
602
+ #
603
+ # Note: If no height has been set and variable line widths are used, no search for a possible
604
+ # vertical offset is done in case a single item doesn't fit.
576
605
  #
577
606
  # This method is automatically called as part of the drawing routine but it can also be used
578
- # by itself to determine the actual height of the text box.
607
+ # by itself to determine the actual height of the layed out text.
579
608
  def fit
580
609
  @lines.clear
581
610
  @actual_height = 0
582
- y_offset = 0
583
-
584
- items = @items
585
- if style.text_indent != 0
586
- items = [Box.new(InlineBox.new(style.text_indent, 0) { })].concat(items)
587
- end
588
-
589
- if @width.respond_to?(:call)
590
- width_arg = proc {|h| @width.call(@actual_height, h)}
591
- width_block = @width
592
- else
593
- width_arg = @width
594
- width_block = proc { @width }
595
- end
596
611
 
597
- rest = style.text_line_wrapping_algorithm.call(items, width_arg) do |line, item|
598
- line << TextFragment.new(items: [], style: style) if item.nil? && line.items.empty?
599
- new_height = @actual_height + line.height +
600
- (@lines.empty? ? 0 : style.line_spacing.gap(@lines.last, line))
601
-
602
- if new_height <= @height && !line.items.empty?
603
- # valid line found, use it
604
- cur_width = width_block.call(@actual_height, line.height)
605
- line.x_offset = horizontal_alignment_offset(line, cur_width)
606
- line.x_offset += @x_offsets.call(@actual_height, line.height) if @x_offsets
607
- line.y_offset = if y_offset
608
- y_offset + (@lines.last ? -@lines.last.y_min + line.y_max : 0)
609
- else
610
- style.line_spacing.baseline_distance(@lines.last, line)
611
- end
612
- @actual_height = new_height
613
- @lines << line
614
- y_offset = nil
615
- true
616
- elsif new_height <= @height && @height != Float::INFINITY
617
- # some height left but item didn't fit on the line, search downwards for usable space
618
- new_height = @actual_height
619
- while item.width > width_block.call(new_height, item.height) && new_height <= @height
620
- new_height += item.height / 3
621
- end
622
- if new_height + item.height <= @height
623
- y_offset = new_height - @actual_height
612
+ rest = @items
613
+ y_offset = 0
614
+ indent = (style.text_indent != 0 ? style.text_indent : 0)
615
+ width_block = if @width.respond_to?(:call)
616
+ proc {|h| @width.call(@actual_height, h) - indent }
617
+ else
618
+ proc { @width - indent }
619
+ end
620
+
621
+ while true
622
+ too_wide_box = nil
623
+
624
+ rest = style.text_line_wrapping_algorithm.call(rest, width_block) do |line, item|
625
+ line << TextFragment.new(items: [], style: style) if item&.type != :box && line.items.empty?
626
+ new_height = @actual_height + line.height +
627
+ (@lines.empty? ? 0 : style.line_spacing.gap(@lines.last, line))
628
+
629
+ if new_height > @height
630
+ nil
631
+ elsif !line.items.empty?
632
+ # valid line found, use it
633
+ cur_width = width_block.call(line.height)
634
+ line.x_offset = indent + horizontal_alignment_offset(line, cur_width)
635
+ line.x_offset += @x_offsets.call(@actual_height, line.height) if @x_offsets
636
+ line.y_offset = if y_offset
637
+ y_offset + (@lines.last ? -@lines.last.y_min + line.y_max : 0)
638
+ else
639
+ style.line_spacing.baseline_distance(@lines.last, line)
640
+ end
624
641
  @actual_height = new_height
642
+ @lines << line
643
+ y_offset = nil
644
+ indent = if item&.type == :penalty && item.penalty == Penalty::PARAGRAPH_BREAK
645
+ style.text_indent
646
+ else
647
+ 0
648
+ end
625
649
  true
650
+ elsif @height != Float::INFINITY
651
+ # some height left but item didn't fit on the line, search downwards for usable space
652
+ old_height = @actual_height
653
+ while item.width > width_block.call(item.height) && @actual_height <= @height
654
+ @actual_height += item.height / 3
655
+ end
656
+ if @actual_height + item.height <= @height
657
+ y_offset = @actual_height - old_height
658
+ true
659
+ else
660
+ @actual_height = old_height
661
+ too_wide_box = item
662
+ nil
663
+ end
626
664
  else
665
+ too_wide_box = item
627
666
  nil
628
667
  end
668
+ end
669
+
670
+ if too_wide_box && too_wide_box.item.kind_of?(TextFragment) &&
671
+ too_wide_box.item.items.size > 1
672
+ rest[0..rest.index(too_wide_box)] = too_wide_box.item.items.map do |item|
673
+ Box.new(TextFragment.new(items: [item], style: too_wide_box.item.style))
674
+ end
675
+ too_wide_box = nil
629
676
  else
630
- nil
677
+ reason = (too_wide_box ? :box : (rest.empty? ? :success : :height))
678
+ break
631
679
  end
632
680
  end
633
681
 
634
- [rest, @actual_height]
682
+ [rest, reason]
635
683
  end
636
684
 
637
- # Draws the text box onto the canvas with the top-left corner being at [x, y].
685
+ # Draws the layed out text onto the canvas with the top-left corner being at [x, y].
638
686
  #
639
687
  # Depending on the value of +fit+ the text may also be fitted:
640
688
  #
641
689
  # * If +true+, then #fit is always called.
642
- # * If +:if_needed+, then #fit is only called if it has been called before.
690
+ # * If +:if_needed+, then #fit is only called if it has not been called before.
643
691
  # * If +false+, then #fit is never called.
644
692
  def draw(canvas, x, y, fit: :if_needed)
645
693
  self.fit if fit == true || (!@actual_height && fit == :if_needed)
@@ -649,7 +697,15 @@ module HexaPDF
649
697
  y -= initial_baseline_offset + @lines.first.y_offset
650
698
  @lines.each_with_index do |line, index|
651
699
  line_x = x + line.x_offset
652
- line.each {|item, item_x, item_y| item.draw(canvas, line_x + item_x, y + item_y) }
700
+ line.each do |item, item_x, item_y|
701
+ if item.kind_of?(TextFragment)
702
+ item.draw(canvas, line_x + item_x, y + item_y)
703
+ elsif !item.empty?
704
+ canvas.restore_graphics_state
705
+ item.draw(canvas, line_x + item_x, y + item_y)
706
+ canvas.save_graphics_state
707
+ end
708
+ end
653
709
  y -= @lines[index + 1].y_offset if @lines[index + 1]
654
710
  end
655
711
  end
@@ -657,20 +713,19 @@ module HexaPDF
657
713
 
658
714
  private
659
715
 
660
- # Returns the initial baseline offset from the top of the text box, based on the valign style
661
- # option.
716
+ # Returns the initial baseline offset from the top, based on the valign style option.
662
717
  def initial_baseline_offset
663
718
  case style.valign
664
719
  when :top
665
720
  @lines.first.y_max
666
721
  when :center
667
722
  if @height == Float::INFINITY
668
- raise HexaPDF::Error, "Can't vertically align a text box with unlimited height"
723
+ raise HexaPDF::Error, "Can't vertically align when using unlimited height"
669
724
  end
670
725
  (@height - @actual_height) / 2.0 + @lines.first.y_max
671
726
  when :bottom
672
727
  if @height == Float::INFINITY
673
- raise HexaPDF::Error, "Can't vertically align a text box with unlimited height"
728
+ raise HexaPDF::Error, "Can't vertically align when using unlimited height"
674
729
  end
675
730
  (@height - @actual_height) + @lines.first.y_max
676
731
  end