From ab35b987810a9d4f3989c782bf10e47efdbef3d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Co=C3=AAlho?= Date: Thu, 19 Oct 2023 18:47:44 +0000 Subject: [PATCH 1/6] Add test helpers --- test/rubrik/sign_test.rb | 4 +--- test/rubrik_test.rb | 2 +- test/test_helper.rb | 15 +++++++++++++++ 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/test/rubrik/sign_test.rb b/test/rubrik/sign_test.rb index 720e17f..3916b1e 100644 --- a/test/rubrik/sign_test.rb +++ b/test/rubrik/sign_test.rb @@ -2,11 +2,9 @@ # typed: true require "test_helper" -require "stringio" module Rubrik - class SignTest < Minitest::Test - def test_call + class SignTest < Rubrik::Test # Arrange input_pdf = File.open("test/support/simple.pdf", "rb") output_pdf = StringIO.new diff --git a/test/rubrik_test.rb b/test/rubrik_test.rb index 4626839..8f444d9 100644 --- a/test/rubrik_test.rb +++ b/test/rubrik_test.rb @@ -2,7 +2,7 @@ require "test_helper" -class RubrikTest < Minitest::Test +class RubrikTest < Rubrik::Test def test_that_it_has_a_version_number refute_nil ::Rubrik::VERSION end diff --git a/test/test_helper.rb b/test/test_helper.rb index edc2452..4e8438c 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -9,3 +9,18 @@ require "minitest/autorun" require "minitest/pride" + +class Rubrik::Test < Minitest::Test + make_my_diffs_pretty! + parallelize_me! +end + +module SupportPDF + extend self + + SUPPORT_PATH = Pathname.new(__dir__).join("support").freeze + + def [](arg) + SUPPORT_PATH.join("#{arg}.pdf") + end +end From fd88a88889aca89dd45e98609a5bf922887a0d10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Co=C3=AAlho?= Date: Thu, 19 Oct 2023 18:49:34 +0000 Subject: [PATCH 2/6] Add "integration" tests --- test/rubrik/sign_test.rb | 62 ++++++++++++++++-- .../with_interactive_form.expected.pdf | Bin 0 -> 9325 bytes test/support/with_interactive_form.pdf | Bin 0 -> 534 bytes .../without_interactive_form.expected.pdf | Bin 0 -> 9338 bytes ...imple.pdf => without_interactive_form.pdf} | Bin 5 files changed, 57 insertions(+), 5 deletions(-) create mode 100644 test/support/with_interactive_form.expected.pdf create mode 100644 test/support/with_interactive_form.pdf create mode 100644 test/support/without_interactive_form.expected.pdf rename test/support/{simple.pdf => without_interactive_form.pdf} (100%) diff --git a/test/rubrik/sign_test.rb b/test/rubrik/sign_test.rb index 3916b1e..09f526b 100644 --- a/test/rubrik/sign_test.rb +++ b/test/rubrik/sign_test.rb @@ -5,8 +5,9 @@ module Rubrik class SignTest < Rubrik::Test + def test_document_with_interactive_form # Arrange - input_pdf = File.open("test/support/simple.pdf", "rb") + input_pdf = File.open(SupportPDF["with_interactive_form"], "rb") output_pdf = StringIO.new certificate = File.open("test/support/demo_cert.pem", "rb") @@ -18,11 +19,62 @@ class SignTest < Rubrik::Test Sign.call(input_pdf, output_pdf, private_key:, public_key:) # Assert - output_pdf.rewind + expected_output = File.open(SupportPDF["with_interactive_form.expected"], "rb") - output_pdf.close - input_pdf.close - certificate.close + expected_output.readlines.zip(output_pdf.readlines).each do |(expected_line, actual_line)| + # We must erase the signature because it is timestampped + if T.must(actual_line).match?("/Type /Sig") + T.must(actual_line).sub!(/<[a-f0-9]+>/, "") + T.must(expected_line).sub!(/<[a-f0-9]+>/, "") + # The signature field name is also random + elsif T.must(actual_line).match?("Signature-[a-f0-9]{4}") + T.must(actual_line).sub!(/Signature-[a-f0-9]{4}/, "") + T.must(expected_line).sub!(/Signature-[a-f0-9]{4}/, "") + end + + assert_equal(expected_line, actual_line) + end + ensure + certificate&.close + output_pdf&.close + input_pdf&.close + expected_output&.close + end + + def test_document_without_interactive_form + # Arrange + input_pdf = File.open(SupportPDF["without_interactive_form"], "rb") + output_pdf = StringIO.new + certificate = File.open("test/support/demo_cert.pem", "rb") + + private_key = OpenSSL::PKey::RSA.new(certificate, "") + certificate.rewind + public_key = OpenSSL::X509::Certificate.new(certificate) + + # Act + Sign.call(input_pdf, output_pdf, private_key:, public_key:) + + # Assert + expected_output = File.open(SupportPDF["without_interactive_form.expected"], "rb") + + expected_output.readlines.zip(output_pdf.readlines).each do |(expected_line, actual_line)| + # We can't verify the signature because it changes on every run + if T.must(actual_line).match?("/Type /Sig") + T.must(actual_line).sub!(/<[a-f0-9]+>/, "") + T.must(expected_line).sub!(/<[a-f0-9]+>/, "") + # The signature field name is also random + elsif T.must(actual_line).match?("Signature-[a-f0-9]{4}") + T.must(actual_line).sub!(/Signature-[a-f0-9]{4}/, "") + T.must(expected_line).sub!(/Signature-[a-f0-9]{4}/, "") + end + + assert_equal(expected_line, actual_line) + end + ensure + certificate&.close + output_pdf&.close + input_pdf&.close + expected_output&.close end end end diff --git a/test/support/with_interactive_form.expected.pdf b/test/support/with_interactive_form.expected.pdf new file mode 100644 index 0000000000000000000000000000000000000000..9760c663190c15437cd8892292840f7010507f07 GIT binary patch literal 9325 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 M68^W;-CZmB7jr0UqyPW_ literal 0 HcmV?d00001 diff --git a/test/support/with_interactive_form.pdf b/test/support/with_interactive_form.pdf new file mode 100644 index 0000000000000000000000000000000000000000..5aef2b39a88275fbab0b4b4c046ffc759228dea2 GIT binary patch literal 534 zcmZWmO;5ux487-9_|hY3nl}BArb!Ga2S7|6w~9lcp%iruN`o}`_1L9sqF9QQJo~+8 zJHhs{Jd1e7A`oDo-q>mtK776#2rsnPy=@`f>eh@PF<(J={_JdN-5wOZ*K1~mhG>-j zL;qogH(fJA9aHfmYTA#%Lv*51;vbDHox#F-GDY5ll*DY=iJrB;jFaaqO$ADC+SOLPnQNVPA;Rs;|3n-G= u62cyrP%LqF3e@sWcfE1+vAgceKnh`HE&js1tkG-dr~YD9upqd)FWEPh>W7m6 literal 0 HcmV?d00001 diff --git a/test/support/without_interactive_form.expected.pdf b/test/support/without_interactive_form.expected.pdf new file mode 100644 index 0000000000000000000000000000000000000000..df1e996675f2857b2221ad89af02b27cbb4530c4 GIT binary patch literal 9338 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 L!n28Ze^TY2^iFMz literal 0 HcmV?d00001 diff --git a/test/support/simple.pdf b/test/support/without_interactive_form.pdf similarity index 100% rename from test/support/simple.pdf rename to test/support/without_interactive_form.pdf From 42fc30a4be04c7a22da48f73db81a952da2952e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Co=C3=AAlho?= Date: Fri, 20 Oct 2023 02:35:45 +0000 Subject: [PATCH 3/6] Improve fixtures --- .../with_interactive_form.expected.pdf | Bin 9325 -> 9372 bytes test/support/with_interactive_form.pdf | Bin 534 -> 561 bytes .../without_interactive_form.expected.pdf | Bin 9338 -> 9334 bytes test/support/without_interactive_form.pdf | Bin 464 -> 462 bytes 4 files changed, 0 insertions(+), 0 deletions(-) diff --git a/test/support/with_interactive_form.expected.pdf b/test/support/with_interactive_form.expected.pdf index 9760c663190c15437cd8892292840f7010507f07..12db41a3ec4c47d60746e35bed5aa7b8f535c2b0 100644 GIT binary patch delta 238 zcmaFsF~@VlTvj6m1BIZ;rOJ8}FHMqH(DzGCO>rzJNKGtC%u7x!RwyYdO|`S*O3h2j zPs*B{#uz7RXrW*L0t$ItVA{aI*vM$|O-2(Y3$w}mOuAy&4cTnNRHVRT4%BU7X=tQi zVrDRTm(muv^@a8pmT+@T6^vnKo0v@wJ=5# ovamEVnjE5{CuM4BXUA1sl2}wyQIwj-Woc+?z@@6{>hHz{01ufjtpET3 diff --git a/test/support/with_interactive_form.pdf b/test/support/with_interactive_form.pdf index 5aef2b39a88275fbab0b4b4c046ffc759228dea2..97a0b15ab502a8aeb87cba6951fb0ac15075127c 100644 GIT binary patch delta 87 zcmbQnvXN!NTvj6m1BIZ8OAg2@==-InrZ^T9q$U<6<|U^VE0h$KrrOzYrRJsNCuL1e lV~i6uv`{br0fjs+Fl}I9Y-BX~CZh?bg&CKss;j>n7XZ`K8ASj9 delta 60 zcmdnUGL2=zT=r-q1p|ek*olh{FxlBnZe)xTH8M~z00D(OE--CiU~FVE`3s{7r>P~E Ks;aBM8y5f!G!KUW diff --git a/test/support/without_interactive_form.expected.pdf b/test/support/without_interactive_form.expected.pdf index df1e996675f2857b2221ad89af02b27cbb4530c4..22b5732f23e5c12c57dbb7cb9b068118ba5d42ad 100644 GIT binary patch delta 156 zcmez6@y%mGFRPJ)fkM#ad}Y0fhb}N0O`gnX%4oLvB;!K`E<*(i3sX}C6LZVSTa>oI z73MJ-PQIrsW(U*=0t$ItrV7SfV77sQiJ3867%F6HZi*&kVQFYG*+@lS(#+0|tGFbw VsHCDOHH`}>YQUwc>gw;t1pu}dD186` delta 164 zcmez7@ylaEFMG6+f`LL%?Br}^y@>}dFq%xB$Y{!FvH3XTLj^8l1q%yPa|II%!^xYJ zwje3ZlQ32=00D(Opdv#qFx$Y=z}RH+M`bZ}Q@A8V!obwr98Ji=($EZOmE>f16){N* cJ3Fr8lEk8tilWpsE=xlb6E0O%SARDy05~HnJpcdz diff --git a/test/support/without_interactive_form.pdf b/test/support/without_interactive_form.pdf index 45259aa65638beecc09d28b539c669591f2404ff..29e95d6a6a91f96a1dc93b5c422c88f759e7083e 100644 GIT binary patch delta 30 mcmcb>e2#fSFRPJ)fkM#4DgPLaCQoKGTmWHjZl;8Im}^>^a}0JKsGod5s; From 483e9b7a2034148f10da291bfe781946d82d7e52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Co=C3=AAlho?= Date: Fri, 20 Oct 2023 03:04:34 +0000 Subject: [PATCH 4/6] Refactor code based on tests --- lib/rubrik.rb | 1 - lib/rubrik/document.rb | 29 ++++++++--------------------- lib/rubrik/document/increment.rb | 25 ++++++++++++++----------- lib/rubrik/fill_signature.rb | 5 ++--- lib/rubrik/sign.rb | 3 +++ 5 files changed, 27 insertions(+), 36 deletions(-) diff --git a/lib/rubrik.rb b/lib/rubrik.rb index 1f67254..cf4a487 100644 --- a/lib/rubrik.rb +++ b/lib/rubrik.rb @@ -3,7 +3,6 @@ require "sorbet-runtime" require "pdf-reader" -require "irb" module Rubrik class Error < StandardError; end diff --git a/lib/rubrik/document.rb b/lib/rubrik/document.rb index 270ad08..e8dd5fd 100644 --- a/lib/rubrik/document.rb +++ b/lib/rubrik/document.rb @@ -12,19 +12,18 @@ class Document SIGNATURE_SIZE = 8_192 sig {returns(T.any(File, Tempfile, StringIO))} - attr_reader :io + attr_accessor :io sig {returns(PDF::Reader::ObjectHash)} - attr_reader :objects + attr_accessor :objects sig {returns(T::Array[{id: PDF::Reader::Reference, value: T.untyped}])} - attr_reader :modified_objects - - sig {returns(PDF::Reader::Reference)} - attr_reader :interactive_form_id + attr_accessor :modified_objects sig {returns(Integer)} - attr_reader :last_object_id + attr_accessor :last_object_id + + private :io=, :objects=, :modified_objects=, :last_object_id= sig {params(input: T.any(File, Tempfile, StringIO)).void} def initialize(input) @@ -63,7 +62,7 @@ def add_signature_field V: signature_value_id, Type: :Annot, Subtype: :Widget, - Rect: [20, 20, 120, 120], + Rect: [0, 0, 0, 0], F: 4, P: first_page_reference } @@ -87,7 +86,7 @@ def interactive_form sig {void} def fetch_or_create_interactive_form! root_ref = objects.trailer[:Root] - root = T.let(objects.fetch(root_ref), Hash) + root = T.let(objects.fetch(root_ref), T::Hash[Symbol, T.untyped]) if root.key?(:AcroForm) form_id = root[:AcroForm] @@ -111,17 +110,5 @@ def fetch_or_create_interactive_form! def assign_new_object_id! PDF::Reader::Reference.new(self.last_object_id += 1, 0) end - - sig {params(io: T.any(File, Tempfile, StringIO)).returns(T.any(File, Tempfile, StringIO))} - attr_writer :io - - sig {params(objects: PDF::Reader::ObjectHash).returns(PDF::Reader::ObjectHash)} - attr_writer :objects - - sig {params(modified_objects: T::Array[{id: PDF::Reader::Reference, value: T.untyped}]).returns(T::Array[{id: PDF::Reader::Reference, value: T.untyped}])} - attr_writer :modified_objects - - sig {params(last_object_id: Integer).returns(Integer)} - attr_writer :last_object_id end end diff --git a/lib/rubrik/document/increment.rb b/lib/rubrik/document/increment.rb index cd79f6c..b9783f3 100644 --- a/lib/rubrik/document/increment.rb +++ b/lib/rubrik/document/increment.rb @@ -9,13 +9,15 @@ module Increment extend T::Sig extend self - sig {params(document: Rubrik::Document, io: T.any(File, Tempfile, StringIO)).returns(T.any(File, Tempfile, StringIO))} + sig {params(document: Rubrik::Document, io: T.any(File, Tempfile, StringIO)).void} def call(document, io:) document.io.rewind - IO.copy_stream(T.unsafe(document.io), T.unsafe(io)) + IO.copy_stream(document.io, io) io << "\n" - new_xref = Array.new + + new_xref = T.let([], T::Array[T::Hash[Symbol, Integer]]) + new_xref << {id: 0} document.modified_objects.each do |object| integer_id = T.let(object[:id].to_i, Integer) @@ -31,18 +33,22 @@ def call(document, io:) new_xref_pos = io.pos new_xref_subsections = new_xref - .sort_by { _1[:id] } - .chunk_while { _1[:id] + 1 == _2[:id] } + .sort_by { |entry| entry.fetch(:id) } + .chunk_while { _1.fetch(:id) + 1 == _2[:id] } io << "xref\n" - io << "0 1\n" - io << "0000000000 65535 f\n" new_xref_subsections.each do |subsection| - starting_id = subsection.first[:id] + starting_id = T.must(subsection.first).fetch(:id) length = subsection.length io << "#{starting_id} #{length}\n" + + if starting_id.zero? + io << "0000000000 65535 f\n" + subsection.shift + end + subsection.each { |entry| io << "#{format("%010d", entry[:offset])} 00000 n\n" } end @@ -51,9 +57,6 @@ def call(document, io:) io << "startxref\n" io << "#{new_xref_pos.to_s}\n" io << "%%EOF\n" - - io.rewind - io end private diff --git a/lib/rubrik/fill_signature.rb b/lib/rubrik/fill_signature.rb index 61d2814..d739ad5 100644 --- a/lib/rubrik/fill_signature.rb +++ b/lib/rubrik/fill_signature.rb @@ -15,7 +15,7 @@ module FillSignature private_key: OpenSSL::PKey::RSA, public_key: OpenSSL::X509::Certificate, certificate_chain: T::Array[OpenSSL::X509::Certificate]) - .returns(T.any(File, StringIO, Tempfile))} + .void} FIRST_OFFSET = 0 @@ -29,8 +29,7 @@ def call(io, signature_value_ref:, private_key:, public_key:, certificate_chain: io.gets("<") first_length = io.pos - 1 - # we need to double the SIGNATURE_SIZE because the hex encoding double the data size - # we also need to sum +2 to account for "<" and ">" of the hex string + # We need to sum +2 to account for "<" and ">" of the hex string second_offset = first_length + Document::SIGNATURE_SIZE + 2 second_length = io.size - second_offset diff --git a/lib/rubrik/sign.rb b/lib/rubrik/sign.rb index 4406837..4a84a63 100644 --- a/lib/rubrik/sign.rb +++ b/lib/rubrik/sign.rb @@ -13,6 +13,9 @@ module Sign certificate_chain: T::Array[OpenSSL::X509::Certificate]) .void} def self.call(input, output, private_key:, public_key:, certificate_chain: []) + input.binmode + output.reopen(T.unsafe(output), "wb+") if !output.is_a?(StringIO) + document = Rubrik::Document.new(input) document.add_signature_field From bd774284a704b079c0ebd1d1502135b19b567f75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Co=C3=AAlho?= Date: Fri, 20 Oct 2023 19:32:03 +0000 Subject: [PATCH 5/6] Test `Document` and refactor --- lib/rubrik/document.rb | 8 ++- lib/rubrik/sign.rb | 5 +- test/rubrik/document_test.rb | 104 +++++++++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 6 deletions(-) create mode 100644 test/rubrik/document_test.rb diff --git a/lib/rubrik/document.rb b/lib/rubrik/document.rb index e8dd5fd..f300bdb 100644 --- a/lib/rubrik/document.rb +++ b/lib/rubrik/document.rb @@ -35,9 +35,11 @@ def initialize(input) fetch_or_create_interactive_form! end - sig {void} + sig {returns(PDF::Reader::Reference)} + # Returns the reference of the Signature Value dictionary. def add_signature_field - # create signature value dictionary + # To add an signature to the PDF, we need the following structure + # Interactive Form -> Signature Field -> Signature Value signature_value_id = assign_new_object_id! modified_objects << { id: signature_value_id, @@ -74,6 +76,8 @@ def add_signature_field modified_objects << {id: first_page_reference, value: modified_page} (interactive_form[:Fields] ||= []) << signature_field_id + + signature_value_id end private diff --git a/lib/rubrik/sign.rb b/lib/rubrik/sign.rb index 4a84a63..f2e6df4 100644 --- a/lib/rubrik/sign.rb +++ b/lib/rubrik/sign.rb @@ -18,13 +18,10 @@ def self.call(input, output, private_key:, public_key:, certificate_chain: []) document = Rubrik::Document.new(input) - document.add_signature_field + signature_value_ref = document.add_signature_field Document::Increment.call(document, io: output) - signature_value = T.must(document.modified_objects.find { _1.dig(:value, :Type) == :Sig }) - - signature_value_ref = T.let(signature_value[:id], PDF::Reader::Reference) FillSignature.call(output, signature_value_ref:, private_key:, public_key:, certificate_chain:) end end diff --git a/test/rubrik/document_test.rb b/test/rubrik/document_test.rb new file mode 100644 index 0000000..f46d377 --- /dev/null +++ b/test/rubrik/document_test.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true +# typed: true + +require "test_helper" + +module Rubrik + class DocumentTest < Rubrik::Test + def test_initialize_document_without_interactive_form + # Arrange + input = File.open(SupportPDF["without_interactive_form"], "rb") + + # Act + document = Document.new(input) + + # Assert + assert_equal(input, document.send(:io)) + # FACT: the interactive form was created + assert_equal(5, document.last_object_id) + assert_kind_of(PDF::Reader::ObjectHash, document.objects) + + acro_form = document.modified_objects.find { |obj| obj.dig(:value, :Type) == :Catalog } + acro_form_reference = T.must(acro_form).dig(:value, :AcroForm) + assert_pattern do + document.modified_objects => [*, {id: ^acro_form_reference, value: {Fields: [], SigFlags: 3}}, *] + end + ensure + input&.close + end + + def test_initialize_document_with_interactive_form + # Arrange + input = File.open(SupportPDF["with_interactive_form"], "rb") + + # Act + document = Document.new(input) + + # Assert + assert_equal(input, document.send(:io)) + assert_equal(5, document.last_object_id) + assert_kind_of(PDF::Reader::ObjectHash, document.objects) + + assert_pattern do + document.modified_objects => [{ + id: PDF::Reader::Reference, value: {Fields: [], SigFlags: 3, NeedAppearances: true} + }] + end + ensure + input&.close + end + + def test_add_signature_field + # Arrange + input = File.open(SupportPDF["with_interactive_form"], "rb") + document = Document.new(input) + initial_number_of_objects = document.modified_objects.size + + # Act + result = document.add_signature_field + + # Assert + number_of_added_objects = document.modified_objects.size - initial_number_of_objects + assert_equal(3, number_of_added_objects) + + assert_pattern do + signature_value = document.modified_objects.find { _1[:id] == result } + signature_value => {id: ^result, + value: { + Type: :Sig, + Filter: :"Adobe.PPKLite", + SubFilter: :"adbe.pkcs7.detached", + Contents: Document::CONTENTS_PLACEHOLDER, + ByteRange: Document::BYTE_RANGE_PLACEHOLDER + } + } + end + + signature_field = document.modified_objects.find { _1.dig(:value, :FT) == :Sig } + assert_pattern do + first_page_reference = document.objects.page_references[0] + signature_field => { + id: PDF::Reader::Reference, + value: { + T: /Signature-\w{4}/, + V: ^result, + Type: :Annot, + Subtype: :Widget, + Rect: [0, 0, 0, 0], + F: 4, + P: ^first_page_reference + } + } + end + + signature_field_id = T.must(signature_field)[:id] + + first_page = document.modified_objects.find { _1.dig(:value, :Type) == :Page } + assert_pattern { first_page => {value: {Annots: [*, ^signature_field_id, *]}}} + + assert_pattern { document.send(:interactive_form) => {Fields: [*, ^signature_field_id, *]} } + ensure + input&.close + end + end +end From 769d4b52e7b4fa9753d7edc36234153681e59482 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Co=C3=AAlho?= Date: Fri, 20 Oct 2023 19:32:38 +0000 Subject: [PATCH 6/6] Extract `Document::SerializeObject` to test it --- lib/rubrik.rb | 1 + lib/rubrik/document/increment.rb | 44 +------ lib/rubrik/document/serialize_object.rb | 53 +++++++++ test/rubrik/document/serialize_object_test.rb | 110 ++++++++++++++++++ 4 files changed, 167 insertions(+), 41 deletions(-) create mode 100644 lib/rubrik/document/serialize_object.rb create mode 100644 test/rubrik/document/serialize_object_test.rb diff --git a/lib/rubrik.rb b/lib/rubrik.rb index cf4a487..08105d1 100644 --- a/lib/rubrik.rb +++ b/lib/rubrik.rb @@ -10,5 +10,6 @@ class Error < StandardError; end require_relative "rubrik/document" require_relative "rubrik/document/increment" +require_relative "rubrik/document/serialize_object" require_relative "rubrik/fill_signature" require_relative "rubrik/sign" diff --git a/lib/rubrik/document/increment.rb b/lib/rubrik/document/increment.rb index b9783f3..831e865 100644 --- a/lib/rubrik/document/increment.rb +++ b/lib/rubrik/document/increment.rb @@ -23,7 +23,8 @@ def call(document, io:) integer_id = T.let(object[:id].to_i, Integer) new_xref << {id: integer_id, offset: io.pos} - io << "#{integer_id} 0 obj\n" "#{serialize(object[:value])}\n" "endobj\n\n" + value = object[:value] + io << "#{integer_id} 0 obj\n" "#{SerializeObject[value]}\n" "endobj\n\n" end updated_trailer = document.objects.trailer.dup @@ -53,7 +54,7 @@ def call(document, io:) end io << "trailer\n" - io << "#{serialize(updated_trailer)}\n" + io << "#{SerializeObject[updated_trailer]}\n" io << "startxref\n" io << "#{new_xref_pos.to_s}\n" io << "%%EOF\n" @@ -61,45 +62,6 @@ def call(document, io:) private - sig {params(obj: T.untyped).returns(String)} - def serialize(obj) - case obj - when Hash - serialized_objs = obj.flatten.map { |e| serialize(e) } - "<<#{serialized_objs.join(" ")}>>" - when Symbol - "/#{obj}" - when Array - serialized_objs = obj.map { |e| serialize(e) } - "[#{serialized_objs.join(" ")}]" - when PDF::Reader::Reference - "#{obj.id} #{obj.gen} R" - when String - "(#{obj})" - when TrueClass - "true" - when FalseClass - "false" - when Document::CONTENTS_PLACEHOLDER - "<#{"0" * Document::SIGNATURE_SIZE}>" - when Document::BYTE_RANGE_PLACEHOLDER - "[0 0000000000 0000000000 0000000000]" - when Numeric - obj.to_s - when NilClass - "null" - when PDF::Reader::Stream - <<~OBJECT.chomp - #{serialize(obj.hash)} - stream - #{obj.data} - endstream - OBJECT - else - raise NotImplementedError.new("Don't know how to serialize #{obj}") - end - end - sig {params(document: Rubrik::Document).returns(Integer)} def last_xref_pos(document) PDF::Reader::Buffer.new(document.io, seek: 0).find_first_xref_offset diff --git a/lib/rubrik/document/serialize_object.rb b/lib/rubrik/document/serialize_object.rb new file mode 100644 index 0000000..2219e9c --- /dev/null +++ b/lib/rubrik/document/serialize_object.rb @@ -0,0 +1,53 @@ +# typed: true +# frozen_string_literal: true + +module Rubrik + class Document + module SerializeObject + include Kernel + extend T::Sig + extend self + + sig {params(obj: T.untyped).returns(String)} + def [](obj) + case obj + when Hash + serialized_objs = obj.flatten.map { |e| SerializeObject[e] } + "<<#{serialized_objs.join(" ")}>>" + when Symbol + "/#{obj}" + when Array + serialized_objs = obj.map { |e| SerializeObject[e] } + "[#{serialized_objs.join(" ")}]" + when PDF::Reader::Reference + "#{obj.id} #{obj.gen} R" + when String + "(#{obj})" + when TrueClass + "true" + when FalseClass + "false" + when Document::CONTENTS_PLACEHOLDER + "<#{"0" * Document::SIGNATURE_SIZE}>" + when Document::BYTE_RANGE_PLACEHOLDER + "[0 0000000000 0000000000 0000000000]" + when Float, Integer + obj.to_s + when NilClass + "null" + when PDF::Reader::Stream + <<~OBJECT.chomp + #{SerializeObject[obj.hash]} + stream + #{obj.data} + endstream + OBJECT + else + raise "Don't know how to serialize #{obj}" + end + end + + alias call [] + end + end +end diff --git a/test/rubrik/document/serialize_object_test.rb b/test/rubrik/document/serialize_object_test.rb new file mode 100644 index 0000000..92f9de9 --- /dev/null +++ b/test/rubrik/document/serialize_object_test.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true +# typed: true + +require "test_helper" + +class Rubrik::Document + class SerializeObjectTest < Rubrik::Test + def test_hash_serialization + # Arrange + hash = {Test: {Key: :Value, Array: []}} + + # Act + result = SerializeObject[hash] + + # Assert + assert_equal("<>>>", result) + end + + def test_symbol_serialization + # Act + result = SerializeObject[:Test] + + # Assert + assert_equal("/Test", result) + end + + def test_array_serialization + # Arrange + array = [0, [PDF::Reader::Reference.new(1, 0), {Key: :Value}]] + + # Act + result = SerializeObject[array] + + # Assert + assert_equal("[0 [1 0 R <>]]", result) + end + + def test_reference_serialization + # Act + result = SerializeObject[PDF::Reader::Reference.new(2, 1)] + + # Assert + assert_equal("2 1 R", result) + end + + def test_string_serialization + # Act + result = SerializeObject["Test"] + + # Assert + assert_equal("(Test)", result) + end + + def test_booleans_serialization + # Assert + assert_equal("true", SerializeObject[true]) + assert_equal("false", SerializeObject[false]) + end + + def test_contents_placeholder_serialization + # Assert + expected_result = "<#{"0" * 8_192}>" + assert_equal(expected_result, SerializeObject[CONTENTS_PLACEHOLDER]) + end + + def test_byte_range_placeholder_serialization + # Assert + assert_equal("[0 0000000000 0000000000 0000000000]", SerializeObject[BYTE_RANGE_PLACEHOLDER]) + end + + def test_numeric_serialization + # Assert + [ + ["30", SerializeObject.call(30)], + ["-30", SerializeObject.call(-30)], + ["-1.5", SerializeObject.call(-1.5)], + ["3.3", SerializeObject.call(3.3)] + ].each { |expectation| assert_equal(*expectation) } + end + + def test_nil_serialization + # Assert + assert_equal("null", SerializeObject[nil]) + end + + def test_stream_serialization + # Arrange + data = "Test Text\nTest x\x9C\xBDX[o\u00147\u0014\xB6\xB4HH\xF3\u0002)" + stream = PDF::Reader::Stream.new({Length: data.bytesize}, data) + + # Act + result = SerializeObject[stream] + + # Assert + assert_equal(<<~STREAM.chomp, result) + <> + stream + #{data} + endstream + STREAM + end + + def test_raise_on_unknown_object_serialization + # Assert + assert_raises(RuntimeError, "Don't know how to serialize Object") do + SerializeObject[Object.new] + end + end + end +end