hexapdf 0.4.0 → 0.5.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 (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