hexapdf 0.4.0 → 0.5.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +46 -0
- data/CONTRIBUTERS +1 -1
- data/README.md +5 -5
- data/VERSION +1 -1
- data/examples/emoji-smile.png +0 -0
- data/examples/emoji-wink.png +0 -0
- data/examples/graphics.rb +9 -8
- data/examples/standard_pdf_fonts.rb +2 -1
- data/examples/text_box_alignment.rb +47 -0
- data/examples/text_box_inline_boxes.rb +56 -0
- data/examples/text_box_line_wrapping.rb +57 -0
- data/examples/text_box_shapes.rb +166 -0
- data/examples/text_box_styling.rb +72 -0
- data/examples/truetype.rb +3 -4
- data/lib/hexapdf/cli/optimize.rb +2 -2
- data/lib/hexapdf/configuration.rb +8 -6
- data/lib/hexapdf/content/canvas.rb +8 -5
- data/lib/hexapdf/content/parser.rb +3 -2
- data/lib/hexapdf/content/processor.rb +14 -3
- data/lib/hexapdf/document.rb +1 -0
- data/lib/hexapdf/document/fonts.rb +2 -1
- data/lib/hexapdf/document/pages.rb +23 -0
- data/lib/hexapdf/font/invalid_glyph.rb +78 -0
- data/lib/hexapdf/font/true_type/font.rb +14 -3
- data/lib/hexapdf/font/true_type/table.rb +1 -0
- data/lib/hexapdf/font/true_type/table/cmap.rb +1 -1
- data/lib/hexapdf/font/true_type/table/cmap_subtable.rb +1 -0
- data/lib/hexapdf/font/true_type/table/glyf.rb +4 -0
- data/lib/hexapdf/font/true_type/table/kern.rb +170 -0
- data/lib/hexapdf/font/true_type/table/post.rb +5 -1
- data/lib/hexapdf/font/true_type_wrapper.rb +71 -24
- data/lib/hexapdf/font/type1/afm_parser.rb +3 -2
- data/lib/hexapdf/font/type1/character_metrics.rb +0 -9
- data/lib/hexapdf/font/type1/font.rb +11 -0
- data/lib/hexapdf/font/type1/font_metrics.rb +6 -1
- data/lib/hexapdf/font/type1_wrapper.rb +51 -7
- data/lib/hexapdf/font_loader/standard14.rb +1 -1
- data/lib/hexapdf/layout.rb +51 -0
- data/lib/hexapdf/layout/inline_box.rb +95 -0
- data/lib/hexapdf/layout/line_fragment.rb +333 -0
- data/lib/hexapdf/layout/numeric_refinements.rb +56 -0
- data/lib/hexapdf/layout/style.rb +365 -0
- data/lib/hexapdf/layout/text_box.rb +727 -0
- data/lib/hexapdf/layout/text_fragment.rb +206 -0
- data/lib/hexapdf/layout/text_shaper.rb +155 -0
- data/lib/hexapdf/task.rb +0 -1
- data/lib/hexapdf/task/dereference.rb +1 -1
- data/lib/hexapdf/tokenizer.rb +3 -2
- data/lib/hexapdf/type/font_descriptor.rb +2 -1
- data/lib/hexapdf/type/font_type0.rb +3 -1
- data/lib/hexapdf/type/form.rb +12 -4
- data/lib/hexapdf/version.rb +1 -1
- data/test/hexapdf/common_tokenizer_tests.rb +7 -0
- data/test/hexapdf/content/common.rb +8 -0
- data/test/hexapdf/content/test_canvas.rb +10 -22
- data/test/hexapdf/content/test_processor.rb +4 -1
- data/test/hexapdf/document/test_pages.rb +16 -0
- data/test/hexapdf/font/test_invalid_glyph.rb +34 -0
- data/test/hexapdf/font/test_true_type_wrapper.rb +25 -11
- data/test/hexapdf/font/test_type1_wrapper.rb +26 -10
- data/test/hexapdf/font/true_type/table/common.rb +27 -0
- data/test/hexapdf/font/true_type/table/test_cmap.rb +14 -20
- data/test/hexapdf/font/true_type/table/test_cmap_subtable.rb +7 -0
- data/test/hexapdf/font/true_type/table/test_glyf.rb +8 -6
- data/test/hexapdf/font/true_type/table/test_head.rb +9 -13
- data/test/hexapdf/font/true_type/table/test_hhea.rb +16 -23
- data/test/hexapdf/font/true_type/table/test_hmtx.rb +4 -7
- data/test/hexapdf/font/true_type/table/test_kern.rb +61 -0
- data/test/hexapdf/font/true_type/table/test_loca.rb +7 -13
- data/test/hexapdf/font/true_type/table/test_maxp.rb +4 -9
- data/test/hexapdf/font/true_type/table/test_name.rb +14 -17
- data/test/hexapdf/font/true_type/table/test_os2.rb +3 -5
- data/test/hexapdf/font/true_type/table/test_post.rb +21 -19
- data/test/hexapdf/font/true_type/test_font.rb +4 -0
- data/test/hexapdf/font/type1/common.rb +6 -0
- data/test/hexapdf/font/type1/test_afm_parser.rb +9 -0
- data/test/hexapdf/font/type1/test_font.rb +6 -0
- data/test/hexapdf/layout/test_inline_box.rb +40 -0
- data/test/hexapdf/layout/test_line_fragment.rb +206 -0
- data/test/hexapdf/layout/test_style.rb +143 -0
- data/test/hexapdf/layout/test_text_box.rb +640 -0
- data/test/hexapdf/layout/test_text_fragment.rb +208 -0
- data/test/hexapdf/layout/test_text_shaper.rb +64 -0
- data/test/hexapdf/task/test_dereference.rb +1 -0
- data/test/hexapdf/test_writer.rb +2 -2
- data/test/hexapdf/type/test_font_descriptor.rb +4 -2
- data/test/hexapdf/type/test_font_type0.rb +7 -0
- data/test/hexapdf/type/test_form.rb +12 -0
- 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
|