hexapdf 0.24.2 → 0.25.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,122 @@
1
+ # -*- encoding: utf-8; frozen_string_literal: true -*-
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-2022 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
+ # If the GNU Affero General Public License doesn't fit your need,
34
+ # commercial licenses are available at <https://gettalong.at/hexapdf/>.
35
+ #++
36
+
37
+ require 'hexapdf/dictionary'
38
+
39
+ module HexaPDF
40
+ module Type
41
+
42
+ # Represents the root of the PDF's document outline containing a hierarchy of outline items
43
+ # (sometimes called bookmarks) in a linked list.
44
+ #
45
+ # The document outline usually contains items for the sections of the document, so that clicking
46
+ # on an item opens the page where the section starts (the section header is). Most PDF viewers
47
+ # are able to display the outline to aid in navigation, though not all apply the optional
48
+ # attributes like the text color.
49
+ #
50
+ # The outline dictionary is linked via the /Outlines entry from the Type::Catalog and can
51
+ # directly be accessed via HexaPDF::Document#outline.
52
+ #
53
+ # Here is an example for creating an outline:
54
+ #
55
+ # doc = HexaPDF::Document.new
56
+ # 5.times { doc.pages.add }
57
+ # doc.outline.add_item("Section 1", destination: 0) do |sec1|
58
+ # sec1.add_item("Page 2", destination: doc.pages[1])
59
+ # sec1.add_item("Page 3", destination: 2)
60
+ # sec1.add_item("Section 1.1", text_color: "red", flags: [:bold]) do |sec11|
61
+ # sec11.add_item("Page 4", destination: 3)
62
+ # end
63
+ # end
64
+ #
65
+ # See: PDF1.7 s12.3.3
66
+ class Outline < Dictionary
67
+
68
+ define_type :Outlines
69
+
70
+ define_field :Type, type: Symbol, default: type
71
+ define_field :First, type: :XXOutlineItem, indirect: true
72
+ define_field :Last, type: :XXOutlineItem, indirect: true
73
+ define_field :Count, type: Integer
74
+
75
+ # Adds a new top-level outline item.
76
+ #
77
+ # See OutlineItem#add_item for details on the available options since this method just passes
78
+ # all arguments through to it.
79
+ def add_item(title, **options, &block)
80
+ self[:Count] ||= 0
81
+ self_as_item.add_item(title, **options, &block)
82
+ end
83
+
84
+ # :call-seq:
85
+ # outline.each_item {|item| block } -> item
86
+ # outline.each_item -> Enumerator
87
+ #
88
+ # Iterates over all items of the outline.
89
+ #
90
+ # The items are yielded in-order, yielding first the item itself and then its descendants.
91
+ def each_item(&block)
92
+ self_as_item.each_item(&block)
93
+ end
94
+
95
+ private
96
+
97
+ # Represents the outline dictionary as an outline item dictionary to make use of some of its
98
+ # methods.
99
+ def self_as_item
100
+ @self_as_item ||= document.wrap(self, type: :XXOutlineItem)
101
+ end
102
+
103
+ # Makes sure the required values are set.
104
+ def perform_validation
105
+ super
106
+ first = self[:First]
107
+ last = self[:Last]
108
+ if (first && !last) || (!first && last)
109
+ yield('Outline dictionary is missing an endpoint reference', true)
110
+ node, dir = first ? [first, :Next] : [last, :Prev]
111
+ node = node[dir] while node.key?(dir)
112
+ self[dir == :Next ? :Last : :First] = node
113
+ elsif !first && !last && self[:Count]
114
+ yield('Outline dictionary key /Count set but no items exist', true)
115
+ delete(:Count)
116
+ end
117
+ end
118
+
119
+ end
120
+
121
+ end
122
+ end
@@ -0,0 +1,373 @@
1
+ # -*- encoding: utf-8; frozen_string_literal: true -*-
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-2022 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
+ # If the GNU Affero General Public License doesn't fit your need,
34
+ # commercial licenses are available at <https://gettalong.at/hexapdf/>.
35
+ #++
36
+
37
+ require 'hexapdf/dictionary'
38
+ require 'hexapdf/utils/bit_field'
39
+ require 'hexapdf/content/color_space'
40
+
41
+ module HexaPDF
42
+ module Type
43
+
44
+ # Represents an outline item dictionary.
45
+ #
46
+ # An item has a title and some optional attributes: the action that is activated when clicking
47
+ # (either a simple destination or an explicit action object), the text color, and flags (whether
48
+ # the text should appear bold and/or italic).
49
+ #
50
+ # Additionally, items may have child items which makes it possible to create a hierarchy of
51
+ # items.
52
+ #
53
+ # If no destination/action is set, the item just acts as kind of a header. It usually only makes
54
+ # sense to do this when the item has children.
55
+ #
56
+ # Outline item dictionaries are connected together in the form of a linked list using the /Next
57
+ # and /Prev keys. Each item may have descendant items. If so, the /First and /Last keys point to
58
+ # respectively the first and last descendant items.
59
+ #
60
+ # Since many dictionary keys need to be kept up-to-date when manipulating the outline item tree,
61
+ # it is not recommended to manually do this but to rely on the provided convenience methods.
62
+ #
63
+ # See: PDF1.7 s12.3.3
64
+ class OutlineItem < Dictionary
65
+
66
+ extend Utils::BitField
67
+
68
+ define_type :XXOutlineItem
69
+
70
+ define_field :Title, type: String, required: true
71
+ define_field :Parent, type: Dictionary, required: true, indirect: true
72
+ define_field :Prev, type: :XXOutlineItem, indirect: true
73
+ define_field :Next, type: :XXOutlineItem, indirect: true
74
+ define_field :First, type: :XXOutlineItem, indirect: true
75
+ define_field :Last, type: :XXOutlineItem, indirect: true
76
+ define_field :Count, type: Integer
77
+ define_field :Dest, type: [Symbol, PDFByteString, PDFArray]
78
+ define_field :A, type: :Action, version: '1.1'
79
+ define_field :SE, type: Dictionary, indirect: true
80
+ define_field :C, type: PDFArray, default: [0, 0, 0], version: '1.4'
81
+ define_field :F, type: Integer, default: 0, version: '1.4'
82
+
83
+ ##
84
+ # :method: flags
85
+ #
86
+ # Returns an array of flag names representing the set bit flags for /F.
87
+ #
88
+ # The available flags are:
89
+ #
90
+ # :italic or 0:: The text is displayed in italic.
91
+ # :bold or 1:: The text is displayed in bold.
92
+ #
93
+
94
+ ##
95
+ # :method: flagged?
96
+ # :call-seq:
97
+ # flagged?(flag)
98
+ #
99
+ # Returns +true+ if the given flag is set on /F. The argument can either be the flag name or
100
+ # the bit index.
101
+ #
102
+ # See #flags for the list of available flags.
103
+ #
104
+
105
+ ##
106
+ # :method: flag
107
+ # :call-seq:
108
+ # flag(*flags, clear_existing: false)
109
+ #
110
+ # Sets the given flags on /F, given as flag names or bit indices. If +clear_existing+ is
111
+ # +true+, all prior flags will be cleared.
112
+ #
113
+ # See #flags for the list of available flags.
114
+ #
115
+
116
+ ##
117
+ # :method: unflag
118
+ # :call-seq:
119
+ # flag(*flags)
120
+ #
121
+ # Clears the given flags from /F, given as flag names or bit indices.
122
+ #
123
+ # See #flags for the list of available flags.
124
+ #
125
+ bit_field(:flags, {italic: 0, bold: 1},
126
+ lister: "flags", getter: "flagged?", setter: "flag", unsetter: "unflag",
127
+ value_getter: "self[:F]", value_setter: "self[:F]")
128
+
129
+ # :call-seq:
130
+ # item.title -> title
131
+ # item.title(value) -> title
132
+ #
133
+ # Returns the item's title if no argument is given. Otherwise sets the title to the given
134
+ # value.
135
+ def title(value = nil)
136
+ if value
137
+ self[:Title] = value
138
+ else
139
+ self[:Title]
140
+ end
141
+ end
142
+
143
+ # :call-seq:
144
+ # item.text_color -> color
145
+ # item.text_color(color) -> color
146
+ #
147
+ # Returns the item's text color as HexaPDF::Content::ColorSpace::DeviceRGB::Color object if no
148
+ # argument is given. Otherwise sets the text color, see
149
+ # HexaPDF::Content::ColorSpace.device_color_from_specification for possible +color+ values.
150
+ #
151
+ # Note: The color *has* to be an RGB color.
152
+ def text_color(color = nil)
153
+ if color
154
+ color = HexaPDF::Content::ColorSpace.device_color_from_specification(color)
155
+ unless color.color_space.family == :DeviceRGB
156
+ raise ArgumentError, "The given argument is not a valid RGB color"
157
+ end
158
+ self[:C] = color.components
159
+ else
160
+ Content::ColorSpace.prenormalized_device_color(self[:C])
161
+ end
162
+ end
163
+
164
+ # :call-seq:
165
+ # item.destination -> destination
166
+ # item.destination(value) -> destination
167
+ #
168
+ # Returns the item's destination if no argument is given. Otherwise sets the destination to
169
+ # the given value (see HexaPDF::Document::Destinations#use_or_create for the posssible
170
+ # values).
171
+ #
172
+ # If an action is set, the destination has to be unset; and vice versa. So when setting a
173
+ # destination value, the action is automatically deleted.
174
+ def destination(value = nil)
175
+ if value
176
+ delete(:A)
177
+ self[:Dest] = document.destinations.use_or_create(value)
178
+ else
179
+ self[:Dest]
180
+ end
181
+ end
182
+
183
+ # :call-seq:
184
+ # item.action -> action
185
+ # item.action(value) -> action
186
+ #
187
+ # Returns the item's action if no argument is given. Otherwise sets the action to
188
+ # the given value (needs to be a valid HexaPDF::Type::Action dictionary).
189
+ #
190
+ # If an action is set, the destination has to be unset; and vice versa. So when setting an
191
+ # action value, the destination is automatically deleted.
192
+ def action(value = nil)
193
+ if value
194
+ delete(:Dest)
195
+ self[:A] = value
196
+ else
197
+ self[:A]
198
+ end
199
+ end
200
+
201
+ # Adds, as child to this item, a new outline item with the given title that performs the
202
+ # provided action on clicking. Returns the newly added item.
203
+ #
204
+ # If neither :destination nor :action is specified, the outline item has no associated action.
205
+ # This is only meaningful if the new item will have children as it then acts just as a
206
+ # container.
207
+ #
208
+ # If a block is specified, the newly created item is yielded.
209
+ #
210
+ # destination::
211
+ #
212
+ # Specifies the destination that should be activated when clicking on the outline item.
213
+ # See HexaPDF::Document::Destinations#use_or_create for details. The argument :action
214
+ # takes precedence if it is also specified,
215
+ #
216
+ # action::
217
+ #
218
+ # Specifies the action that should be taken when clicking on the outline item. See
219
+ # HexaPDF::Type::Action for details. If the argument :destination is also specified, the
220
+ # :action argument takes precedence.
221
+ #
222
+ # position::
223
+ #
224
+ # The position where the new child item should be inserted. Can either be:
225
+ #
226
+ # +:first+:: Insert as first item
227
+ # +:last+:: Insert as last item (default)
228
+ # Integer:: When non-negative inserts before, otherwise after, the item at the given
229
+ # zero-based index.
230
+ #
231
+ # open::
232
+ #
233
+ # Specifies whether the outline item should be open (i.e. one or more children are shown)
234
+ # or closed. Default: +true+.
235
+ #
236
+ # text_color::
237
+ #
238
+ # The text color of the outline item text which needs to be a valid RGB color (see
239
+ # #text_color for details). If not set, the text appears in black.
240
+ #
241
+ # flags::
242
+ #
243
+ # An array of font variants (possible values are :bold and :italic) to set for the outline
244
+ # item text, see #flags for detail. Default is to use no variant.
245
+ #
246
+ # Examples:
247
+ #
248
+ # doc.destinations.add("Title") do |item| # no action, just container
249
+ # item.add("Second subitem", destination: doc.pages[1]) # links to page 2
250
+ # item.add("First subitem", position: :first, destination: doc.pages[0])
251
+ # end
252
+ def add_item(title, destination: nil, action: nil, position: :last, open: true,
253
+ text_color: nil, flags: nil) # :yield: item
254
+ item = document.add({Parent: self}, type: :XXOutlineItem)
255
+ item.title(title)
256
+ if action
257
+ item.action(action)
258
+ else
259
+ item.destination(destination)
260
+ end
261
+ item.text_color(text_color) if text_color
262
+ item.flag(*flags) if flags
263
+ item[:Count] = 0 if open # Count=0 means open if items are later added
264
+
265
+ unless position == :last || position == :first || position.kind_of?(Integer)
266
+ raise ArgumentError, "position must be :first, :last, or an integer"
267
+ end
268
+ if self[:First]
269
+ case position
270
+ when :last, -1
271
+ item[:Prev] = self[:Last]
272
+ self[:Last][:Next] = item
273
+ self[:Last] = item
274
+ when :first, 0
275
+ item[:Next] = self[:First]
276
+ self[:First][:Prev] = item
277
+ self[:First] = item
278
+ when Integer
279
+ temp, direction = if position > 0
280
+ [self[:First], :Next]
281
+ else
282
+ position = -position - 2
283
+ [self[:Last], :Prev]
284
+ end
285
+ position.times { temp &&= temp[direction] }
286
+ raise ArgumentError, "position out of bounds" if temp.nil?
287
+ item[:Prev] = temp[:Prev]
288
+ item[:Next] = temp
289
+ temp[:Prev] = item
290
+ item[:Prev][:Next] = item
291
+ end
292
+ else
293
+ self[:First] = self[:Last] = item
294
+ end
295
+
296
+ # Re-calculate /Count entries
297
+ temp = self
298
+ while temp
299
+ if !temp.key?(:Count) || temp[:Count] < 0
300
+ temp[:Count] = (temp[:Count] || 0) - 1
301
+ break
302
+ else
303
+ temp[:Count] += 1
304
+ end
305
+ temp = temp[:Parent]
306
+ end
307
+
308
+ yield(item) if block_given?
309
+
310
+ item
311
+ end
312
+
313
+ # :call-seq:
314
+ # item.each_item {|descendant_item| block } -> item
315
+ # item.each_item -> Enumerator
316
+ #
317
+ # Iterates over all descendant items of this one.
318
+ #
319
+ # The items are yielded in-order, yielding first the item itself and then its descendants.
320
+ def each_item(&block)
321
+ return to_enum(__method__) unless block_given?
322
+
323
+ item = self[:First]
324
+ while item
325
+ yield(item)
326
+ item.each_item(&block)
327
+ item = item[:Next]
328
+ end
329
+
330
+ self
331
+ end
332
+
333
+ private
334
+
335
+ def perform_validation # :nodoc:
336
+ super
337
+ first = self[:First]
338
+ last = self[:Last]
339
+ if (first && !last) || (!first && last)
340
+ yield('Outline item dictionary is missing an endpoint reference', true)
341
+ node, dir = first ? [first, :Next] : [last, :Prev]
342
+ node = node[dir] while node.key?(dir)
343
+ self[dir == :Next ? :Last : :First] = node
344
+ elsif !first && !last && self[:Count] != 0
345
+ yield('Outline item dictionary key /Count set but no descendants exist', true)
346
+ delete(:Count)
347
+ end
348
+
349
+ prev_item = self[:Prev]
350
+ if prev_item && (prev_item_next = prev_item[:Next]) != self
351
+ if prev_item_next
352
+ yield('Outline item /Prev points to item whose /Next points somewhere else', false)
353
+ else
354
+ yield('Outline item /Prev points to item without /Next', true)
355
+ prev_item[:Next] = self
356
+ end
357
+ end
358
+
359
+ next_item = self[:Next]
360
+ if next_item && (next_item_prev = next_item[:Prev]) != self
361
+ if next_item_prev
362
+ yield('Outline item /Next points to item whose /Prev points somewhere else', false)
363
+ else
364
+ yield('Outline item /Next points to item without /Prev', true)
365
+ next_item[:Prev] = self
366
+ end
367
+ end
368
+ end
369
+
370
+ end
371
+
372
+ end
373
+ end
data/lib/hexapdf/type.rb CHANGED
@@ -73,6 +73,8 @@ module HexaPDF
73
73
  autoload(:IconFit, 'hexapdf/type/icon_fit')
74
74
  autoload(:AcroForm, 'hexapdf/type/acro_form')
75
75
  autoload(:Signature, 'hexapdf/type/signature')
76
+ autoload(:Outline, 'hexapdf/type/outline')
77
+ autoload(:OutlineItem, 'hexapdf/type/outline_item')
76
78
 
77
79
  end
78
80
 
@@ -37,6 +37,6 @@
37
37
  module HexaPDF
38
38
 
39
39
  # The version of HexaPDF.
40
- VERSION = '0.24.2'
40
+ VERSION = '0.25.0'
41
41
 
42
42
  end
@@ -8,6 +8,27 @@ describe HexaPDF::Document::Destinations::Destination do
8
8
  HexaPDF::Document::Destinations::Destination.new(dest)
9
9
  end
10
10
 
11
+ describe "self.valid?" do
12
+ before do
13
+ @klass = HexaPDF::Document::Destinations::Destination
14
+ end
15
+
16
+ it "validates the type" do
17
+ assert(@klass.valid?([5, :Fit]))
18
+ refute(@klass.valid?([5, :FitNone]))
19
+ end
20
+
21
+ it "validates the page entry" do
22
+ assert(@klass.valid?([5, :Fit]))
23
+ refute(@klass.valid?([HexaPDF::Dictionary.new({Type: :Page}), :FitNone]))
24
+ end
25
+
26
+ it "validates the arguments" do
27
+ assert(@klass.valid?([5, :FitH, 5]))
28
+ refute(@klass.valid?([5, :FitH, :other]))
29
+ end
30
+ end
31
+
11
32
  it "can be asked whether the referenced page is in a remote document" do
12
33
  assert(destination([5, :Fit]).remote?)
13
34
  refute(destination([HexaPDF::Dictionary.new({}), :Fit]).remote?)
@@ -17,6 +38,10 @@ describe HexaPDF::Document::Destinations::Destination do
17
38
  assert_equal(:page, destination([:page, :Fit]).page)
18
39
  end
19
40
 
41
+ it "can validate a destination" do
42
+ assert(destination([5, :Fit]).valid?)
43
+ end
44
+
20
45
  describe "type :xyz" do
21
46
  before do
22
47
  @dest = destination([:page, :XYZ, :left, :top, :zoom])
@@ -201,6 +226,68 @@ describe HexaPDF::Document::Destinations do
201
226
  @page = @doc.pages.add
202
227
  end
203
228
 
229
+ describe "use_or_create" do
230
+ it "uses the given destination name if it exists" do
231
+ @doc.destinations.create(:fit_page, @page, name: "test")
232
+ assert_equal("test", @doc.destinations.use_or_create("test"))
233
+ end
234
+
235
+ it "fails if the given destination name doesn't exist" do
236
+ assert_raises(HexaPDF::Error) { @doc.destinations.use_or_create("test") }
237
+ end
238
+
239
+ it "uses the given destination array" do
240
+ dest = [@page, :Fit]
241
+ assert_same(dest, @doc.destinations.use_or_create(dest))
242
+ end
243
+
244
+ it "fails if the given destination array is not valid" do
245
+ assert_raises(HexaPDF::Error) { @doc.destinations.use_or_create([@page, :FitNone]) }
246
+ end
247
+
248
+ it "creates a fit page destination for a given page" do
249
+ assert_equal([@page, :Fit], @doc.destinations.use_or_create(@page))
250
+ end
251
+
252
+ it "fails if the given dictionary object is not a page object" do
253
+ assert_raises(HexaPDF::Error) { @doc.destinations.use_or_create(@doc.catalog) }
254
+ end
255
+
256
+ it "creates a fit page destination for a given page index" do
257
+ assert_equal([@page, :Fit], @doc.destinations.use_or_create(0))
258
+ end
259
+
260
+ it "fails if the given index is no a valid page index" do
261
+ assert_raises(ArgumentError) { @doc.destinations.use_or_create(-1) }
262
+ assert_raises(ArgumentError) { @doc.destinations.use_or_create(1) }
263
+ end
264
+
265
+ it "creates the destination using the provided details" do
266
+ dest = @doc.destinations.use_or_create(type: :fit_page_horizontal, page: @page, top: 10)
267
+ assert_equal([@page, :FitH, 10], dest)
268
+ end
269
+
270
+ it "fails creating a destination if the :type key is missing" do
271
+ assert_raises(ArgumentError) { @doc.destinations.use_or_create(page: @page) }
272
+ end
273
+
274
+ it "fails creating a destination if the :page key is missing" do
275
+ assert_raises(ArgumentError) { @doc.destinations.use_or_create(type: :fit_page) }
276
+ end
277
+
278
+ it "fails if the provided argument has an invalid type" do
279
+ assert_raises(ArgumentError) { @doc.destinations.use_or_create(:value) }
280
+ end
281
+ end
282
+
283
+ describe "create" do
284
+ it "creates the destination based on the given type" do
285
+ @doc.destinations.stub(:create_fit_page, [5, :Fit]) do
286
+ assert_equal([5, :Fit], @doc.destinations.create(:fit_page, 5))
287
+ end
288
+ end
289
+ end
290
+
204
291
  describe "create_xyz" do
205
292
  it "creates the destination" do
206
293
  dest = @doc.destinations.create_xyz(@page, left: 1, top: 2, zoom: 3)
@@ -351,8 +351,10 @@ describe HexaPDF::Encryption::SecurityHandler do
351
351
 
352
352
  it "doesn't encrypt strings in a document's Encrypt dictionary" do
353
353
  @document.trailer[:Encrypt] = @handler.dict
354
- @document.trailer[:Encrypt][:Mine] = 'string'
355
- assert_equal('string', @handler.encrypt_string('string', @document.trailer[:Encrypt]))
354
+ str = 'string'
355
+ result = @handler.encrypt_string(str, @document.trailer[:Encrypt])
356
+ assert_equal('string', result)
357
+ refute_same(str, result)
356
358
  end
357
359
 
358
360
  it "doesn't encrypt XRef streams" do
@@ -671,6 +671,7 @@ describe HexaPDF::Layout::Style do
671
671
  @style = HexaPDF::Layout::Style.new
672
672
  assert_raises(HexaPDF::Error) { @style.font }
673
673
  assert_equal(10, @style.font_size)
674
+ assert_nil(@style.line_height)
674
675
  assert_equal(0, @style.character_spacing)
675
676
  assert_equal(0, @style.word_spacing)
676
677
  assert_equal(100, @style.horizontal_scaling)
@@ -461,6 +461,12 @@ describe HexaPDF::Layout::TextLayouter do
461
461
  assert_equal(20 + 20 + 9 + 20 + 9, result.height)
462
462
  end
463
463
 
464
+ it "handles penalties with non-zero width at end of line if they don't fit" do
465
+ items = boxes([17.21, 9]) + [penalty(50, boxes([3.33]).first)] + boxes([30, 9])
466
+ result = @layouter.fit(items, 20.5, 100)
467
+ assert_equal(3, result.remaining_items.size)
468
+ end
469
+
464
470
  it "handles line breaks in combination with multiple parts per line" do
465
471
  items = boxes([20, 20]) + [penalty(-5000)] +
466
472
  boxes([20, 20], [20, 20], [20, 20]) + [penalty(-5000)] +
@@ -562,6 +562,10 @@ describe HexaPDF::Document do
562
562
  end
563
563
  end
564
564
 
565
+ it "returns the document outline" do
566
+ assert_kind_of(HexaPDF::Type::Outline, @doc.outline)
567
+ end
568
+
565
569
  it "can be inspected and the output is not too large" do
566
570
  assert_match(/HexaPDF::Document:\d+/, @doc.inspect)
567
571
  end