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
@@ -187,8 +187,8 @@ module HexaPDF
187
187
  end
188
188
 
189
189
  # :call-seq:
190
- # page.box(type = :media) -> box
191
- # page.box(type = :media, rectangle) -> rectangle
190
+ # page.box(type = :crop) -> box
191
+ # page.box(type = :crop, rectangle) -> rectangle
192
192
  #
193
193
  # If no +rectangle+ is given, returns the rectangle defining a certain kind of box for the
194
194
  # page. Otherwise sets the value for the given box type to +rectangle+ (an array with four
@@ -219,7 +219,7 @@ module HexaPDF
219
219
  # author. The default is the crop box.
220
220
  #
221
221
  # See: PDF1.7 s14.11.2
222
- def box(type = :media, rectangle = nil)
222
+ def box(type = :crop, rectangle = nil)
223
223
  if rectangle
224
224
  case type
225
225
  when :media, :crop, :bleed, :trim, :art
@@ -240,9 +240,10 @@ module HexaPDF
240
240
  end
241
241
  end
242
242
 
243
- # Returns the orientation of the media box, either :portrait or :landscape.
244
- def orientation
245
- box = self[:MediaBox]
243
+ # Returns the orientation of the specified box (default is the crop box), either :portrait or
244
+ # :landscape.
245
+ def orientation(type = :crop)
246
+ box = self.box(type)
246
247
  rotation = self[:Rotate]
247
248
  if (box.height > box.width && (rotation == 0 || rotation == 180)) ||
248
249
  (box.height < box.width && (rotation == 90 || rotation == 270))
@@ -266,27 +267,49 @@ module HexaPDF
266
267
  raise ArgumentError, "Page rotation has to be multiple of 90 degrees"
267
268
  end
268
269
 
270
+ # /Rotate and therefore cw_angle is angle in clockwise orientation
269
271
  cw_angle = (self[:Rotate] - angle) % 360
270
272
 
271
273
  if flatten
272
274
  delete(:Rotate)
273
275
  return if cw_angle == 0
274
276
 
275
- matrix, llx, lly, urx, ury = \
276
- case cw_angle
277
- when 90
278
- [HexaPDF::Content::TransformationMatrix.new(0, -1, 1, 0),
279
- box.right, box.bottom, box.left, box.top]
280
- when 180
281
- [HexaPDF::Content::TransformationMatrix.new(-1, 0, 0, -1),
282
- box.right, box.top, box.left, box.bottom]
283
- when 270
284
- [HexaPDF::Content::TransformationMatrix.new(0, 1, -1, 0),
285
- box.left, box.top, box.right, box.bottom]
277
+ matrix = case cw_angle
278
+ when 90 then Content::TransformationMatrix.new(0, -1, 1, 0, -box.bottom, box.right)
279
+ when 180 then Content::TransformationMatrix.new(-1, 0, 0, -1, box.right, box.top)
280
+ when 270 then Content::TransformationMatrix.new(0, 1, -1, 0, box.top, -box.left)
281
+ end
282
+
283
+ rotate_box = lambda do |box|
284
+ llx, lly, urx, ury = \
285
+ case cw_angle
286
+ when 90 then [box.right, box.bottom, box.left, box.top]
287
+ when 180 then [box.right, box.top, box.left, box.bottom]
288
+ when 270 then [box.left, box.top, box.right, box.bottom]
289
+ end
290
+ box.value.replace(matrix.evaluate(llx, lly).concat(matrix.evaluate(urx, ury)))
291
+ end
292
+
293
+ [:MediaBox, :CropBox, :BleedBox, :TrimBox, :ArtBox].each do |box_name|
294
+ next unless key?(box_name)
295
+ rotate_box.call(self[box_name])
296
+ end
297
+
298
+ each_annotation do |annot|
299
+ rotate_box.call(annot[:Rect])
300
+ if (quad_points = annot[:QuadPoints])
301
+ quad_points = quad_points.value if quad_points.respond_to?(:value)
302
+ result = []
303
+ quad_points.each_slice(2) {|x, y| result.concat(matrix.evaluate(x, y)) }
304
+ quad_points.replace(result)
305
+ end
306
+ if (appearance = annot.appearance)
307
+ appearance[:Matrix] = matrix.dup.premultiply(*appearance[:Matrix].value).to_a
308
+ end
309
+ if annot[:Subtype] == :Widget
310
+ app_ch = annot[:MK] ||= document.wrap({}, type: :XXAppearanceCharacteristics)
311
+ app_ch[:R] = (app_ch[:R] + 360 - cw_angle) % 360
286
312
  end
287
- [:MediaBox, :CropBox, :BleedBox, :TrimBox, :ArtBox].each do |box|
288
- next unless key?(box)
289
- self[box].value = matrix.evaluate(llx, lly).concat(matrix.evaluate(urx, ury))
290
313
  end
291
314
 
292
315
  before_contents = document.add({}, stream: " q #{matrix.to_a.join(' ')} cm ")
@@ -373,20 +396,33 @@ module HexaPDF
373
396
 
374
397
  # Returns the requested type of canvas for the page.
375
398
  #
376
- # The canvas object is cached once it is created so that its graphics state is correctly
377
- # retained without the need for parsing its contents.
378
- #
379
- # If the media box of the page doesn't have its origin at (0, 0), the canvas origin is
380
- # translated into the bottom left corner so that this detail doesn't matter when using the
381
- # canvas. This means that the canvas' origin is always at the bottom left corner of the media
382
- # box.
399
+ # There are potentially three different canvas objects, one for each of the types :underlay,
400
+ # :page, and :overlay. The canvas objects are cached once they are created so that their
401
+ # graphics states are correctly retained without the need for parsing the contents. This also
402
+ # means that on subsequent invocations the graphic states of the canvases might already be
403
+ # changed.
383
404
  #
384
405
  # type::
385
406
  # Can either be
386
407
  # * :page for getting the canvas for the page itself (only valid for initially empty pages)
387
408
  # * :overlay for getting the canvas for drawing over the page contents
388
409
  # * :underlay for getting the canvas for drawing unter the page contents
389
- def canvas(type: :page)
410
+ #
411
+ # translate_origin::
412
+ # Specifies whether the origin should automatically be translated into the lower-left
413
+ # corner of the crop box.
414
+ #
415
+ # Note that this argument is only used for the first invocation for every canvas type. So
416
+ # if a canvas was initially requested with this argument set to false and then with true,
417
+ # it won't have any effect as the cached canvas is returned.
418
+ #
419
+ # To check whether the origin has been translated or not, use
420
+ #
421
+ # canvas.graphics_state.ctm.evaluate(0, 0)
422
+ #
423
+ # and check whether the result is [0, 0]. If it is, then the origin has not been
424
+ # translated.
425
+ def canvas(type: :page, translate_origin: true)
390
426
  unless [:page, :overlay, :underlay].include?(type)
391
427
  raise ArgumentError, "Invalid value for 'type', expected: :page, :underlay or :overlay"
392
428
  end
@@ -399,9 +435,10 @@ module HexaPDF
399
435
 
400
436
  create_canvas = lambda do
401
437
  Content::Canvas.new(self).tap do |canvas|
402
- media_box = box(:media)
403
- if media_box.left != 0 || media_box.bottom != 0
404
- canvas.translate(media_box.left, media_box.bottom)
438
+ next unless translate_origin
439
+ crop_box = box(:crop)
440
+ if crop_box.left != 0 || crop_box.bottom != 0
441
+ canvas.translate(crop_box.left, crop_box.bottom)
405
442
  end
406
443
  end
407
444
  end
@@ -497,17 +534,16 @@ module HexaPDF
497
534
  # If an annotation is a form field widget, only the widget will be deleted but not the form
498
535
  # field itself.
499
536
  def flatten_annotations(annotations = self[:Annots])
500
- return [] unless key?(:Annots)
537
+ not_flattened = (annotations || []).to_ary
538
+ return not_flattened unless key?(:Annots)
501
539
 
502
- not_flattened = annotations.to_ary
503
540
  annotations = not_flattened & self[:Annots] if annotations != self[:Annots]
504
541
  return not_flattened if annotations.empty?
505
542
 
506
543
  canvas = self.canvas(type: :overlay)
507
- canvas.save_graphics_state
508
- media_box = box(:media)
509
- if media_box.left != 0 || media_box.bottom != 0
510
- canvas.translate(-media_box.left, -media_box.bottom) # revert initial translation of origin
544
+ if (pos = canvas.graphics_state.ctm.evaluate(0, 0)) != [0, 0]
545
+ canvas.save_graphics_state
546
+ canvas.translate(-pos[0], -pos[1])
511
547
  end
512
548
 
513
549
  to_delete = []
@@ -525,36 +561,31 @@ module HexaPDF
525
561
 
526
562
  rect = annotation[:Rect]
527
563
  box = appearance.box
528
- matrix = appearance[:Matrix]
529
-
530
- # Adjust position based on matrix
531
- pos = [rect.left - matrix[4], rect.bottom - matrix[5]]
532
-
533
- # In case of a rotation we need to counter the default translation in #xobject by adding
534
- # box.left and box.bottom, and then translate the origin for the rotation
535
- angle = (-Math.atan2(matrix[2], matrix[0]) * 180 / Math::PI).to_i
536
- case angle
537
- when 0
538
- # Nothing to do, no rotation
539
- when 90
540
- pos[0] += box.top + box.left
541
- pos[1] += -box.left + box.bottom
542
- when -90
543
- pos[0] += -box.bottom + box.left
544
- pos[1] += box.right + box.bottom
545
- when 180, -180
546
- pos[0] += box.right + box.left
547
- pos[1] += box.top + box.bottom
548
- else
549
- not_flattened << annotation
550
- next
551
- end
552
564
 
553
- width, height = (angle.abs == 90 ? [rect.height, rect.width] : [rect.width, rect.height])
554
- canvas.xobject(appearance, at: pos, width: width, height: height)
565
+ # PDF1.7 12.5.5 algorithm
566
+ # Step a) Calculate smallest rectangle containing transformed bounding box
567
+ matrix = HexaPDF::Content::TransformationMatrix.new(*appearance[:Matrix].value)
568
+ llx, lly = matrix.evaluate(box.left, box.bottom)
569
+ ulx, uly = matrix.evaluate(box.left, box.top)
570
+ lrx, lry = matrix.evaluate(box.right, box.bottom)
571
+ left, right = [llx, ulx, lrx, lrx + (ulx - llx)].minmax
572
+ bottom, top = [lly, uly, lry, lry + (uly - lly)].minmax
573
+
574
+ # Step b) Fit calculated rectangle to annotation rectangle by translating/scaling
575
+ a = HexaPDF::Content::TransformationMatrix.new
576
+ a.translate(rect.left - left, rect.bottom - bottom)
577
+ a.scale(rect.width.fdiv(right - left), rect.height.fdiv(top - bottom))
578
+
579
+ # Step c) Premultiply form matrix - done implicitly when drawing the XObject
580
+
581
+ canvas.transform(*a) do
582
+ # Use [box.left, box.bottom] to counter default translation in #xobject since that
583
+ # is already taken care of in matrix a
584
+ canvas.xobject(appearance, at: [box.left, box.bottom])
585
+ end
555
586
  to_delete << annotation
556
587
  end
557
- canvas.restore_graphics_state
588
+ canvas.restore_graphics_state unless pos == [0, 0]
558
589
 
559
590
  to_delete.each do |annotation|
560
591
  if annotation[:Subtype] == :Widget
@@ -217,24 +217,20 @@ module HexaPDF
217
217
  # Ensures that a valid procedure set is available.
218
218
  def perform_validation
219
219
  super
220
- val = self[:ProcSet]
221
- if val
222
- if val.kind_of?(Symbol)
223
- yield("Procedure set is a single value instead of an Array", true)
224
- val = value[:ProcSet] = [val]
225
- end
226
- val.reject! do |name|
227
- case name
228
- when :PDF, :Text, :ImageB, :ImageC, :ImageI
229
- false
230
- else
231
- yield("Invalid page procedure set name /#{name}", true)
232
- true
233
- end
220
+ return unless (val = self[:ProcSet])
221
+
222
+ if val.kind_of?(Symbol)
223
+ yield("Procedure set is a single value instead of an Array", true)
224
+ val = value[:ProcSet] = [val]
225
+ end
226
+ val.reject! do |name|
227
+ case name
228
+ when :PDF, :Text, :ImageB, :ImageC, :ImageI
229
+ false
230
+ else
231
+ yield("Invalid page procedure set name /#{name}", true)
232
+ true
234
233
  end
235
- else
236
- yield("No procedure set specified", true)
237
- self[:ProcSet] = [:PDF, :Text, :ImageB, :ImageC, :ImageI]
238
234
  end
239
235
  end
240
236
 
@@ -104,8 +104,22 @@ module HexaPDF
104
104
  result.log(:error, "Certificate key usage is missing 'Digital Signature'")
105
105
  end
106
106
 
107
- if @pkcs7.verify(certificate_chain, store, signature_dict.signed_data,
108
- OpenSSL::PKCS7::DETACHED | OpenSSL::PKCS7::BINARY)
107
+ if signature_dict.signature_type == 'ETSI.RFC3161'
108
+ # Getting the needed values is not directly supported by Ruby OpenSSL
109
+ p7 = OpenSSL::ASN1.decode(signature_dict.contents.sub(/\x00*\z/, ''))
110
+ signed_data = p7.value[1].value[0]
111
+ content_info = signed_data.value[2]
112
+ content = OpenSSL::ASN1.decode(content_info.value[1].value[0].value)
113
+ digest_algorithm = content.value[2].value[0].value[0].value
114
+ original_hash = content.value[2].value[1].value
115
+ recomputed_hash = OpenSSL::Digest.digest(digest_algorithm, signature_dict.signed_data)
116
+ hash_valid = (original_hash == recomputed_hash)
117
+ else
118
+ data = signature_dict.signed_data
119
+ hash_valid = true # hash will be checked by @pkcs7.verify
120
+ end
121
+ if hash_valid && @pkcs7.verify(certificate_chain, store, data,
122
+ OpenSSL::PKCS7::DETACHED | OpenSSL::PKCS7::BINARY)
109
123
  result.log(:info, "Signature valid")
110
124
  else
111
125
  result.log(:error, "Signature verification failed")
@@ -230,6 +230,16 @@ module HexaPDF
230
230
  signature_handler.verify(store, allow_self_signed: allow_self_signed)
231
231
  end
232
232
 
233
+ private
234
+
235
+ def perform_validation #:nodoc:
236
+ if (self[:SubFilter] == :'ETSI.CAdES.detached' || self[:SubFilter] == :'ETSI.RFC3161') &&
237
+ document.version < '2.0'
238
+ yield("Signature handler needs at least PDF version 2.0", true)
239
+ document.version = '2.0'
240
+ end
241
+ end
242
+
233
243
  end
234
244
 
235
245
  end
@@ -37,6 +37,6 @@
37
37
  module HexaPDF
38
38
 
39
39
  # The version of HexaPDF.
40
- VERSION = '0.26.2'
40
+ VERSION = '0.28.0'
41
41
 
42
42
  end
@@ -110,8 +110,11 @@ module HexaPDF
110
110
 
111
111
  revision = Revision.new(@document.revisions.current.trailer)
112
112
  @document.trailer.info[:Producer] = "HexaPDF version #{HexaPDF::VERSION}"
113
+ if parser.file_header_version < @document.version
114
+ @document.catalog[:Version] = @document.version.to_sym
115
+ end
113
116
  @document.revisions.each do |rev|
114
- rev.each_modified_object {|obj| revision.send(:add_without_check, obj) }
117
+ rev.each_modified_object(all: true) {|obj| revision.send(:add_without_check, obj) }
115
118
  end
116
119
 
117
120
  write_revision(revision, parser.startxref_offset)
@@ -132,8 +135,7 @@ module HexaPDF
132
135
 
133
136
  revision = @document.revisions.add
134
137
  @document.revisions.all[0..-2].each do |rev|
135
- rev.each_modified_object {|obj| revision.send(:add_without_check, obj) }
136
- rev.reset_objects
138
+ rev.each_modified_object(delete: true) {|obj| revision.send(:add_without_check, obj) }
137
139
  end
138
140
  @document.revisions.merge(-2..-1)
139
141
  end
@@ -934,6 +934,11 @@ describe HexaPDF::Content::Canvas do
934
934
  [:restore_graphics_state]])
935
935
  end
936
936
 
937
+ it "correctly serializes the form when no transformation is needed" do
938
+ @canvas.image(@form, at: [100, 50])
939
+ assert_operators(@page.contents, [[:paint_xobject, [:XO1]]])
940
+ end
941
+
937
942
  it "doesn't do anything if the form's width or height is zero" do
938
943
  @form[:BBox] = [100, 50, 100, 200]
939
944
  @canvas.xobject(@form, at: [0, 0])
@@ -29,6 +29,12 @@ describe HexaPDF::Document::Destinations::Destination do
29
29
  end
30
30
  end
31
31
 
32
+ it "accepts an array or a dictionary containing a /D entry as value" do
33
+ assert(destination([5, :Fit]).valid?)
34
+ assert(destination({D: [5, :Fit]}).valid?)
35
+ assert(destination(HexaPDF::Dictionary.new({D: [5, :Fit]})).valid?)
36
+ end
37
+
32
38
  it "can be asked whether the referenced page is in a remote document" do
33
39
  assert(destination([5, :Fit]).remote?)
34
40
  refute(destination([HexaPDF::Dictionary.new({}), :Fit]).remote?)
@@ -42,6 +48,10 @@ describe HexaPDF::Document::Destinations::Destination do
42
48
  assert(destination([5, :Fit]).valid?)
43
49
  end
44
50
 
51
+ it "returns the destination array" do
52
+ assert_equal([5, :Fit], destination([5, :Fit]).value)
53
+ end
54
+
45
55
  describe "type :xyz" do
46
56
  before do
47
57
  @dest = destination([:page, :XYZ, :left, :top, :zoom])
@@ -396,6 +406,37 @@ describe HexaPDF::Document::Destinations do
396
406
  refute(@doc.destinations['abc'])
397
407
  end
398
408
 
409
+ describe "resolve" do
410
+ it "resolves the named destination" do
411
+ @doc.catalog.names.destinations.add_entry("arr", [@page, :Fit])
412
+ @doc.catalog.names.destinations.add_entry("dict", {D: [@page, :Fit]})
413
+ assert_equal([@page, :Fit], @doc.destinations.resolve("arr").value)
414
+ assert_equal([@page, :Fit], @doc.destinations.resolve("dict").value)
415
+ end
416
+
417
+ it "returns nil if the named destination is not found" do
418
+ assert_nil(@doc.destinations.resolve("arr"))
419
+ end
420
+
421
+ it "resolves the old-style named destination" do
422
+ @doc.catalog[:Dests] = {arr: [@page, :Fit]}
423
+ assert_equal([@page, :Fit], @doc.destinations.resolve(:arr).value)
424
+ end
425
+
426
+ it "returns nil if the old-style named destination is not found" do
427
+ assert_nil(@doc.destinations.resolve(:arr))
428
+ end
429
+
430
+ it "uses a PDFArray or array argument directly" do
431
+ assert_equal([@page, :Fit], @doc.destinations.resolve([@page, :Fit]).value)
432
+ assert_equal([@page, :Fit], @doc.destinations.resolve(HexaPDF::PDFArray.new([@page, :Fit])).value)
433
+ end
434
+
435
+ it "returns nil if the resolved destination is not valid" do
436
+ assert_nil(@doc.destinations.resolve([@page, :Fitd]))
437
+ end
438
+ end
439
+
399
440
  describe "each" do
400
441
  before do
401
442
  3.times {|i| @doc.destinations.add("abc#{i}", [:page, :Fit]) }
@@ -166,7 +166,7 @@ describe HexaPDF::Document::Pages do
166
166
  it "works for a single page label entry" do
167
167
  @doc.catalog[:PageLabels] = {Nums: [0, {S: :r}]}
168
168
  result = @doc.pages.each_labelling_range.to_a
169
- assert_equal([[0, 10, {S: :r}]], result.map {|s, c, l| [s, c, l.value]})
169
+ assert_equal([[0, 10, {S: :r}]], result.map {|s, c, l| [s, c, l.value] })
170
170
  assert_equal(:lowercase_roman, result[0].last.numbering_style)
171
171
  end
172
172
 
@@ -174,7 +174,7 @@ describe HexaPDF::Document::Pages do
174
174
  @doc.catalog[:PageLabels] = {Nums: [0, {S: :r}, 2, {S: :d}, 7, {S: :A}]}
175
175
  result = @doc.pages.each_labelling_range.to_a
176
176
  assert_equal([[0, 2, {S: :r}], [2, 5, {S: :d}], [7, 3, {S: :A}]],
177
- result.map {|s, c, l| [s, c, l.value]})
177
+ result.map {|s, c, l| [s, c, l.value] })
178
178
  end
179
179
 
180
180
  it "returns a zero or negative count for the last range if there aren't enough pages" do
@@ -19,17 +19,37 @@ describe HexaPDF::Document::Signatures do
19
19
  )
20
20
  end
21
21
 
22
- describe "DefaultHandler" do
23
- it "returns the filter name" do
24
- assert_equal(:'Adobe.PPKLite', @handler.filter_name)
25
- end
26
-
27
- it "returns the sub filter algorithm name" do
28
- assert_equal(:'adbe.pkcs7.detached', @handler.sub_filter_name)
22
+ it "allows embedding an external signature value" do
23
+ doc = HexaPDF::Document.new(io: StringIO.new(MINIMAL_PDF))
24
+ io = StringIO.new(''.b)
25
+ doc.signatures.add(io, @handler)
26
+ doc = HexaPDF::Document.new(io: io)
27
+ io = StringIO.new(''.b)
28
+
29
+ byte_range = nil
30
+ @handler.signature_size = 5000
31
+ @handler.external_signing = proc {|_, br| byte_range = br; "" }
32
+ doc.signatures.add(io, @handler)
33
+
34
+ io.pos = byte_range[0]
35
+ data = io.read(byte_range[1])
36
+ io.pos = byte_range[2]
37
+ data << io.read(byte_range[3])
38
+ contents = OpenSSL::PKCS7.sign(@handler.certificate, @handler.key, data, @handler.certificate_chain,
39
+ OpenSSL::PKCS7::DETACHED | OpenSSL::PKCS7::BINARY).to_der
40
+ HexaPDF::Document::Signatures.embed_signature(io, contents)
41
+ doc = HexaPDF::Document.new(io: io)
42
+ assert_equal(2, doc.signatures.each.count)
43
+ doc.signatures.each do |signature|
44
+ assert(signature.verify(allow_self_signed: true).messages.find {|m| m.content == 'Signature valid' })
29
45
  end
46
+ end
30
47
 
48
+ describe "DefaultHandler" do
31
49
  it "returns the size of serialized signature" do
32
50
  assert_equal(1310, @handler.signature_size)
51
+ @handler.signature_size = 100
52
+ assert_equal(100, @handler.signature_size)
33
53
  end
34
54
 
35
55
  it "allows setting the DocMDP permissions" do
@@ -56,16 +76,23 @@ describe HexaPDF::Document::Signatures do
56
76
  assert_raises(ArgumentError) { @handler.doc_mdp_permissions = :other }
57
77
  end
58
78
 
59
- it "can sign the data using PKCS7" do
60
- data = "data"
61
- store = OpenSSL::X509::Store.new
62
- store.add_cert(CERTIFICATES.ca_certificate)
79
+ describe "sign" do
80
+ it "can sign the data using PKCS7" do
81
+ data = StringIO.new("data")
82
+ store = OpenSSL::X509::Store.new
83
+ store.add_cert(CERTIFICATES.ca_certificate)
84
+
85
+ pkcs7 = OpenSSL::PKCS7.new(@handler.sign(data, [0, 4, 0, 0]))
86
+ assert(pkcs7.detached?)
87
+ assert_equal([CERTIFICATES.signer_certificate, CERTIFICATES.ca_certificate],
88
+ pkcs7.certificates)
89
+ assert(pkcs7.verify([], store, data.string, OpenSSL::PKCS7::DETACHED | OpenSSL::PKCS7::BINARY))
90
+ end
63
91
 
64
- pkcs7 = OpenSSL::PKCS7.new(@handler.sign(data))
65
- assert(pkcs7.detached?)
66
- assert_equal([CERTIFICATES.signer_certificate, CERTIFICATES.ca_certificate],
67
- pkcs7.certificates)
68
- assert(pkcs7.verify([], store, data, OpenSSL::PKCS7::DETACHED | OpenSSL::PKCS7::BINARY))
92
+ it "can use external signing" do
93
+ @handler.external_signing = proc { "hallo" }
94
+ assert_equal("hallo", @handler.sign(StringIO.new, [0, 0, 0, 0]))
95
+ end
69
96
  end
70
97
 
71
98
  describe "finalize_objects" do
@@ -80,6 +107,12 @@ describe HexaPDF::Document::Signatures do
80
107
  assert(@obj.empty?)
81
108
  end
82
109
 
110
+ it "adjust the /SubFilter if signature type is etsi" do
111
+ @handler.signature_type = :etsi
112
+ @handler.finalize_objects(@field, @obj)
113
+ assert_equal(:'ETSI.CAdES.detached', @obj[:SubFilter])
114
+ end
115
+
83
116
  it "sets the reason, location and contact info fields" do
84
117
  @handler.reason = 'Reason'
85
118
  @handler.location = 'Location'
@@ -111,6 +144,87 @@ describe HexaPDF::Document::Signatures do
111
144
  end
112
145
  end
113
146
 
147
+ describe "TimestampHandler" do
148
+ before do
149
+ @handler = HexaPDF::Document::Signatures::TimestampHandler.new
150
+ end
151
+
152
+ it "allows setting the attributes in the constructor" do
153
+ handler = HexaPDF::Document::Signatures::TimestampHandler.new(
154
+ tsa_url: "url", tsa_hash_algorithm: "MD5", tsa_policy_id: "5",
155
+ reason: "Reason", location: "Location", contact_info: "Contact",
156
+ signature_size: 1_000
157
+ )
158
+ assert_equal("url", handler.tsa_url)
159
+ assert_equal("MD5", handler.tsa_hash_algorithm)
160
+ assert_equal("5", handler.tsa_policy_id)
161
+ assert_equal("Reason", handler.reason)
162
+ assert_equal("Location", handler.location)
163
+ assert_equal("Contact", handler.contact_info)
164
+ assert_equal(1_000, handler.signature_size)
165
+ end
166
+
167
+ it "finalizes the signature field and signature objects" do
168
+ @field = @doc.wrap({})
169
+ @sig = @doc.wrap({})
170
+ @handler.reason = 'Reason'
171
+ @handler.location = 'Location'
172
+ @handler.contact_info = 'Contact'
173
+
174
+ @handler.finalize_objects(@field, @sig)
175
+ assert_equal('2.0', @doc.version)
176
+ assert_equal(:DocTimeStamp, @sig[:Type])
177
+ assert_equal(:'ETSI.RFC3161', @sig[:SubFilter])
178
+ assert_equal('Reason', @sig[:Reason])
179
+ assert_equal('Location', @sig[:Location])
180
+ assert_equal('Contact', @sig[:ContactInfo])
181
+ end
182
+
183
+ it "returns the size of serialized signature" do
184
+ @handler.tsa_url = "http://127.0.0.1:34567"
185
+ CERTIFICATES.start_tsa_server
186
+ assert_equal(1420, @handler.signature_size)
187
+ end
188
+
189
+ describe "sign" do
190
+ before do
191
+ @data = StringIO.new("data")
192
+ @range = [0, 4, 0, 0]
193
+ @handler.tsa_url = "http://127.0.0.1:34567"
194
+ CERTIFICATES.start_tsa_server
195
+ end
196
+
197
+ it "respects the set hash algorithm and policy id" do
198
+ @handler.tsa_hash_algorithm = 'SHA256'
199
+ @handler.tsa_policy_id = '1.2.3.4.2'
200
+ token = OpenSSL::ASN1.decode(@handler.sign(@data, @range))
201
+ content = OpenSSL::ASN1.decode(token.value[1].value[0].value[2].value[1].value[0].value)
202
+ policy_id = content.value[1].value
203
+ digest_algorithm = content.value[2].value[0].value[0].value
204
+ assert_equal('SHA256', digest_algorithm)
205
+ assert_equal("1.2.3.4.2", policy_id)
206
+ end
207
+
208
+ it "returns the serialized timestamp token" do
209
+ token = OpenSSL::PKCS7.new(@handler.sign(@data, @range))
210
+ assert_equal(CERTIFICATES.ca_certificate.subject, token.signers[0].issuer)
211
+ assert_equal(CERTIFICATES.timestamp_certificate.serial, token.signers[0].serial)
212
+ end
213
+
214
+ it "fails if the timestamp token could not be created" do
215
+ @handler.tsa_hash_algorithm = 'SHA1'
216
+ msg = assert_raises(HexaPDF::Error) { @handler.sign(@data, @range) }
217
+ assert_match(/BAD_ALG/, msg.message)
218
+ end
219
+
220
+ it "fails if the timestamp server couldn't process the request" do
221
+ @handler.tsa_policy_id = '1.2.3.4.1'
222
+ msg = assert_raises(HexaPDF::Error) { @handler.sign(@data, @range) }
223
+ assert_match(/Invalid TSA server response/, msg.message)
224
+ end
225
+ end
226
+ end
227
+
114
228
  it "iterates over all signature dictionaries" do
115
229
  assert_equal([], @doc.signatures.to_a)
116
230
  @sig1.field_value = :sig1
@@ -164,7 +278,7 @@ describe HexaPDF::Document::Signatures do
164
278
  sig = @doc.signatures.first
165
279
  assert_equal(:'Adobe.PPKLite', sig[:Filter])
166
280
  assert_equal(:'adbe.pkcs7.detached', sig[:SubFilter])
167
- assert_equal([0, 968, 3590, 2517], sig[:ByteRange].value)
281
+ assert_equal([0, 996, 3618, 2501], sig[:ByteRange].value)
168
282
  assert_equal(:sig, sig[:key])
169
283
  assert_equal(:sig_field, @doc.acro_form.each_field.first[:key])
170
284
  assert(sig.key?(:Contents))
@@ -205,14 +319,14 @@ describe HexaPDF::Document::Signatures do
205
319
  it "handles different xref section types correctly when determing the offsets" do
206
320
  @doc.delete(7)
207
321
  sig = @doc.signatures.add(@io, @handler, write_options: {update_fields: false})
208
- assert_equal([0, 968, 3590, 2491], sig[:ByteRange].value)
322
+ assert_equal([0, 988, 3610, 2483], sig[:ByteRange].value)
209
323
  end
210
324
 
211
325
  it "works if the signature object is the last object of the xref section" do
212
326
  field = @doc.acro_form(create: true).create_signature_field('Signature2')
213
327
  field.create_widget(@doc.pages[0], Rect: [0, 0, 0, 0])
214
328
  sig = @doc.signatures.add(@io, @handler, signature: field, write_options: {update_fields: false})
215
- assert_equal([0, 3063, 5685, 400], sig[:ByteRange].value)
329
+ assert_equal([0, 3095, 5717, 380], sig[:ByteRange].value)
216
330
  end
217
331
 
218
332
  it "allows writing to a file in addition to writing to an IO" do
@@ -228,5 +342,11 @@ describe HexaPDF::Document::Signatures do
228
342
  signed_doc = HexaPDF::Document.new(io: @io)
229
343
  assert(signed_doc.signatures.first.verify)
230
344
  end
345
+
346
+ it "fails if the reserved signature space is too small" do
347
+ def @handler.signature_size; 200; end
348
+ msg = assert_raises(HexaPDF::Error) { @doc.signatures.add(@io, @handler) }
349
+ assert_match(/space.*too small.*200 vs/, msg.message)
350
+ end
231
351
  end
232
352
  end
@@ -122,7 +122,7 @@ describe HexaPDF::Encryption::AES do
122
122
  [4, 20, 40].each do |length|
123
123
  assert_raises(HexaPDF::EncryptionError) do
124
124
  collector(@algorithm_class.decryption_fiber('some' * 4,
125
- Fiber.new { 'a' * length }))
125
+ Fiber.new { 'a' * length }))
126
126
  end
127
127
  end
128
128
  end