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.
- 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
|