hexapdf 0.26.2 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +115 -1
  3. data/README.md +1 -1
  4. data/examples/013-text_layouter_shapes.rb +8 -8
  5. data/examples/016-frame_automatic_box_placement.rb +3 -3
  6. data/examples/017-frame_text_flow.rb +3 -3
  7. data/examples/019-acro_form.rb +14 -3
  8. data/examples/020-column_box.rb +3 -3
  9. data/examples/023-images.rb +30 -0
  10. data/lib/hexapdf/cli/info.rb +5 -1
  11. data/lib/hexapdf/cli/inspect.rb +2 -2
  12. data/lib/hexapdf/cli/split.rb +8 -8
  13. data/lib/hexapdf/cli/watermark.rb +2 -2
  14. data/lib/hexapdf/configuration.rb +3 -2
  15. data/lib/hexapdf/content/canvas.rb +8 -3
  16. data/lib/hexapdf/dictionary.rb +4 -17
  17. data/lib/hexapdf/document/destinations.rb +42 -5
  18. data/lib/hexapdf/document/signatures.rb +265 -48
  19. data/lib/hexapdf/document.rb +6 -10
  20. data/lib/hexapdf/filter/ascii85_decode.rb +1 -1
  21. data/lib/hexapdf/importer.rb +35 -27
  22. data/lib/hexapdf/layout/list_box.rb +1 -5
  23. data/lib/hexapdf/object.rb +5 -0
  24. data/lib/hexapdf/parser.rb +14 -0
  25. data/lib/hexapdf/revision.rb +15 -12
  26. data/lib/hexapdf/revisions.rb +7 -1
  27. data/lib/hexapdf/tokenizer.rb +15 -9
  28. data/lib/hexapdf/type/acro_form/appearance_generator.rb +174 -128
  29. data/lib/hexapdf/type/acro_form/button_field.rb +5 -3
  30. data/lib/hexapdf/type/acro_form/choice_field.rb +2 -0
  31. data/lib/hexapdf/type/acro_form/field.rb +11 -5
  32. data/lib/hexapdf/type/acro_form/form.rb +61 -8
  33. data/lib/hexapdf/type/acro_form/signature_field.rb +2 -0
  34. data/lib/hexapdf/type/acro_form/text_field.rb +12 -2
  35. data/lib/hexapdf/type/annotations/widget.rb +3 -0
  36. data/lib/hexapdf/type/catalog.rb +1 -1
  37. data/lib/hexapdf/type/font_true_type.rb +14 -0
  38. data/lib/hexapdf/type/object_stream.rb +2 -2
  39. data/lib/hexapdf/type/outline.rb +19 -1
  40. data/lib/hexapdf/type/outline_item.rb +72 -14
  41. data/lib/hexapdf/type/page.rb +95 -64
  42. data/lib/hexapdf/type/resources.rb +13 -17
  43. data/lib/hexapdf/type/signature/adbe_pkcs7_detached.rb +16 -2
  44. data/lib/hexapdf/type/signature.rb +10 -0
  45. data/lib/hexapdf/version.rb +1 -1
  46. data/lib/hexapdf/writer.rb +5 -3
  47. data/test/hexapdf/content/test_canvas.rb +5 -0
  48. data/test/hexapdf/document/test_destinations.rb +41 -0
  49. data/test/hexapdf/document/test_pages.rb +2 -2
  50. data/test/hexapdf/document/test_signatures.rb +139 -19
  51. data/test/hexapdf/encryption/test_aes.rb +1 -1
  52. data/test/hexapdf/filter/test_predictor.rb +0 -1
  53. data/test/hexapdf/layout/test_box.rb +2 -1
  54. data/test/hexapdf/layout/test_column_box.rb +1 -1
  55. data/test/hexapdf/layout/test_list_box.rb +1 -1
  56. data/test/hexapdf/test_document.rb +2 -8
  57. data/test/hexapdf/test_importer.rb +27 -6
  58. data/test/hexapdf/test_parser.rb +19 -2
  59. data/test/hexapdf/test_revision.rb +15 -14
  60. data/test/hexapdf/test_revisions.rb +63 -12
  61. data/test/hexapdf/test_stream.rb +1 -1
  62. data/test/hexapdf/test_tokenizer.rb +10 -1
  63. data/test/hexapdf/test_writer.rb +11 -3
  64. data/test/hexapdf/type/acro_form/test_appearance_generator.rb +135 -56
  65. data/test/hexapdf/type/acro_form/test_button_field.rb +6 -1
  66. data/test/hexapdf/type/acro_form/test_choice_field.rb +4 -0
  67. data/test/hexapdf/type/acro_form/test_field.rb +4 -4
  68. data/test/hexapdf/type/acro_form/test_form.rb +65 -0
  69. data/test/hexapdf/type/acro_form/test_signature_field.rb +4 -0
  70. data/test/hexapdf/type/acro_form/test_text_field.rb +13 -0
  71. data/test/hexapdf/type/signature/common.rb +54 -0
  72. data/test/hexapdf/type/signature/test_adbe_pkcs7_detached.rb +21 -0
  73. data/test/hexapdf/type/test_catalog.rb +5 -2
  74. data/test/hexapdf/type/test_font_true_type.rb +20 -0
  75. data/test/hexapdf/type/test_object_stream.rb +2 -1
  76. data/test/hexapdf/type/test_outline.rb +4 -1
  77. data/test/hexapdf/type/test_outline_item.rb +62 -1
  78. data/test/hexapdf/type/test_page.rb +103 -45
  79. data/test/hexapdf/type/test_page_tree_node.rb +4 -2
  80. data/test/hexapdf/type/test_resources.rb +0 -5
  81. data/test/hexapdf/type/test_signature.rb +8 -0
  82. data/test/test_helper.rb +1 -1
  83. metadata +61 -4
@@ -10,6 +10,10 @@ describe HexaPDF::Type::AcroForm::ButtonField do
10
10
  @field = @doc.add({FT: :Btn, T: 'button'}, type: :XXAcroFormField, subtype: :Btn)
11
11
  end
12
12
 
13
+ it "identifies as an :XXAcroFormField type" do
14
+ assert_equal(:XXAcroFormField, @field.type)
15
+ end
16
+
13
17
  it "can be initialized as push button" do
14
18
  @field.initialize_as_push_button
15
19
  assert_nil(@field[:V])
@@ -232,6 +236,7 @@ describe HexaPDF::Type::AcroForm::ButtonField do
232
236
  @field.create_appearances
233
237
  yes = widget.appearance_dict.normal_appearance[:Yes]
234
238
  off = widget.appearance_dict.normal_appearance[:Off]
239
+ widget.appearance_dict.normal_appearance[:Yes] = HexaPDF::Reference.new(yes.oid)
235
240
  @field.create_appearances
236
241
  assert_same(yes, widget.appearance_dict.normal_appearance[:Yes])
237
242
  assert_same(off, widget.appearance_dict.normal_appearance[:Off])
@@ -252,7 +257,7 @@ describe HexaPDF::Type::AcroForm::ButtonField do
252
257
  refute_same(yes, widget.appearance_dict.normal_appearance[:Yes])
253
258
  end
254
259
 
255
- it "fails for unsupported button types" do
260
+ it "fails for push buttons as they are not implemented yet" do
256
261
  @field.flag(:push_button)
257
262
  @field.create_widget(@doc.pages.add, Rect: [0, 0, 0, 0])
258
263
  assert_raises(HexaPDF::Error) { @field.create_appearances }
@@ -10,6 +10,10 @@ describe HexaPDF::Type::AcroForm::ChoiceField do
10
10
  @field = @doc.add({FT: :Ch, T: 'choice'}, type: :XXAcroFormField, subtype: :Ch)
11
11
  end
12
12
 
13
+ it "identifies as an :XXAcroFormField type" do
14
+ assert_equal(:XXAcroFormField, @field.type)
15
+ end
16
+
13
17
  it "can be initialized as list box" do
14
18
  @field.initialize_as_list_box
15
19
  assert_nil(@field[:V])
@@ -111,14 +111,14 @@ describe HexaPDF::Type::AcroForm::Field do
111
111
  @field[:Subtype] = :Widget
112
112
  @field[:Rect] = [0, 0, 0, 0]
113
113
  widgets = @field.each_widget.to_a
114
- assert_kind_of(HexaPDF::Type::Annotations::Widget, *widgets)
114
+ assert_kind_of(HexaPDF::Type::Annotations::Widget, widgets.first)
115
115
  assert_same(@field.data, widgets.first.data)
116
116
  end
117
117
 
118
118
  it "yields all widgets in the /Kids array" do
119
119
  @field[:Kids] = [{Subtype: :Widget, Rect: [0, 0, 0, 0], X: 1}]
120
120
  widgets = @field.each_widget.to_a
121
- assert_kind_of(HexaPDF::Type::Annotations::Widget, *widgets)
121
+ assert_kind_of(HexaPDF::Type::Annotations::Widget, widgets.first)
122
122
  assert_equal(1, widgets.first[:X])
123
123
  end
124
124
 
@@ -128,8 +128,8 @@ describe HexaPDF::Type::AcroForm::Field do
128
128
  @doc.add({T: "b", Subtype: :Widget, Rect: [0, 0, 0, 0]}, type: :XXAcroFormField) <<
129
129
  @doc.add({T: "a", X: 1, Subtype: :Widget, Rect: [0, 0, 0, 0]}, type: :XXAcroFormField)
130
130
 
131
- widgets = @field.each_widget.to_a
132
- assert_kind_of(HexaPDF::Type::Annotations::Widget, *widgets)
131
+ widgets = @field.each_widget(direct_only: false).to_a
132
+ assert_kind_of(HexaPDF::Type::Annotations::Widget, widgets.first)
133
133
  assert_equal(1, widgets.first[:X])
134
134
  end
135
135
 
@@ -327,6 +327,12 @@ describe HexaPDF::Type::AcroForm::Form do
327
327
  assert_equal(1, result.size)
328
328
  assert(@doc.catalog.key?(:AcroForm))
329
329
  end
330
+
331
+ it "returns the fields that could not be flattened" do
332
+ @cb.create_appearances
333
+ result = @acro_form.flatten(create_appearances: false)
334
+ assert_equal([@tf], result)
335
+ end
330
336
  end
331
337
 
332
338
  describe "perform_validation" do
@@ -349,6 +355,65 @@ describe HexaPDF::Type::AcroForm::Form do
349
355
  assert_equal("0.0 g /F1 0 Tf", @acro_form[:DA])
350
356
  end
351
357
 
358
+ describe "field hierarchy validation" do
359
+ before do
360
+ @acro_form[:Fields] = [
361
+ nil,
362
+ HexaPDF::Object.new(nil),
363
+ 5,
364
+ HexaPDF::Object.new(5),
365
+ @doc.add({T: :Tx1}),
366
+ @doc.add({T: :Tx2, Kids: [nil, @doc.add({Subtype: :Widget})]}),
367
+ @doc.add({T: :Tx3, FT: :Tx, Kids: [@doc.add({T: :Tx4}),
368
+ [:nothing],
369
+ @doc.add({T: :Tx5, Kids: [@doc.add({T: :Tx6})]})]}),
370
+ ]
371
+ @acro_form[:Fields][6][:Kids][0][:Parent] = @acro_form[:Fields][6]
372
+ @acro_form[:Fields][6][:Kids][2][:Parent] = @acro_form[:Fields][6]
373
+ @acro_form[:Fields][6][:Kids][2][:Kids][0][:Parent] = @acro_form[:Fields][6][:Kids][2]
374
+ end
375
+
376
+ it "removes invalid objects from the field hierarchy" do
377
+ assert(@acro_form.validate)
378
+ assert_equal([:Tx1, :Tx2, :Tx3, :Tx4, :Tx5, :Tx6],
379
+ @acro_form.each_field(terminal_only: false).map {|f| f[:T] })
380
+ end
381
+
382
+ it "handles missing /Parent fields" do
383
+ @acro_form[:Fields][6][:Kids][0].delete(:Parent)
384
+ assert(@acro_form.validate)
385
+ assert_equal(1, @acro_form[:Fields][2][:Kids].size)
386
+ assert_equal(:Tx5, @acro_form[:Fields][2][:Kids][0][:T])
387
+ assert_equal(:Tx4, @acro_form[:Fields][3][:T])
388
+ end
389
+
390
+ it "handles /Parent field pointing to somewhere else" do
391
+ @acro_form[:Fields][6][:Kids][0][:Parent] = @acro_form[:Fields][4]
392
+ assert(@acro_form.validate)
393
+ assert_equal(2, @acro_form[:Fields][2][:Kids].size)
394
+ assert_equal(:Tx4, @acro_form[:Fields][2][:Kids][0][:T])
395
+ assert_equal(@acro_form[:Fields][2], @acro_form[:Fields][2][:Kids][0][:Parent])
396
+ end
397
+ end
398
+
399
+ describe "combining fields with the same name" do
400
+ before do
401
+ @acro_form[:Fields] = [
402
+ @doc.add({T: 'e', Subtype: :Widget, Rect: [0, 0, 0, 1]}),
403
+ @doc.add({T: 'e', Subtype: :Widget, Rect: [0, 0, 0, 2]}),
404
+ @doc.add({T: 'Tx2'}),
405
+ @doc.add({T: 'e', Kids: [{Subtype: :Widget, Rect: [0, 0, 0, 3]}]}),
406
+ ]
407
+ end
408
+
409
+ it "merges fields with the same name into the first one" do
410
+ assert(@acro_form.validate)
411
+ assert_equal(2, @acro_form.root_fields.size)
412
+ assert_equal([[0, 0, 0, 1], [0, 0, 0, 2], [0, 0, 0, 3]],
413
+ @acro_form.field_by_name('e').each_widget.map {|w| w[:Rect] })
414
+ end
415
+ end
416
+
352
417
  describe "automatically creates the terminal fields; appearances" do
353
418
  before do
354
419
  @cb = @acro_form.create_check_box('test2')
@@ -20,6 +20,10 @@ describe HexaPDF::Type::AcroForm::SignatureField do
20
20
  @field = @doc.wrap({}, type: :XXAcroFormField, subtype: :Sig)
21
21
  end
22
22
 
23
+ it "identifies as an :XXAcroFormField type" do
24
+ assert_equal(:XXAcroFormField, @field.type)
25
+ end
26
+
23
27
  it "sets the field value" do
24
28
  @field.field_value = {Empty: :True}
25
29
  assert_equal({Empty: :True}, @field[:V].value)
@@ -10,6 +10,10 @@ describe HexaPDF::Type::AcroForm::TextField do
10
10
  @field = @doc.add({FT: :Tx}, type: :XXAcroFormField, subtype: :Tx)
11
11
  end
12
12
 
13
+ it "identifies as an :XXAcroFormField type" do
14
+ assert_equal(:XXAcroFormField, @field.type)
15
+ end
16
+
13
17
  it "resolves /MaxLen as inheritable field" do
14
18
  assert_nil(@field[:MaxLen])
15
19
 
@@ -164,11 +168,20 @@ describe HexaPDF::Type::AcroForm::TextField do
164
168
  assert_same(stream, @field[:AP][:N].raw_stream)
165
169
  @field.field_value = 'test'
166
170
  refute_same(stream, @field[:AP][:N].raw_stream)
171
+ stream = @field[:AP][:N].raw_stream
167
172
 
168
173
  widget = @field.create_widget(@doc.pages.add, Rect: [0, 0, 0, 0])
169
174
  assert_nil(widget[:AP])
170
175
  @field.create_appearances
171
176
  refute_nil(widget[:AP][:N])
177
+
178
+ @doc.clear_cache
179
+ @field.create_appearances
180
+ assert_same(stream, @field[:Kids][0][:AP][:N].raw_stream)
181
+
182
+ @doc.clear_cache
183
+ @field.field_value = 'other'
184
+ refute_same(stream, @field[:Kids][0][:AP][:N].raw_stream)
172
185
  end
173
186
 
174
187
  it "always creates a new appearance stream if force is true" do
@@ -65,6 +65,60 @@ module HexaPDF
65
65
  end
66
66
  end
67
67
 
68
+ def timestamp_certificate
69
+ @timestamp_certificate ||=
70
+ begin
71
+ name = OpenSSL::X509::Name.parse('/CN=timestamp/DC=gettalong')
72
+
73
+ signer_cert = OpenSSL::X509::Certificate.new
74
+ signer_cert.serial = 3
75
+ signer_cert.version = 2
76
+ signer_cert.not_before = Time.now - 86400
77
+ signer_cert.not_after = Time.now + 86400
78
+ signer_cert.public_key = signer_key.public_key
79
+ signer_cert.subject = name
80
+ signer_cert.issuer = ca_certificate.subject
81
+
82
+ extension_factory = OpenSSL::X509::ExtensionFactory.new
83
+ extension_factory.subject_certificate = signer_cert
84
+ extension_factory.issuer_certificate = ca_certificate
85
+ signer_cert.add_extension(extension_factory.create_extension('subjectKeyIdentifier', 'hash'))
86
+ signer_cert.add_extension(extension_factory.create_extension('basicConstraints', 'CA:FALSE'))
87
+ signer_cert.add_extension(extension_factory.create_extension('keyUsage', 'digitalSignature'))
88
+ signer_cert.add_extension(extension_factory.create_extension('extendedKeyUsage',
89
+ 'timeStamping', true))
90
+ signer_cert.sign(ca_key, OpenSSL::Digest.new('SHA1'))
91
+
92
+ signer_cert
93
+ end
94
+ end
95
+
96
+ def start_tsa_server
97
+ return if defined?(@tsa_server)
98
+ require 'webrick'
99
+ port = 34567
100
+ @tsa_server = WEBrick::HTTPServer.new(Port: port, BindAddress: '127.0.0.1',
101
+ Logger: WEBrick::Log.new(StringIO.new), AccessLog: [])
102
+ @tsa_server.mount_proc('/') do |request, response|
103
+ @tsr = OpenSSL::Timestamp::Request.new(request.body)
104
+ case (@tsr.policy_id || '1.2.3.4.0')
105
+ when '1.2.3.4.0', '1.2.3.4.2'
106
+ fac = OpenSSL::Timestamp::Factory.new
107
+ fac.gen_time = Time.now
108
+ fac.serial_number = 1
109
+ fac.default_policy_id = '1.2.3.4.5'
110
+ fac.allowed_digests = ["sha256", "sha512"]
111
+ tsr = fac.create_timestamp(CERTIFICATES.signer_key, CERTIFICATES.timestamp_certificate,
112
+ @tsr)
113
+ response.body = tsr.to_der
114
+ when '1.2.3.4.1'
115
+ response.status = 403
116
+ response.body = "Invalid"
117
+ end
118
+ end
119
+ Thread.new { @tsa_server.start }
120
+ end
121
+
68
122
  end
69
123
 
70
124
  end
@@ -1,5 +1,6 @@
1
1
  # -*- encoding: utf-8 -*-
2
2
 
3
+ require 'digest'
3
4
  require 'test_helper'
4
5
  require_relative 'common'
5
6
  require 'hexapdf/type/signature'
@@ -95,5 +96,25 @@ describe HexaPDF::Type::Signature::AdbePkcs7Detached do
95
96
  assert_equal(:error, result.messages.last.type)
96
97
  assert_match(/Signature verification failed/, result.messages.last.content)
97
98
  end
99
+
100
+ it "verifies a timestamp signature" do
101
+ req = OpenSSL::Timestamp::Request.new
102
+ req.algorithm = 'SHA256'
103
+ req.message_imprint = Digest::SHA256.digest(@data)
104
+ req.policy_id = "1.2.3.4.5"
105
+ req.nonce = 42
106
+ fac = OpenSSL::Timestamp::Factory.new
107
+ fac.gen_time = Time.now
108
+ fac.serial_number = 1
109
+ fac.allowed_digests = ["sha256", "sha512"]
110
+ res = fac.create_timestamp(CERTIFICATES.signer_key, CERTIFICATES.timestamp_certificate, req)
111
+ @dict.contents = res.token.to_der
112
+ @dict.signature_type = 'ETSI.RFC3161'
113
+ @handler = HexaPDF::Type::Signature::AdbePkcs7Detached.new(@dict)
114
+
115
+ result = @handler.verify(@store)
116
+ assert_equal(:info, result.messages.last.type)
117
+ assert_match(/Signature valid/, result.messages.last.content)
118
+ end
98
119
  end
99
120
  end
@@ -29,8 +29,11 @@ describe HexaPDF::Type::Catalog do
29
29
  assert_same(other, names)
30
30
  end
31
31
 
32
- it "creates the document outline on access" do
33
- assert_nil(@catalog[:Outlines])
32
+ it "uses or creates the document outline on access" do
33
+ @catalog[:Outlines] = {}
34
+ assert_equal(:Outlines, @catalog.outline.type)
35
+
36
+ @catalog.delete(:Outlines)
34
37
  outline = @catalog.outline
35
38
  assert_equal(:Outlines, outline.type)
36
39
  assert_same(outline, @catalog.outline)
@@ -15,6 +15,26 @@ describe HexaPDF::Type::FontTrueType do
15
15
  BaseFont: :Something, FontDescriptor: font_descriptor})
16
16
  end
17
17
 
18
+ describe "font_wrapper" do
19
+ it "returns the default value if the font is subset" do
20
+ @font[:BaseFont] = :'ABCDEF+Something'
21
+ assert_nil(@font.font_wrapper)
22
+ end
23
+
24
+ it "returns the default value if the font has no embedded font file" do
25
+ assert_nil(@font.font_wrapper)
26
+ end
27
+
28
+ it "uses a fully embedded TrueType font file" do
29
+ font_file = File.binread(File.join(TEST_DATA_DIR, "fonts", "Ubuntu-Title.ttf"))
30
+ @font[:FontDescriptor][:FontFile2] = @doc.add({}, stream: font_file)
31
+ font_wrapper = @font.font_wrapper
32
+ assert(font_wrapper)
33
+ assert_equal(font_file, font_wrapper.wrapped_font.io.string)
34
+ assert_same(font_wrapper, @font.font_wrapper)
35
+ end
36
+ end
37
+
18
38
  describe "validation" do
19
39
  it "ignores some missing fields if the font name is one of the standard PDF fonts" do
20
40
  @font[:BaseFont] = :'Arial,Bold'
@@ -104,7 +104,8 @@ describe HexaPDF::Type::ObjectStream do
104
104
  assert_equal("", @obj.stream)
105
105
  end
106
106
 
107
- it "doesn't allow the Catalog entry to be compressed when encryption is used" do
107
+ it "doesn't allow the Catalog entry to be compressed" do
108
+ @doc.trailer.delete(:Encrypt)
108
109
  @obj.add_object(HexaPDF::Dictionary.new({Type: :Catalog}, oid: 8))
109
110
  @obj.write_objects(@revision)
110
111
  assert_equal(0, @obj.value[:N])
@@ -25,7 +25,7 @@ describe HexaPDF::Type::Outline do
25
25
  end
26
26
  item1.add_item("Item5")
27
27
  end
28
- assert_equal(%w[Item1 Item2 Item3 Item4 Item5], @outline.each_item.map(&:title))
28
+ assert_equal(%w[Item1 Item2 Item3 Item4 Item5], @outline.each_item.map {|i, _| i.title })
29
29
  end
30
30
 
31
31
  describe "perform_validation" do
@@ -64,6 +64,9 @@ describe HexaPDF::Type::Outline do
64
64
  assert(correctable)
65
65
  end
66
66
  refute(@outline.key?(:Count))
67
+
68
+ @outline[:Count] = 0
69
+ assert(@outline.validate(auto_correct: false))
67
70
  end
68
71
  end
69
72
  end
@@ -10,6 +10,10 @@ describe HexaPDF::Type::OutlineItem do
10
10
  @item = @doc.add({Title: "root", Count: 0}, type: :XXOutlineItem)
11
11
  end
12
12
 
13
+ it "must be an indirect object" do
14
+ assert(@item.must_be_indirect?)
15
+ end
16
+
13
17
  describe "title" do
14
18
  it "returns the set title" do
15
19
  @item[:Title] = 'Test'
@@ -78,6 +82,43 @@ describe HexaPDF::Type::OutlineItem do
78
82
  end
79
83
  end
80
84
 
85
+ describe "level" do
86
+ it "returns 0 for the outline dictionary when treated as an item" do
87
+ assert_equal(0, @item.level)
88
+ end
89
+
90
+ it "returns 1 for the root level items" do
91
+ @item[:Parent] = {Type: :Outlines}
92
+ assert_equal(1, @item.level)
93
+ end
94
+
95
+ it "returns the correct level for items in the hierarchy" do
96
+ @item[:Parent] = {Title: 'Root elem', Parent: {Type: :Outlines}}
97
+ assert_equal(2, @item.level)
98
+ end
99
+ end
100
+
101
+ describe "destination_page" do
102
+ it "returns the page of a set destination" do
103
+ @item[:Dest] = [5, :Fit]
104
+ assert_equal(5, @item.destination_page)
105
+ end
106
+
107
+ it "returns the page of a set GoTO action" do
108
+ @item[:A] = {S: :GoTo, D: [5, :Fit]}
109
+ assert_equal(5, @item.destination_page)
110
+ end
111
+
112
+ it "returns nil if no destination or action is set" do
113
+ assert_nil(@item.destination_page)
114
+ end
115
+
116
+ it "returns nil if an action besides GoTo is set" do
117
+ @item[:A] = {S: :GoToR}
118
+ assert_nil(@item.destination_page)
119
+ end
120
+ end
121
+
81
122
  describe "add" do
82
123
  it "returns the created item" do
83
124
  new_item = @item.add_item("Test")
@@ -118,6 +159,24 @@ describe HexaPDF::Type::OutlineItem do
118
159
  assert_same(new_item, yielded_item)
119
160
  end
120
161
 
162
+ it "uses the provided outline item instead of creating a new one" do
163
+ item = @doc.wrap({Dest: [1, :Fit], flags: 1, First: 5, Count: 2}, type: :XXOutlineItem)
164
+ new_item = @item.add_item(item, destination: [2, :Fit])
165
+ assert_same(item, new_item)
166
+ assert_equal([1, :Fit], new_item.destination)
167
+ assert_same(@item, new_item[:Parent])
168
+ refute(new_item.key?(:First))
169
+ assert_equal(0, new_item[:Count])
170
+
171
+ item = @doc.wrap({Count: nil}, type: :XXOutlineItem)
172
+ new_item = @item.add_item(item)
173
+ refute(new_item.key?(:Count))
174
+
175
+ item = @doc.wrap({Count: -1}, type: :XXOutlineItem)
176
+ new_item = @item.add_item(item)
177
+ refute(new_item.key?(:Count))
178
+ end
179
+
121
180
  describe "position" do
122
181
  it "works for an empty item" do
123
182
  new_item = @item.add_item("Test")
@@ -211,7 +270,8 @@ describe HexaPDF::Type::OutlineItem do
211
270
  end
212
271
  item1.add_item("Item5")
213
272
  end
214
- assert_equal(%w[Item1 Item2 Item3 Item4 Item5], @item.each_item.map(&:title))
273
+ assert_equal(['Item1', 1, 'Item2', 2, 'Item3', 2, 'Item4', 3, 'Item5', 2],
274
+ @item.each_item.map {|i, l| [i.title, l] }.flatten)
215
275
  end
216
276
 
217
277
  describe "perform_validation" do
@@ -251,6 +311,7 @@ describe HexaPDF::Type::OutlineItem do
251
311
  assert(correctable)
252
312
  end
253
313
  refute(@item.key?(:Count))
314
+ assert(@item.validate(auto_correct: false))
254
315
  end
255
316
 
256
317
  it "fails validation if the previous item's /Next points somewhere else" do