hexapdf 0.24.2 → 0.25.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.
@@ -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