hexapdf 0.4.0 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -0
  3. data/CONTRIBUTERS +1 -1
  4. data/README.md +5 -5
  5. data/VERSION +1 -1
  6. data/examples/emoji-smile.png +0 -0
  7. data/examples/emoji-wink.png +0 -0
  8. data/examples/graphics.rb +9 -8
  9. data/examples/standard_pdf_fonts.rb +2 -1
  10. data/examples/text_box_alignment.rb +47 -0
  11. data/examples/text_box_inline_boxes.rb +56 -0
  12. data/examples/text_box_line_wrapping.rb +57 -0
  13. data/examples/text_box_shapes.rb +166 -0
  14. data/examples/text_box_styling.rb +72 -0
  15. data/examples/truetype.rb +3 -4
  16. data/lib/hexapdf/cli/optimize.rb +2 -2
  17. data/lib/hexapdf/configuration.rb +8 -6
  18. data/lib/hexapdf/content/canvas.rb +8 -5
  19. data/lib/hexapdf/content/parser.rb +3 -2
  20. data/lib/hexapdf/content/processor.rb +14 -3
  21. data/lib/hexapdf/document.rb +1 -0
  22. data/lib/hexapdf/document/fonts.rb +2 -1
  23. data/lib/hexapdf/document/pages.rb +23 -0
  24. data/lib/hexapdf/font/invalid_glyph.rb +78 -0
  25. data/lib/hexapdf/font/true_type/font.rb +14 -3
  26. data/lib/hexapdf/font/true_type/table.rb +1 -0
  27. data/lib/hexapdf/font/true_type/table/cmap.rb +1 -1
  28. data/lib/hexapdf/font/true_type/table/cmap_subtable.rb +1 -0
  29. data/lib/hexapdf/font/true_type/table/glyf.rb +4 -0
  30. data/lib/hexapdf/font/true_type/table/kern.rb +170 -0
  31. data/lib/hexapdf/font/true_type/table/post.rb +5 -1
  32. data/lib/hexapdf/font/true_type_wrapper.rb +71 -24
  33. data/lib/hexapdf/font/type1/afm_parser.rb +3 -2
  34. data/lib/hexapdf/font/type1/character_metrics.rb +0 -9
  35. data/lib/hexapdf/font/type1/font.rb +11 -0
  36. data/lib/hexapdf/font/type1/font_metrics.rb +6 -1
  37. data/lib/hexapdf/font/type1_wrapper.rb +51 -7
  38. data/lib/hexapdf/font_loader/standard14.rb +1 -1
  39. data/lib/hexapdf/layout.rb +51 -0
  40. data/lib/hexapdf/layout/inline_box.rb +95 -0
  41. data/lib/hexapdf/layout/line_fragment.rb +333 -0
  42. data/lib/hexapdf/layout/numeric_refinements.rb +56 -0
  43. data/lib/hexapdf/layout/style.rb +365 -0
  44. data/lib/hexapdf/layout/text_box.rb +727 -0
  45. data/lib/hexapdf/layout/text_fragment.rb +206 -0
  46. data/lib/hexapdf/layout/text_shaper.rb +155 -0
  47. data/lib/hexapdf/task.rb +0 -1
  48. data/lib/hexapdf/task/dereference.rb +1 -1
  49. data/lib/hexapdf/tokenizer.rb +3 -2
  50. data/lib/hexapdf/type/font_descriptor.rb +2 -1
  51. data/lib/hexapdf/type/font_type0.rb +3 -1
  52. data/lib/hexapdf/type/form.rb +12 -4
  53. data/lib/hexapdf/version.rb +1 -1
  54. data/test/hexapdf/common_tokenizer_tests.rb +7 -0
  55. data/test/hexapdf/content/common.rb +8 -0
  56. data/test/hexapdf/content/test_canvas.rb +10 -22
  57. data/test/hexapdf/content/test_processor.rb +4 -1
  58. data/test/hexapdf/document/test_pages.rb +16 -0
  59. data/test/hexapdf/font/test_invalid_glyph.rb +34 -0
  60. data/test/hexapdf/font/test_true_type_wrapper.rb +25 -11
  61. data/test/hexapdf/font/test_type1_wrapper.rb +26 -10
  62. data/test/hexapdf/font/true_type/table/common.rb +27 -0
  63. data/test/hexapdf/font/true_type/table/test_cmap.rb +14 -20
  64. data/test/hexapdf/font/true_type/table/test_cmap_subtable.rb +7 -0
  65. data/test/hexapdf/font/true_type/table/test_glyf.rb +8 -6
  66. data/test/hexapdf/font/true_type/table/test_head.rb +9 -13
  67. data/test/hexapdf/font/true_type/table/test_hhea.rb +16 -23
  68. data/test/hexapdf/font/true_type/table/test_hmtx.rb +4 -7
  69. data/test/hexapdf/font/true_type/table/test_kern.rb +61 -0
  70. data/test/hexapdf/font/true_type/table/test_loca.rb +7 -13
  71. data/test/hexapdf/font/true_type/table/test_maxp.rb +4 -9
  72. data/test/hexapdf/font/true_type/table/test_name.rb +14 -17
  73. data/test/hexapdf/font/true_type/table/test_os2.rb +3 -5
  74. data/test/hexapdf/font/true_type/table/test_post.rb +21 -19
  75. data/test/hexapdf/font/true_type/test_font.rb +4 -0
  76. data/test/hexapdf/font/type1/common.rb +6 -0
  77. data/test/hexapdf/font/type1/test_afm_parser.rb +9 -0
  78. data/test/hexapdf/font/type1/test_font.rb +6 -0
  79. data/test/hexapdf/layout/test_inline_box.rb +40 -0
  80. data/test/hexapdf/layout/test_line_fragment.rb +206 -0
  81. data/test/hexapdf/layout/test_style.rb +143 -0
  82. data/test/hexapdf/layout/test_text_box.rb +640 -0
  83. data/test/hexapdf/layout/test_text_fragment.rb +208 -0
  84. data/test/hexapdf/layout/test_text_shaper.rb +64 -0
  85. data/test/hexapdf/task/test_dereference.rb +1 -0
  86. data/test/hexapdf/test_writer.rb +2 -2
  87. data/test/hexapdf/type/test_font_descriptor.rb +4 -2
  88. data/test/hexapdf/type/test_font_type0.rb +7 -0
  89. data/test/hexapdf/type/test_form.rb +12 -0
  90. metadata +29 -2
@@ -0,0 +1,56 @@
1
+ # -*- encoding: utf-8 -*-
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-2017 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
+
34
+ module HexaPDF
35
+ module Layout
36
+
37
+ # Provides a refinement of the Numeric class so that kerning numbers can more seamlessly be used
38
+ # together with actual glyphs.
39
+ module NumericRefinements
40
+ refine Numeric do
41
+ def x_min
42
+ -self
43
+ end
44
+
45
+ def y_min
46
+ 0
47
+ end
48
+
49
+ def y_max
50
+ 0
51
+ end
52
+ end
53
+ end
54
+
55
+ end
56
+ end
@@ -0,0 +1,365 @@
1
+ # -*- encoding: utf-8 -*-
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-2017 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
+
34
+ require 'hexapdf/error'
35
+
36
+ module HexaPDF
37
+ module Layout
38
+
39
+ # A Style is a container for properties that describe the appearance of text or graphics.
40
+ #
41
+ # Each property except #font has a default value, so only the desired properties need to be
42
+ # changed.
43
+ class Style
44
+
45
+ # Defines how the distance between the baselines of two adjacent text lines is determined:
46
+ #
47
+ # :single::
48
+ # :proportional with value 1.
49
+ #
50
+ # :double::
51
+ # :proportional with value 2.
52
+ #
53
+ # :proportional::
54
+ # The y_min of the first line and the y_max of the second line are multiplied with the
55
+ # specified value, and the sum is used as baseline distance.
56
+ #
57
+ # :fixed::
58
+ # The distance between the baselines is set to the specified value.
59
+ #
60
+ # :leading::
61
+ # The distance between the baselines is set to the sum of the y_min of the first line, the
62
+ # y_max of the second line and the specified value.
63
+ class LineSpacing
64
+
65
+ # The type of line spacing - see LineSpacing
66
+ attr_reader :type
67
+
68
+ # The value (needed for some types) - see LineSpacing
69
+ attr_reader :value
70
+
71
+ # Creates a new LineSpacing object for the given type which can be any valid line spacing
72
+ # type or a LineSpacing object.
73
+ def initialize(type, value: 1)
74
+ case type
75
+ when :single
76
+ @type = :proportional
77
+ @value = 1
78
+ when :double
79
+ @type = :proportional
80
+ @value = 2
81
+ when :fixed, :proportional, :leading
82
+ unless value.kind_of?(Numeric)
83
+ raise ArgumentError, "Need a valid number for #{type} line spacing"
84
+ end
85
+ @type = type
86
+ @value = value
87
+ when LineSpacing
88
+ @type = type.type
89
+ @value = type.value
90
+ else
91
+ raise ArgumentError, "Invalid type #{type} for line spacing"
92
+ end
93
+ end
94
+
95
+ # Returns the distance between the baselines of the two given LineFragment objects.
96
+ def baseline_distance(line1, line2)
97
+ case type
98
+ when :proportional then (line1.y_min.abs + line2.y_max) * value
99
+ when :fixed then value
100
+ when :leading then line1.y_min.abs + line2.y_max + value
101
+ end
102
+ end
103
+
104
+ # Returns the gap between the two given LineFragment objects, i.e. the distance between the
105
+ # y_min of the first line and the y_max of the second line.
106
+ def gap(line1, line2)
107
+ case type
108
+ when :proportional then (line1.y_min.abs + line2.y_max) * (value - 1)
109
+ when :fixed then value - line1.y_min.abs - line2.y_max
110
+ when :leading then value
111
+ end
112
+ end
113
+
114
+ end
115
+
116
+ UNSET = ::Object.new # :nodoc:
117
+
118
+ # Creates a new Style object.
119
+ #
120
+ # The +options+ hash may be used to set the initial values of properties by using keys
121
+ # equivalent to the property names.
122
+ #
123
+ # Example:
124
+ # Style.new(font_size: 15, align: :center, valign: center)
125
+ def initialize(**options)
126
+ options.each {|key, value| send(key, value)}
127
+ @scaled_item_widths = {}
128
+ end
129
+
130
+ ##
131
+ # :method: font
132
+ # :call-seq:
133
+ # font(name = nil)
134
+ #
135
+ # The font to be used, must be set to a valid font wrapper object before it can be used.
136
+ #
137
+ # This is the only style property without a default value!
138
+ #
139
+ # See: HexaPDF::Content::Canvas#font
140
+
141
+ ##
142
+ # :method: font_size
143
+ # :call-seq:
144
+ # font_size(size = nil)
145
+ #
146
+ # The font size, defaults to 10.
147
+ #
148
+ # See: HexaPDF::Content::Canvas#font_size
149
+
150
+ ##
151
+ # :method: character_spacing
152
+ # :call-seq:
153
+ # character_spacing(amount = nil)
154
+ #
155
+ # The character spacing, defaults to 0 (i.e. no additional character spacing).
156
+ #
157
+ # See: HexaPDF::Content::Canvas#character_spacing
158
+
159
+ ##
160
+ # :method: word_spacing
161
+ # :call-seq:
162
+ # word_spacing(amount = nil)
163
+ #
164
+ # The word spacing, defaults to 0 (i.e. no additional word spacing).
165
+ #
166
+ # See: HexaPDF::Content::Canvas#word_spacing
167
+
168
+ ##
169
+ # :method: horizontal_scaling
170
+ # :call-seq:
171
+ # horizontal_scaling(percent = nil)
172
+ #
173
+ # The horizontal scaling, defaults to 100 (in percent, i.e. normal scaling).
174
+ #
175
+ # See: HexaPDF::Content::Canvas#horizontal_scaling
176
+
177
+ ##
178
+ # :method: text_rise
179
+ # :call-seq:
180
+ # text_rise(amount = nil)
181
+ #
182
+ # The text rise, i.e. the vertical offset from the baseline, defaults to 0.
183
+ #
184
+ # See: HexaPDF::Content::Canvas#text_rise
185
+
186
+ ##
187
+ # :method: font_features
188
+ # :call-seq:
189
+ # font_features(features = nil)
190
+ #
191
+ # The font features (e.g. kerning, ligatures, ...) that should be applied by the shaping
192
+ # engine, defaults to {} (i.e. no font features are applied).
193
+ #
194
+ # Each feature to be applied is indicated by a key with a truthy value.
195
+ #
196
+ # See: HexaPDF::Layout::TextShaper#shape_text for available features.
197
+
198
+ ##
199
+ # :method: align
200
+ # :call-seq:
201
+ # align(direction = nil)
202
+ #
203
+ # The horizontal alignment of text, defaults to :left.
204
+ #
205
+ # Possible values:
206
+ #
207
+ # :left:: Left-align the text, i.e. the right side is rugged.
208
+ # :center:: Center the text horizontally.
209
+ # :right:: Right-align the text, i.e. the left side is rugged.
210
+ # :justify:: Justify the text, except for those lines that end in a hard line break.
211
+
212
+ ##
213
+ # :method: valign
214
+ # :call-seq:
215
+ # valign(direction = nil)
216
+ #
217
+ # The vertical alignment of items (normally text) inside a box, defaults to :top.
218
+ #
219
+ # Possible values:
220
+ #
221
+ # :top:: Vertically align the items to the top of the box.
222
+ # :center:: Vertically align the items in the center of the box.
223
+ # :bottom:: Vertically align the items to the bottom of the box.
224
+
225
+ ##
226
+ # :method: text_indent
227
+ # :call-seq:
228
+ # text_indent(amount = nil)
229
+ #
230
+ # The indentation to be used for the first line of a sequence of text lines, defaults to 0.
231
+
232
+ [
233
+ [:font, "raise HexaPDF::Error, 'No font set'"],
234
+ [:font_size, 10],
235
+ [:character_spacing, 0],
236
+ [:word_spacing, 0],
237
+ [:horizontal_scaling, 100],
238
+ [:text_rise, 0],
239
+ [:font_features, {}],
240
+ [:align, :left],
241
+ [:valign, :top],
242
+ [:text_indent, 0],
243
+ ].each do |name, default|
244
+ default = default.inspect unless default.kind_of?(String)
245
+ module_eval(<<-EOF, __FILE__, __LINE__)
246
+ def #{name}(value = UNSET)
247
+ value == UNSET ? (@#{name} ||= #{default}) : (@#{name} = value; self)
248
+ end
249
+ EOF
250
+ alias_method("#{name}=", name)
251
+ end
252
+
253
+ # :call-seq:
254
+ # line_spacing(type = nil, value = nil)
255
+ #
256
+ # The spacing between consecutive lines, defaults to type = :single.
257
+ #
258
+ # See: LineSpacing
259
+ def line_spacing(type = UNSET, value = nil)
260
+ if type == UNSET
261
+ @line_spacing ||= LineSpacing.new(:single)
262
+ else
263
+ @line_spacing = LineSpacing.new(type, value: value)
264
+ self
265
+ end
266
+ end
267
+ alias_method(:line_spacing=, :line_spacing)
268
+
269
+ # :call-seq:
270
+ # text_segmentation_algorithm(algorithm = nil) {|items| block }
271
+ #
272
+ # The algorithm to use for text segmentation purposes, defaults to
273
+ # TextBox::SimpleTextSegmentation.
274
+ #
275
+ # When setting the algorithm, either an object that responds to #call(items) or a block can be
276
+ # used.
277
+ def text_segmentation_algorithm(algorithm = UNSET, &block)
278
+ if algorithm == UNSET && !block
279
+ @text_segmentation_algorithm ||= TextBox::SimpleTextSegmentation
280
+ else
281
+ @text_segmentation_algorithm = (algorithm != UNSET ? algorithm : block)
282
+ self
283
+ end
284
+ end
285
+ alias_method(:text_segmentation_algorithm=, :text_segmentation_algorithm)
286
+
287
+ # :call-seq:
288
+ # text_line_wrapping_algorithm(algorithm = nil) {|items| block }
289
+ #
290
+ # The line wrapping algorithm that should be used, defaults to TextBox::SimpleLineWrapping.
291
+ #
292
+ # When setting the algorithm, either an object that responds to #call or a block can be used.
293
+ # See TextBox::SimpleLineWrapping#call for the needed method signature.
294
+ def text_line_wrapping_algorithm(algorithm = UNSET, &block)
295
+ if algorithm == UNSET && !block
296
+ @text_line_wrapping_algorithm ||= TextBox::SimpleLineWrapping
297
+ else
298
+ @text_line_wrapping_algorithm = (algorithm != UNSET ? algorithm : block)
299
+ self
300
+ end
301
+ end
302
+ alias_method(:text_line_wrapping_algorithm=, :text_line_wrapping_algorithm)
303
+
304
+ # The font size scaled appropriately.
305
+ def scaled_font_size
306
+ @scaled_font_size ||= font_size / 1000.0 * scaled_horizontal_scaling
307
+ end
308
+
309
+ # The character spacing scaled appropriately.
310
+ def scaled_character_spacing
311
+ @scaled_character_spacing ||= character_spacing * scaled_horizontal_scaling
312
+ end
313
+
314
+ # The word spacing scaled appropriately.
315
+ def scaled_word_spacing
316
+ @scaled_word_spacing ||= word_spacing * scaled_horizontal_scaling
317
+ end
318
+
319
+ # The horizontal scaling scaled appropriately.
320
+ def scaled_horizontal_scaling
321
+ @scaled_horizontal_scaling ||= horizontal_scaling / 100.0
322
+ end
323
+
324
+ # The ascender of the font scaled appropriately.
325
+ def scaled_font_ascender
326
+ @ascender ||= font.wrapped_font.ascender * font.scaling_factor * font_size / 1000
327
+ end
328
+
329
+ # The descender of the font scaled appropriately.
330
+ def scaled_font_descender
331
+ @descender ||= font.wrapped_font.descender * font.scaling_factor * font_size / 1000
332
+ end
333
+
334
+ # Returns the width of the item scaled appropriately (by taking font size, characters spacing,
335
+ # word spacing and horizontal scaling into account).
336
+ #
337
+ # The item may be a (singleton) glyph object or an integer/float, i.e. items that can appear
338
+ # inside a TextFragment.
339
+ def scaled_item_width(item)
340
+ @scaled_item_widths[item.object_id] ||=
341
+ begin
342
+ if item.kind_of?(Numeric)
343
+ -item * scaled_font_size
344
+ else
345
+ item.width * scaled_font_size + scaled_character_spacing +
346
+ (item.apply_word_spacing? ? scaled_word_spacing : 0)
347
+ end
348
+ end
349
+ end
350
+
351
+ # Clears all cached values.
352
+ #
353
+ # This method needs to be called if the following style properties are changed and values were
354
+ # already cached: font, font_size, character_spacing, word_spacing, horizontal_scaling,
355
+ # ascender, descender.
356
+ def clear_cache
357
+ @scaled_font_size = @scaled_character_spacing = @scaled_word_spacing = nil
358
+ @scaled_horizontal_scaling = @ascender = @descender = nil
359
+ @scaled_item_widths.clear
360
+ end
361
+
362
+ end
363
+
364
+ end
365
+ end
@@ -0,0 +1,727 @@
1
+ # -*- encoding: utf-8 -*-
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-2017 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
+
34
+ require 'hexapdf/error'
35
+ require 'hexapdf/layout/text_fragment'
36
+ require 'hexapdf/layout/inline_box'
37
+ require 'hexapdf/layout/line_fragment'
38
+ require 'hexapdf/layout/numeric_refinements'
39
+
40
+ module HexaPDF
41
+ module Layout
42
+
43
+ # Arranges text and inline objects into lines according to a specified width and height as well
44
+ # as other options.
45
+ #
46
+ # == Features
47
+ #
48
+ # * Existing line breaking characters inside of TextFragment objects are respected when fitting
49
+ # text. If this is not wanted, they have to be removed beforehand.
50
+ #
51
+ # * The first line may be indented by setting Style#text_indent which may also be negative.
52
+ #
53
+ # == Layouting Algorithm
54
+ #
55
+ # Laying out text consists of two phases:
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.
60
+ #
61
+ # 2. The pieces are arranged into lines using a very simple algorithm that just puts the maximum
62
+ # number of consecutive pieces into each line. This step is done by the SimpleLineWrapping
63
+ # module.
64
+ class TextBox
65
+
66
+ using NumericRefinements
67
+
68
+ # Used for layouting. Describes an item with a fixed width, like an InlineBox or TextFragment.
69
+ class Box
70
+
71
+ # The wrapped item.
72
+ attr_reader :item
73
+
74
+ # Creates a new Box for the item.
75
+ def initialize(item)
76
+ @item = item
77
+ end
78
+
79
+ # The width of the item.
80
+ def width
81
+ @item.width
82
+ end
83
+
84
+ # Returns :box.
85
+ def type
86
+ :box
87
+ end
88
+
89
+ end
90
+
91
+ # Used for layouting. Describes a glue item, i.e. an item describing white space that could
92
+ # potentially be shrunk or stretched.
93
+ class Glue
94
+
95
+ # The wrapped item.
96
+ attr_reader :item
97
+
98
+ # The amount by which the glue could be stretched.
99
+ attr_reader :stretchability
100
+
101
+ # The amount by which the glue could be shrunk.
102
+ attr_reader :shrinkability
103
+
104
+ # Creates a new Glue for the item.
105
+ def initialize(item, stretchability = item.width / 2, shrinkability = item.width / 3)
106
+ @item = item
107
+ @stretchability = stretchability
108
+ @shrinkability = shrinkability
109
+ end
110
+
111
+ # Returns :glue.
112
+ def type
113
+ :glue
114
+ end
115
+
116
+ end
117
+
118
+ # Used for layouting. Describes a penalty item, i.e. a point where a break is allowed.
119
+ #
120
+ # If the penalty is greater than or equal to INFINITY, a break is forbidden. If it is smaller
121
+ # than or equal to -INFINITY, a break is mandatory.
122
+ #
123
+ # If a penalty contains an item and a break occurs at the penalty (taking the width of the
124
+ # penalty/item into account), then the penality item must be the last item of the line.
125
+ class Penalty
126
+
127
+ # All numbers greater than this one are deemed infinite.
128
+ INFINITY = 1000
129
+
130
+ # The penalty for breaking at this point.
131
+ attr_reader :penalty
132
+
133
+ # The width assigned to this item.
134
+ attr_reader :width
135
+
136
+ # The wrapped item.
137
+ attr_reader :item
138
+
139
+ # Creates a new Penalty with the given penality.
140
+ def initialize(penalty, width = 0, item: nil)
141
+ @penalty = penalty
142
+ @width = width
143
+ @item = item
144
+ end
145
+
146
+ # Returns :penalty.
147
+ def type
148
+ :penalty
149
+ end
150
+
151
+ # Singleton object describing a Penalty for a mandatory break.
152
+ MandatoryBreak = new(-Penalty::INFINITY)
153
+
154
+ # Singleton object describing a Penalty for a prohibited break.
155
+ ProhibitedBreak = new(Penalty::INFINITY)
156
+
157
+ # Singleton object describing a standard Penalty, e.g. for hyphens.
158
+ Standard = new(50)
159
+
160
+ end
161
+
162
+ # Implementation of a simple text segmentation algorithm.
163
+ #
164
+ # The algorithm breaks TextFragment objects into objects wrapped by Box, Glue or Penalty
165
+ # items, and inserts additional Penalty items when needed:
166
+ #
167
+ # * Any valid Unicode newline separator inserts a Penalty object describing a mandatory break.
168
+ #
169
+ # See http://www.unicode.org/reports/tr18/#Line_Boundaries
170
+ #
171
+ # * Spaces and tabulators are wrapped by Glue objects, allowing breaks.
172
+ #
173
+ # * Non-breaking spaces are wrapped into Penalty objects that prohibit line breaking.
174
+ #
175
+ # * Hyphens are attached to the preceeding text fragment (or are a standalone text fragment)
176
+ # and followed by a Penalty object to allow a break.
177
+ #
178
+ # * If a soft-hyphens is encountered, a hyphen wrapped by a Penalty object is inserted to
179
+ # allow a break.
180
+ #
181
+ # * If a zero-width-space is encountered, a Penalty object is inserted to allow a break.
182
+ module SimpleTextSegmentation
183
+
184
+ # Breaks are detected at: space, tab, zero-width-space, non-breaking space, hyphen,
185
+ # soft-hypen and any valid Unicode newline separator
186
+ BREAK_RE = /[ \u{A}-\u{D}\u{85}\u{2028}\u{2029}\t\u{200B}\u{00AD}\u{00A0}-]/
187
+
188
+ # Breaks the items (an array of InlineBox and TextFragment objects) into atomic pieces
189
+ # wrapped by Box, Glue or Penalty items, and returns those as an array.
190
+ def self.call(items)
191
+ result = []
192
+ glues = {}
193
+ items.each do |item|
194
+ if item.kind_of?(InlineBox)
195
+ result << Box.new(item)
196
+ else
197
+ i = 0
198
+ while i < item.items.size
199
+ # Collect characters and kerning values until break character is encountered
200
+ box_items = []
201
+ while (glyph = item.items[i]) &&
202
+ (glyph.kind_of?(Numeric) || !BREAK_RE.match?(glyph.str))
203
+ box_items << glyph
204
+ i += 1
205
+ end
206
+
207
+ # A hyphen belongs to the text fragment
208
+ box_items << glyph if glyph && !glyph.kind_of?(Numeric) && glyph.str == '-'.freeze
209
+
210
+ unless box_items.empty?
211
+ result << Box.new(TextFragment.new(items: box_items.freeze, style: item.style))
212
+ end
213
+
214
+ if glyph
215
+ case glyph.str
216
+ when ' '
217
+ glues[item.style] ||=
218
+ Glue.new(TextFragment.new(items: [glyph].freeze, style: item.style))
219
+ result << glues[item.style]
220
+ when "\n", "\v", "\f", "\u{85}", "\u{2028}", "\u{2029}"
221
+ result << Penalty::MandatoryBreak
222
+ when "\r"
223
+ if item.items[i + 1]&.kind_of?(Numeric) || item.items[i + 1].str != "\n"
224
+ result << Penalty::MandatoryBreak
225
+ end
226
+ when '-'
227
+ result << Penalty::Standard
228
+ when "\t"
229
+ spaces = [item.style.font.decode_utf8(" ").first] * 8
230
+ result << Glue.new(TextFragment.new(items: spaces.freeze, style: item.style))
231
+ when "\u{00AD}"
232
+ hyphen = item.style.font.decode_utf8("-").first
233
+ frag = TextFragment.new(items: [hyphen].freeze, style: item.style)
234
+ result << Penalty.new(Penalty::Standard.penalty, frag.width, item: frag)
235
+ when "\u{00A0}"
236
+ space = item.style.font.decode_utf8(" ").first
237
+ frag = TextFragment.new(items: [space].freeze, style: item.style)
238
+ result << Penalty.new(Penalty::ProhibitedBreak.penalty, frag.width, item: frag)
239
+ when "\u{200B}"
240
+ result << Penalty.new(0)
241
+ end
242
+ end
243
+ i += 1
244
+ end
245
+ end
246
+ end
247
+ result
248
+ end
249
+ end
250
+
251
+ # Implementation of a simple line wrapping algorithm.
252
+ #
253
+ # The algorithm arranges the given items so that the maximum number is put onto each line,
254
+ # taking the differences of Box, Glue and Penalty items into account.
255
+ class SimpleLineWrapping
256
+
257
+ # :call-seq:
258
+ # SimpleLineWrapping.call(items, available_width) {|line, item| block } -> rest
259
+ #
260
+ # Arranges the items into lines.
261
+ #
262
+ # The +available_width+ argument can either be a simple number or a callable object:
263
+ #
264
+ # * If all lines should have the same width, the +available_width+ argument should be a
265
+ # number. This is the general case.
266
+ #
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
269
+ # +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.
272
+ #
273
+ # 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.
279
+ #
280
+ # 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)
284
+ obj.variable_width_wrapping(&block)
285
+ else
286
+ obj.fixed_width_wrapping(&block)
287
+ end
288
+ end
289
+
290
+ private_class_method :new
291
+
292
+ # Creates a new line wrapping object that arranges the +items+ on lines with the given
293
+ # width.
294
+ def initialize(items, available_width)
295
+ @items = items
296
+ @available_width = @width_block = available_width
297
+ @line_items = []
298
+ @width = 0
299
+ @glue_items = []
300
+ @beginning_of_line_index = 0
301
+ @last_breakpoint_index = 0
302
+ @last_breakpoint_line_items_index = 0
303
+ @break_prohibited_state = false
304
+
305
+ @height_calc = LineFragment::HeightCalculator.new
306
+ @line_height = 0
307
+ end
308
+
309
+ # Peforms the line wrapping with a fixed width.
310
+ def fixed_width_wrapping
311
+ index = 0
312
+
313
+ while (item = @items[index])
314
+ case item.type
315
+ when :box
316
+ unless add_box_item(item.item)
317
+ if @break_prohibited_state
318
+ index = reset_line_to_last_breakpoint_state
319
+ item = @items[index]
320
+ end
321
+ break unless yield(create_line, item.item)
322
+ reset_after_line_break(index)
323
+ redo
324
+ end
325
+ when :glue
326
+ unless add_glue_item(item.item, index)
327
+ break unless yield(create_line, nil)
328
+ reset_after_line_break(index + 1)
329
+ end
330
+ when :penalty
331
+ if item.penalty <= -Penalty::INFINITY
332
+ break unless yield(create_unjustified_line, nil)
333
+ reset_after_line_break(index + 1)
334
+ elsif item.penalty >= Penalty::INFINITY
335
+ @break_prohibited_state = true
336
+ add_box_item(item.item) if item.width > 0
337
+ elsif item.width > 0
338
+ if item_fits_on_line?(item)
339
+ next_index = index + 1
340
+ next_item = @items[next_index]
341
+ next_item = @items[next_index += 1] while next_item && next_item.type == :penalty
342
+ if next_item && !item_fits_on_line?(next_item)
343
+ @line_items.concat(@glue_items).push(item.item)
344
+ @width += item.width
345
+ end
346
+ update_last_breakpoint(index)
347
+ else
348
+ @break_prohibited_state = true
349
+ end
350
+ else
351
+ update_last_breakpoint(index)
352
+ end
353
+ end
354
+
355
+ index += 1
356
+ end
357
+
358
+ line = create_unjustified_line
359
+ last_line_used = true
360
+ last_line_used = yield(line, nil) if item.nil? && !line.items.empty?
361
+
362
+ item.nil? && last_line_used ? [] : @items[@beginning_of_line_index..-1]
363
+ end
364
+
365
+ # Performs the line wrapping with variable widths.
366
+ def variable_width_wrapping
367
+ index = 0
368
+ @available_width = @width_block.call(@line_height)
369
+
370
+ while (item = @items[index])
371
+ case item.type
372
+ when :box
373
+ new_height = @height_calc.simulate_height(item.item)
374
+ if new_height > @line_height
375
+ @line_height = new_height
376
+ @available_width = @width_block.call(@line_height)
377
+ end
378
+ if add_box_item(item.item)
379
+ @height_calc << item.item
380
+ else
381
+ if @break_prohibited_state
382
+ index = reset_line_to_last_breakpoint_state
383
+ item = @items[index]
384
+ end
385
+ break unless yield(create_line, item.item)
386
+ reset_after_line_break(index)
387
+ redo
388
+ end
389
+ when :glue
390
+ unless add_glue_item(item.item, index)
391
+ break unless yield(create_line, nil)
392
+ reset_after_line_break(index + 1)
393
+ end
394
+ when :penalty
395
+ if item.penalty <= -Penalty::INFINITY
396
+ break unless yield(create_unjustified_line, nil)
397
+ reset_after_line_break(index + 1)
398
+ elsif item.penalty >= Penalty::INFINITY
399
+ @break_prohibited_state = true
400
+ add_box_item(item.item) if item.width > 0
401
+ elsif item.width > 0
402
+ if item_fits_on_line?(item)
403
+ next_index = index + 1
404
+ next_item = @items[next_index]
405
+ next_item = @items[n_index += 1] while next_item && next_item.type == :penalty
406
+ new_height = @height_calc.simulate_height(next_item.item)
407
+ if next_item && @width + next_item.width > @width_block.call(new_height)
408
+ @line_items.concat(@glue_items).push(item.item)
409
+ @width += item.width
410
+ # No need to clean up, since in the next iteration a line break occurs
411
+ end
412
+ update_last_breakpoint(index)
413
+ else
414
+ @break_prohibited_state = true
415
+ end
416
+ else
417
+ update_last_breakpoint(index)
418
+ end
419
+ end
420
+
421
+ index += 1
422
+ end
423
+
424
+ line = create_unjustified_line
425
+ last_line_used = true
426
+ last_line_used = yield(line, nil) if item.nil? && !line.items.empty?
427
+
428
+ item.nil? && last_line_used ? [] : @items[@beginning_of_line_index..-1]
429
+ end
430
+
431
+ private
432
+
433
+ # Adds the box item to the line items if it fits on the line.
434
+ #
435
+ # Returns +true+ if the item could be added and +false+ otherwise.
436
+ def add_box_item(item)
437
+ return false unless @width + item.width <= @available_width
438
+ @line_items.concat(@glue_items).push(item)
439
+ @width += item.width
440
+ @glue_items.clear
441
+ true
442
+ end
443
+
444
+ # Adds the glue item to the line items if it fits on the line.
445
+ #
446
+ # Returns +true+ if the item could be added and +false+ otherwise.
447
+ def add_glue_item(item, index)
448
+ return false unless @width + item.width <= @available_width
449
+ unless @line_items.empty? # ignore glue at beginning of line
450
+ @glue_items << item
451
+ @width += item.width
452
+ update_last_breakpoint(index)
453
+ end
454
+ true
455
+ end
456
+
457
+ # Updates the information on the last possible breakpoint of the current line.
458
+ def update_last_breakpoint(index)
459
+ @break_prohibited_state = false
460
+ @last_breakpoint_index = index
461
+ @last_breakpoint_line_items_index = @line_items.size
462
+ end
463
+
464
+ # Resets the line items array to contain only those items that were in it when the last
465
+ # breakpoint was encountered and returns the items' index of the last breakpoint.
466
+ def reset_line_to_last_breakpoint_state
467
+ @line_items.slice!(@last_breakpoint_line_items_index..-1)
468
+ @break_prohibited_state = false
469
+ @last_breakpoint_index
470
+ end
471
+
472
+ # Returns +true+ if the item fits on the line.
473
+ def item_fits_on_line?(item)
474
+ @width + item.width <= @available_width
475
+ end
476
+
477
+ # Creates a LineFragment object from the current line items.
478
+ def create_line
479
+ LineFragment.new(@line_items)
480
+ end
481
+
482
+ # Creates a LineFragment object from the current line items that ignores line justification.
483
+ def create_unjustified_line
484
+ create_line.tap(&:ignore_justification!)
485
+ end
486
+
487
+ # Resets the line state variables to their initial values. The +index+ specifies the items
488
+ # index of the first item on the new line.
489
+ def reset_after_line_break(index)
490
+ @beginning_of_line_index = index
491
+ @line_items.clear
492
+ @width = 0
493
+ @glue_items.clear
494
+ @last_breakpoint_index = index
495
+ @last_breakpoint_line_items_index = 0
496
+ @break_prohibited_state = false
497
+
498
+ @line_height = 0
499
+ @height_calc.reset
500
+ end
501
+
502
+ end
503
+
504
+
505
+ # Creates a new TextBox object for the given text and returns it.
506
+ #
507
+ # See ::new for information on +height+.
508
+ #
509
+ # The style of the text box can be specified using additional options, of which font is
510
+ # mandatory.
511
+ def self.create(text, width:, height: nil, x_offsets: nil, **options)
512
+ frag = TextFragment.create(text, **options)
513
+ new(items: [frag], width: width, height: height, x_offsets: x_offsets, style: frag.style)
514
+ end
515
+
516
+ # The style to be applied.
517
+ #
518
+ # Only the following properties are used: Style#text_indent, Style#align, Style#valign,
519
+ # Style#text_segmentation_algorithm, Style#text_line_wrapping_algorithm
520
+ attr_reader :style
521
+
522
+ # The items (TextFragment and InlineBox objects) of the text box that should be layed out.
523
+ attr_reader :items
524
+
525
+ # Array of LineFragment objects describing the lines of the text box.
526
+ #
527
+ # The array is only valid after #fit was called.
528
+ attr_reader :lines
529
+
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.
532
+ attr_reader :actual_height
533
+
534
+ # Creates a new TextBox object with the given width containing the given items.
535
+ #
536
+ # The width can either be a simple number specifying a fixed width, or an object that responds
537
+ # to #call(height, line_height) where +height+ is the bottom of last line and +line_height+ is
538
+ # the height of the line to be layed out. The return value should be the available width given
539
+ # these height restrictions.
540
+ #
541
+ # 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).
544
+ #
545
+ # The height is optional and if not specified means that the text box has infinite height.
546
+ def initialize(items: [], width:, height: nil, x_offsets: nil, style: Style.new)
547
+ @style = style
548
+ @lines = []
549
+ self.items = items
550
+ @width = width
551
+ @height = height || Float::INFINITY
552
+ @x_offsets = x_offsets && (x_offsets.respond_to?(:call) ? x_offsets : proc { x_offsets })
553
+ end
554
+
555
+ # Sets the items to be arranged by the text box, clearing the internal state.
556
+ #
557
+ # If the items array contains items before text segmentation, the text segmentation algorithm
558
+ # is automatically applied.
559
+ def items=(items)
560
+ unless items.empty? || items[0].respond_to?(:type)
561
+ items = style.text_segmentation_algorithm.call(items)
562
+ end
563
+ @items = items.freeze
564
+ @lines.clear
565
+ @actual_height = nil
566
+ end
567
+
568
+ # :call-seq:
569
+ # text_box.fit -> [remaining_items, actual_height]
570
+ #
571
+ # Fits the items into the text box and returns the remaining items as well as the actual
572
+ # height needed.
573
+ #
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.
576
+ #
577
+ # 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.
579
+ def fit
580
+ @lines.clear
581
+ @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
+
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
624
+ @actual_height = new_height
625
+ true
626
+ else
627
+ nil
628
+ end
629
+ else
630
+ nil
631
+ end
632
+ end
633
+
634
+ [rest, @actual_height]
635
+ end
636
+
637
+ # Draws the text box onto the canvas with the top-left corner being at [x, y].
638
+ #
639
+ # Depending on the value of +fit+ the text may also be fitted:
640
+ #
641
+ # * If +true+, then #fit is always called.
642
+ # * If +:if_needed+, then #fit is only called if it has been called before.
643
+ # * If +false+, then #fit is never called.
644
+ def draw(canvas, x, y, fit: :if_needed)
645
+ self.fit if fit == true || (!@actual_height && fit == :if_needed)
646
+ return if @lines.empty?
647
+
648
+ canvas.save_graphics_state do
649
+ y -= initial_baseline_offset + @lines.first.y_offset
650
+ @lines.each_with_index do |line, index|
651
+ line_x = x + line.x_offset
652
+ line.each {|item, item_x, item_y| item.draw(canvas, line_x + item_x, y + item_y) }
653
+ y -= @lines[index + 1].y_offset if @lines[index + 1]
654
+ end
655
+ end
656
+ end
657
+
658
+ private
659
+
660
+ # Returns the initial baseline offset from the top of the text box, based on the valign style
661
+ # option.
662
+ def initial_baseline_offset
663
+ case style.valign
664
+ when :top
665
+ @lines.first.y_max
666
+ when :center
667
+ if @height == Float::INFINITY
668
+ raise HexaPDF::Error, "Can't vertically align a text box with unlimited height"
669
+ end
670
+ (@height - @actual_height) / 2.0 + @lines.first.y_max
671
+ when :bottom
672
+ if @height == Float::INFINITY
673
+ raise HexaPDF::Error, "Can't vertically align a text box with unlimited height"
674
+ end
675
+ (@height - @actual_height) + @lines.first.y_max
676
+ end
677
+ end
678
+
679
+ # Returns the horizontal offset from the left side, based on the align style option.
680
+ def horizontal_alignment_offset(line, available_width)
681
+ case style.align
682
+ when :left then 0
683
+ when :center then (available_width - line.width) / 2
684
+ when :right then available_width - line.width
685
+ when :justify then (justify_line(line, available_width); 0)
686
+ end
687
+ end
688
+
689
+ # Justifies the given line.
690
+ def justify_line(line, width)
691
+ return if line.ignore_justification? || (width - line.width).abs < 0.001
692
+
693
+ indexes = []
694
+ sum = 0.0
695
+ line.items.each_with_index do |item, item_index|
696
+ next if item.kind_of?(InlineBox)
697
+ item.items.each_with_index do |glyph, glyph_index|
698
+ if !glyph.kind_of?(Numeric) && glyph.str == ' '.freeze
699
+ sum += glyph.width * item.style.scaled_font_size
700
+ indexes << item_index << glyph_index
701
+ end
702
+ end
703
+ end
704
+
705
+ if sum > 0
706
+ adjustment = (width - line.width) / sum
707
+ i = indexes.length - 2
708
+ while i >= 0
709
+ frag = line.items[indexes[i]]
710
+ value = -frag.items[indexes[i + 1]].width * adjustment
711
+ if frag.items.frozen?
712
+ value = HexaPDF::Layout::TextFragment.new(items: [value], style: frag.style)
713
+ line.items.insert(indexes[i], value)
714
+ else
715
+ frag.items.insert(indexes[i + 1], value)
716
+ frag.clear_cache
717
+ end
718
+ i -= 2
719
+ end
720
+ line.clear_cache
721
+ end
722
+ end
723
+
724
+ end
725
+
726
+ end
727
+ end