easy_code_sign 0.1.0 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 795cefa5ad0f01177caeb61d8aaa3c910e6796a5d0f7caad13653a762ada80c1
4
- data.tar.gz: 4a65763e2aa2e0396c285c5bf3d75e3dc36697cf4e5f50b4489403a4e0789489
3
+ metadata.gz: 30b0230f9ff02c468a22aaae42998b4d7917138487fb9e650d00d76e424e61ac
4
+ data.tar.gz: 1f7ab49d94de297346fbc4ad929fffe05ad9d5b6b928dfbebb3afe987c2707ca
5
5
  SHA512:
6
- metadata.gz: 0ceaf7fcd326449fd314d493828ed33ca12b4c004f86e08cef72ab1f22212ce92525a4d9ee1aeed5f421dc8636c0504e7b78e75bb5530ef6da1e8aeda929002e
7
- data.tar.gz: 0e59683b775e2809b102022b0f16dd3ed142e1fdb1296c83162e1f2fc0747b75440189ea43372f07518f99d7e95e2040af19713ea79fecaf47f03bd011c0fbf3
6
+ metadata.gz: '00188cddd0c07ce157367eeea03d89dcbec4a527395be854ca5713b7b0d293babd645c1c458514d839f82fba7f1a51e379f975dc1627027791266e7f37c00c90'
7
+ data.tar.gz: a0f322b5af65a1f298f32725098a5f177b269b3064c2ae66d4f8556cc5ff7df934386cf5bec8b9050c8f64f3d12a7735b9537d7d1d4cba02a356cd31b1d8a4b2
data/CHANGELOG.md CHANGED
@@ -7,31 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-03-01
11
+
10
12
  ### Added
11
13
 
12
- - **PDF Document Signing**
13
- - Sign PDF documents (.pdf) with PKCS#7 digital signatures
14
- - Visible signature annotations with customizable position (top-left, top-right, bottom-left, bottom-right)
15
- - Signature metadata (reason, location, contact info)
16
- - Page selection for signature placement
17
- - RFC 3161 timestamp embedding in PDF signatures
18
- - ByteRange-based signing for PDF incremental updates
14
+ - **Native MIT-licensed PDF signing backend** (`NativeSigner` + `CmsBuilder`)
15
+ - ISO 32000 incremental-update signatures (`adbe.pkcs7.detached`) with no AGPL dependency
16
+ - `CmsBuilder`: assembles CMS `SignedData` via OpenSSL ASN.1 with a `sign_bytes(hash)` callback
17
+ interface — compatible with HSM/hardware-token providers that only expose raw signing
18
+ - `NativeSigner`: appends signature dict, AcroForm field, and updated catalog as a valid
19
+ PDF incremental update; computes ByteRange via raw file I/O
19
20
 
20
- - **PDF Verification**
21
- - Verify signed PDF documents
22
- - ByteRange integrity checking
23
- - Extract and display PDF signature metadata
21
+ ### Changed
24
22
 
25
- - **CLI Enhancements for PDF**
26
- - `--visible-signature` - Add visible signature annotation
27
- - `--signature-page` - Select page for signature
28
- - `--signature-position` - Position preset (top_left, top_right, bottom_left, bottom_right)
29
- - `--signature-reason` - Reason for signing
30
- - `--signature-location` - Signing location
23
+ - `pdf-reader` (~> 2.0, MIT) replaces `hexapdf` as the runtime dependency for PDF parsing
24
+ - `PdfFile#apply_signature` and `#extract_signature` use the native backend by default
31
25
 
32
- ### Dependencies
26
+ ### Removed
33
27
 
34
- - Added HexaPDF (~> 1.0) for PDF manipulation and signing
28
+ - **HexaPDF dependency** (AGPL) removed from both runtime and development dependencies.
29
+ easy_code_sign is now fully MIT-licensed throughout its dependency chain.
30
+ - Deferred signing API (`prepare_deferred`, `finalize_deferred`) — these were built on
31
+ HexaPDF internals and are removed with it.
35
32
 
36
33
  ## [0.1.0] - 2025-01-06
37
34
 
@@ -92,4 +89,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
92
89
  - Private keys never leave the hardware token
93
90
  - Certificate revocation checking enabled by default
94
91
 
92
+ [Unreleased]: https://github.com/mpantel/easy_code_sign/compare/v0.2.0...HEAD
93
+ [0.2.0]: https://github.com/mpantel/easy_code_sign/compare/v0.1.0...v0.2.0
95
94
  [0.1.0]: https://github.com/mpantel/easy_code_sign/releases/tag/v0.1.0
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module EasyCodeSign
6
+ module Pdf
7
+ # Builds a detached CMS SignedData structure for PDF signing (adbe.pkcs7.detached).
8
+ #
9
+ # This is a pure-OpenSSL/MIT implementation that does not require HexaPDF.
10
+ # It uses OpenSSL ASN.1 primitives to assemble the CMS structure and supports
11
+ # callback-based signing (HSM, hardware token) via a sign_bytes(hash) block.
12
+ #
13
+ # The block receives SHA256(signed_attrs_DER) and must return raw RSA signature bytes.
14
+ # This matches HexaPDF's external_signing convention so providers are interchangeable.
15
+ #
16
+ # @example
17
+ # builder = CmsBuilder.new(certificate: cert, certificate_chain: [])
18
+ # cms_der = builder.build(byterange_content) { |hash| private_key.sign_raw("SHA256", hash) }
19
+ #
20
+ class CmsBuilder
21
+ # OIDs used in PDF CMS signatures
22
+ OID_DATA = "1.2.840.113549.1.7.1" # id-data
23
+ OID_SIGNED_DATA = "1.2.840.113549.1.7.2" # id-signedData
24
+ OID_CONTENT_TYPE = "1.2.840.113549.1.9.3" # id-contentType
25
+ OID_MESSAGE_DIGEST = "1.2.840.113549.1.9.4" # id-messageDigest
26
+ OID_SIGNING_TIME = "1.2.840.113549.1.9.5" # id-signingTime
27
+ OID_SHA256 = "2.16.840.1.101.3.4.2.1" # id-sha256
28
+ OID_RSA = "1.2.840.113549.1.1.1" # rsaEncryption
29
+ OID_SHA256_RSA = "1.2.840.113549.1.1.11" # sha256WithRSAEncryption
30
+
31
+ # @param certificate [OpenSSL::X509::Certificate] signer certificate
32
+ # @param certificate_chain [Array<OpenSSL::X509::Certificate>] extra certs (intermediate CA etc.)
33
+ # @param signing_time [Time] time to embed in signed attributes (default: now)
34
+ def initialize(certificate:, certificate_chain: [], signing_time: nil)
35
+ @certificate = certificate
36
+ @certificate_chain = certificate_chain
37
+ @signing_time = signing_time || Time.now
38
+ end
39
+
40
+ # Build CMS SignedData DER for the given ByteRange content.
41
+ #
42
+ # @param data [String] concatenated ByteRange bytes from the PDF
43
+ # @yield [hash] SHA256 hash of the signed-attributes DER (binary String)
44
+ # @yieldreturn [String] raw RSA signature bytes (PKCS#1 v1.5)
45
+ # @return [String] DER-encoded CMS ContentInfo
46
+ def build(data)
47
+ raise ArgumentError, "sign_bytes block required" unless block_given?
48
+
49
+ message_digest = OpenSSL::Digest::SHA256.digest(data)
50
+ signed_attrs_der = build_signed_attrs_der(message_digest)
51
+
52
+ # Hash of the DER-encoded signed attributes SET — this is what sign_raw signs
53
+ hash = OpenSSL::Digest::SHA256.digest(signed_attrs_der)
54
+ signature = yield(hash)
55
+
56
+ build_content_info(signature, signed_attrs_der)
57
+ end
58
+
59
+ private
60
+
61
+ # Construct the signed attributes as a DER-encoded SET.
62
+ # The result is hashed and signed; then stored as [0] IMPLICIT in SignerInfo.
63
+ def build_signed_attrs_der(message_digest)
64
+ attrs = [
65
+ attribute(OID_CONTENT_TYPE, OpenSSL::ASN1::ObjectId.new(OID_DATA)),
66
+ attribute(OID_MESSAGE_DIGEST, OpenSSL::ASN1::OctetString.new(message_digest)),
67
+ attribute(OID_SIGNING_TIME, OpenSSL::ASN1::UTCTime.new(@signing_time))
68
+ ]
69
+ OpenSSL::ASN1::Set.new(attrs).to_der
70
+ end
71
+
72
+ # Build a CMS Attribute: SEQUENCE { OID, SET { value } }
73
+ def attribute(oid_str, value)
74
+ OpenSSL::ASN1::Sequence.new([
75
+ OpenSSL::ASN1::ObjectId.new(oid_str),
76
+ OpenSSL::ASN1::Set.new([value])
77
+ ])
78
+ end
79
+
80
+ # Build the top-level CMS ContentInfo wrapping SignedData
81
+ def build_content_info(signature, signed_attrs_der)
82
+ signed_data = build_signed_data(signature, signed_attrs_der)
83
+
84
+ OpenSSL::ASN1::Sequence.new([
85
+ OpenSSL::ASN1::ObjectId.new(OID_SIGNED_DATA),
86
+ OpenSSL::ASN1::ASN1Data.new([signed_data], 0, :CONTEXT_SPECIFIC) # [0] EXPLICIT
87
+ ]).to_der
88
+ end
89
+
90
+ def build_signed_data(signature, signed_attrs_der)
91
+ sha256_alg = algorithm_identifier(OID_SHA256)
92
+ rsa_alg = algorithm_identifier(OID_RSA)
93
+ signer_info = build_signer_info(signature, signed_attrs_der, sha256_alg, rsa_alg)
94
+
95
+ # certificates [0] IMPLICIT — DER of each cert wrapped as context-specific
96
+ all_certs = [@certificate, *@certificate_chain]
97
+ cert_nodes = all_certs.map { |c| OpenSSL::ASN1.decode(c.to_der) }
98
+ certs_tagged = OpenSSL::ASN1::ASN1Data.new(cert_nodes, 0, :CONTEXT_SPECIFIC)
99
+
100
+ # encapContentInfo: just the eContentType, no eContent (detached)
101
+ encap = OpenSSL::ASN1::Sequence.new([OpenSSL::ASN1::ObjectId.new(OID_DATA)])
102
+
103
+ OpenSSL::ASN1::Sequence.new([
104
+ OpenSSL::ASN1::Integer.new(OpenSSL::BN.new(1)), # version 1
105
+ OpenSSL::ASN1::Set.new([sha256_alg]), # digestAlgorithms
106
+ encap, # encapContentInfo
107
+ certs_tagged, # certificates [0]
108
+ OpenSSL::ASN1::Set.new([signer_info]) # signerInfos
109
+ ])
110
+ end
111
+
112
+ def build_signer_info(signature, signed_attrs_der, sha256_alg, rsa_alg)
113
+ # IssuerAndSerialNumber
114
+ issuer_and_serial = OpenSSL::ASN1::Sequence.new([
115
+ OpenSSL::ASN1.decode(@certificate.issuer.to_der),
116
+ OpenSSL::ASN1::Integer.new(@certificate.serial)
117
+ ])
118
+
119
+ # signedAttrs stored as [0] IMPLICIT (same bytes as SET but tag = 0xA0)
120
+ parsed_set = OpenSSL::ASN1.decode(signed_attrs_der)
121
+ signed_attrs_0 = OpenSSL::ASN1::ASN1Data.new(parsed_set.value, 0, :CONTEXT_SPECIFIC)
122
+
123
+ OpenSSL::ASN1::Sequence.new([
124
+ OpenSSL::ASN1::Integer.new(OpenSSL::BN.new(1)), # version 1
125
+ issuer_and_serial,
126
+ sha256_alg, # digestAlgorithm
127
+ signed_attrs_0, # signedAttrs [0] IMPLICIT
128
+ rsa_alg, # signatureAlgorithm
129
+ OpenSSL::ASN1::OctetString.new(signature) # signature
130
+ ])
131
+ end
132
+
133
+ # AlgorithmIdentifier SEQUENCE { OID, NULL }
134
+ def algorithm_identifier(oid_str)
135
+ OpenSSL::ASN1::Sequence.new([
136
+ OpenSSL::ASN1::ObjectId.new(oid_str),
137
+ OpenSSL::ASN1::Null.new(nil)
138
+ ])
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,275 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "pdf-reader"
5
+ require_relative "cms_builder"
6
+
7
+ module EasyCodeSign
8
+ module Pdf
9
+ # Signs a PDF using a pure-OpenSSL/MIT approach (no HexaPDF).
10
+ #
11
+ # Implements ISO 32000 incremental-update signature:
12
+ # 1. Appends new objects (sig dict, sig field, updated catalog) to the original PDF.
13
+ # 2. Appends a cross-reference table and trailer (/Prev chains to original).
14
+ # 3. Writes a fixed-size /Contents placeholder (zeros).
15
+ # 4. Computes ByteRange = [0, placeholder_start, placeholder_end, rest_of_file].
16
+ # 5. Patches ByteRange into the file (fixed-width replacement).
17
+ # 6. Signs the ByteRange content via CmsBuilder + the caller's sign_bytes block.
18
+ # 7. Embeds the CMS DER hex into the /Contents slot.
19
+ #
20
+ class NativeSigner
21
+ # Reserved binary bytes for CMS DER. RSA-2048 + self-signed cert < 4 KiB.
22
+ # 8 KiB gives headroom for small certificate chains.
23
+ SIGNATURE_PLACEHOLDER_SIZE = 8192
24
+
25
+ # Fixed-width ByteRange placeholder written before the real values are known.
26
+ # Four 10-digit zero-padded integers + brackets = always 44 chars.
27
+ BR_PLACEHOLDER = "[0000000000 0000000000 0000000000 0000000000]"
28
+
29
+ def initialize(pdf_path:, output_path:, certificate:, certificate_chain: [],
30
+ reason: nil, location: nil, contact_info: nil, signing_time: nil)
31
+ @pdf_path = pdf_path
32
+ @output_path = output_path
33
+ @certificate = certificate
34
+ @certificate_chain = certificate_chain
35
+ @reason = reason
36
+ @location = location
37
+ @contact_info = contact_info
38
+ @signing_time = signing_time || Time.now
39
+ end
40
+
41
+ # Produce a signed PDF.
42
+ # @yield [hash] binary String — SHA256(signed_attrs_DER), per HexaPDF external_signing convention
43
+ # @yieldreturn [String] raw RSA-PKCS#1-v1.5 signature bytes
44
+ # @return [String] @output_path
45
+ def sign(&sign_bytes)
46
+ raise ArgumentError, "sign_bytes block required" unless sign_bytes
47
+
48
+ original = File.binread(@pdf_path)
49
+ reader = PDF::Reader.new(StringIO.new(original))
50
+ trailer = reader.objects.trailer
51
+
52
+ orig_startxref = find_startxref(original)
53
+ orig_size = trailer[:Size].to_i
54
+ root_ref = trailer[:Root]
55
+
56
+ # New object numbers
57
+ sig_obj_num = orig_size
58
+ field_obj_num = orig_size + 1
59
+
60
+ # The incremental update begins after a "\n" separator
61
+ base = original.bytesize + 1 # +1 for the separator newline
62
+
63
+ # Build object bodies and track their absolute offsets in the combined file
64
+ sig_body, contents_body_rel = build_sig_body(sig_obj_num)
65
+ sig_offset = base
66
+ contents_abs = sig_offset + contents_body_rel
67
+
68
+ field_body = build_field_body(field_obj_num, sig_obj_num)
69
+ field_offset = sig_offset + sig_body.bytesize
70
+
71
+ catalog_obj_num = root_ref.id
72
+ catalog_gen = root_ref.gen
73
+ catalog_body = build_catalog_body(catalog_obj_num, catalog_gen,
74
+ field_obj_num, root_ref, reader)
75
+ catalog_offset = field_offset + field_body.bytesize
76
+
77
+ xref_offset = catalog_offset + catalog_body.bytesize
78
+
79
+ xref_str = build_xref(
80
+ sig_obj_num => sig_offset,
81
+ field_obj_num => field_offset,
82
+ catalog_obj_num => catalog_offset
83
+ )
84
+
85
+ new_size = [sig_obj_num, field_obj_num, catalog_obj_num].max + 1
86
+ trailer_str = "trailer\n" \
87
+ "<</Size #{new_size}" \
88
+ " /Root #{ref(catalog_obj_num, catalog_gen)}" \
89
+ " /Prev #{orig_startxref}>>\n" \
90
+ "startxref\n#{xref_offset}\n%%EOF\n"
91
+
92
+ combined = original +
93
+ "\n" +
94
+ sig_body + field_body + catalog_body +
95
+ xref_str + trailer_str
96
+
97
+ File.binwrite(@output_path, combined)
98
+
99
+ # contents_abs is the absolute offset of '<' in /Contents <hex> in the combined file
100
+ contents_total = SIGNATURE_PLACEHOLDER_SIZE * 2 + 2 # '<' + hex_zeros + '>'
101
+ byte_range = [
102
+ 0,
103
+ contents_abs,
104
+ contents_abs + contents_total,
105
+ combined.bytesize - (contents_abs + contents_total)
106
+ ]
107
+
108
+ patch_byte_range(byte_range)
109
+
110
+ data = read_byte_ranges(byte_range)
111
+
112
+ builder = CmsBuilder.new(
113
+ certificate: @certificate,
114
+ certificate_chain: @certificate_chain,
115
+ signing_time: @signing_time
116
+ )
117
+ cms_der = builder.build(data, &sign_bytes)
118
+
119
+ embed_signature(contents_abs, cms_der)
120
+
121
+ @output_path
122
+ end
123
+
124
+ private
125
+
126
+ # ------------------------------------------------------------------ #
127
+ # Object body builders #
128
+ # ------------------------------------------------------------------ #
129
+
130
+ # Returns [body_string, byte_offset_of_'<'_within_body]
131
+ def build_sig_body(obj_num)
132
+ hex_zeros = "0" * (SIGNATURE_PLACEHOLDER_SIZE * 2)
133
+
134
+ header = "#{obj_num} 0 obj\n" \
135
+ "<</Type /Sig\n" \
136
+ "/Filter /Adobe.PPKLite\n" \
137
+ "/SubFilter /adbe.pkcs7.detached\n" \
138
+ "/ByteRange #{BR_PLACEHOLDER}\n" \
139
+ "/Contents <"
140
+
141
+ footer = ">"
142
+ footer += "\n/Reason #{pdf_str(@reason)}" if @reason
143
+ footer += "\n/Location #{pdf_str(@location)}" if @location
144
+ footer += "\n/ContactInfo #{pdf_str(@contact_info)}" if @contact_info
145
+ footer += "\n>>\nendobj\n"
146
+
147
+ body = header + hex_zeros + footer
148
+ [body, header.bytesize - 1] # -1 → points at '<'
149
+ end
150
+
151
+ def build_field_body(obj_num, sig_obj_num)
152
+ "#{obj_num} 0 obj\n" \
153
+ "<</Type /Annot\n" \
154
+ "/Subtype /Widget\n" \
155
+ "/FT /Sig\n" \
156
+ "/V #{ref(sig_obj_num, 0)}\n" \
157
+ "/Rect [0 0 0 0]\n" \
158
+ "/F 4\n" \
159
+ "/T (Signature1)\n" \
160
+ ">>\nendobj\n"
161
+ end
162
+
163
+ def build_catalog_body(obj_num, gen, field_obj_num, root_ref, reader)
164
+ catalog = reader.objects[root_ref] || {}
165
+
166
+ parts = ["/Type /Catalog"]
167
+
168
+ if catalog[:Pages]
169
+ pr = catalog[:Pages]
170
+ parts << "/Pages #{ref(pr.id, pr.gen)}"
171
+ end
172
+
173
+ # Preserve common catalog entries
174
+ %i[ViewerPreferences PageLayout PageMode Names Outlines MarkInfo Lang].each do |key|
175
+ val = catalog[key]
176
+ next unless val
177
+ parts << "/#{key} #{pdf_val(val)}"
178
+ end
179
+
180
+ parts << "/AcroForm <</Fields [#{ref(field_obj_num, 0)}] /SigFlags 3>>"
181
+
182
+ "#{obj_num} #{gen} obj\n<<#{parts.join("\n")}>>\nendobj\n"
183
+ end
184
+
185
+ # ------------------------------------------------------------------ #
186
+ # Cross-reference table #
187
+ # ------------------------------------------------------------------ #
188
+
189
+ # Writes separate 1-entry subsections — always valid PDF
190
+ def build_xref(entries)
191
+ out = +"xref\n"
192
+ entries.sort_by { |num, _| num }.each do |num, offset|
193
+ out << "#{num} 1\n"
194
+ out << "#{offset.to_s.rjust(10, "0")} 00000 n \n"
195
+ end
196
+ out
197
+ end
198
+
199
+ # ------------------------------------------------------------------ #
200
+ # ByteRange + signature embedding #
201
+ # ------------------------------------------------------------------ #
202
+
203
+ def patch_byte_range(byte_range)
204
+ content = File.binread(@output_path)
205
+ br_str = "[#{byte_range.map { |v| v.to_s.rjust(10, "0") }.join(" ")}]"
206
+ raise "ByteRange value too wide" if br_str.bytesize > BR_PLACEHOLDER.bytesize
207
+
208
+ idx = content.index("/ByteRange #{BR_PLACEHOLDER}")
209
+ raise InvalidPdfError, "ByteRange placeholder not found" unless idx
210
+
211
+ start = idx + "/ByteRange ".bytesize
212
+ content[start, BR_PLACEHOLDER.bytesize] = br_str.ljust(BR_PLACEHOLDER.bytesize)
213
+ File.binwrite(@output_path, content)
214
+ end
215
+
216
+ def read_byte_ranges(byte_range)
217
+ File.open(@output_path, "rb") do |f|
218
+ f.pos = byte_range[0]
219
+ chunk = f.read(byte_range[1])
220
+ f.pos = byte_range[2]
221
+ chunk << f.read(byte_range[3])
222
+ end
223
+ end
224
+
225
+ def embed_signature(contents_abs, cms_der)
226
+ hex = cms_der.unpack1("H*")
227
+ if hex.bytesize > SIGNATURE_PLACEHOLDER_SIZE * 2
228
+ raise PdfSignatureError, "CMS DER (#{hex.bytesize / 2} bytes) exceeds reserved " \
229
+ "#{SIGNATURE_PLACEHOLDER_SIZE} bytes"
230
+ end
231
+ padded = hex.ljust(SIGNATURE_PLACEHOLDER_SIZE * 2, "0")
232
+ File.open(@output_path, "rb+") do |f|
233
+ f.pos = contents_abs # '<' of /Contents <hex>
234
+ f.write("<#{padded}>")
235
+ end
236
+ end
237
+
238
+ # ------------------------------------------------------------------ #
239
+ # Helpers #
240
+ # ------------------------------------------------------------------ #
241
+
242
+ def find_startxref(bytes)
243
+ tail = bytes.byteslice([bytes.bytesize - 1024, 0].max, 1024) || bytes
244
+ m = tail.match(/startxref\s+(\d+)\s*%%EOF/)
245
+ raise InvalidPdfError, "Cannot locate startxref in PDF" unless m
246
+ m[1].to_i
247
+ end
248
+
249
+ def ref(id, gen)
250
+ "#{id} #{gen} R"
251
+ end
252
+
253
+ def pdf_str(str)
254
+ "(#{str.gsub("\\", "\\\\\\\\").gsub("(", "\\(").gsub(")", "\\)")})"
255
+ end
256
+
257
+ def pdf_val(val)
258
+ case val
259
+ when PDF::Reader::Reference then ref(val.id, val.gen)
260
+ when Symbol then "/#{val}"
261
+ when String then pdf_str(val)
262
+ when TrueClass then "true"
263
+ when FalseClass then "false"
264
+ when NilClass then "null"
265
+ when Array then "[#{val.map { |v| pdf_val(v) }.join(" ")}]"
266
+ when Hash
267
+ inner = val.map { |k, v| "/#{k} #{pdf_val(v)}" }.join(" ")
268
+ "<<#{inner}>>"
269
+ else
270
+ val.to_s
271
+ end
272
+ end
273
+ end
274
+ end
275
+ end