rustpdf 0.4.3-x86_64-linux → 0.4.5-x86_64-linux

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: 95532283bdf3fafe95ca9e1227ea40e36c4fdce81f3b28544ae66dabcafa1939
4
- data.tar.gz: 29c18936673cb3d467a881c0cab11f9c0d85ee2dbb4839059a292694eb27c9f8
3
+ metadata.gz: 1e1a51e3817bb0d67e1769dae5b88b46e8691b106670c5fe00667a1f0fa6f29b
4
+ data.tar.gz: e2496c1209908d600fa54c27b1a75c9773763ab76033564190686f5a8407d46d
5
5
  SHA512:
6
- metadata.gz: 84e472bbc222217ea7d5db1f1a345e3159ea9185e2afefeb033a50f244065ff68029d9bf7206911c423262c1a2637dabd561e24137d2a90aeb0fbcac5d4c64b1
7
- data.tar.gz: 8db08da3b030c712be6ae50b67cffbe6fbc47c59e5dcab4b27904438662836e01ccb06bbbf5c47fa3dd17e2e5b1c98fe874fae0f8e28abd1d95f442b45969b85
6
+ metadata.gz: 62c4b0d61e522dd37037887ed0a7db658f093fa206713975085a7f45e0bf0a3bf2a9c7b11a2431d112363458e5842d08ae352d81bc35d14eaa2dd4b9b41c8e5f
7
+ data.tar.gz: 95c1d90d8cefa73f1fccabd1eccede8eae9b8bb1bdd144eb8b7ace08e82c94c46f4de37242494bbae51a161eb2c0c637599a2ee2eaf80ba8be95f98e7dba962e
data/README.md CHANGED
@@ -49,6 +49,38 @@ signed = RustPdf.sign(data, key_der, cert_der, pades: true)
49
49
  Corporate features (PDF/A, signing, encryption, accessibility, page rendering — a **Pro** feature) require a license;
50
50
  without one they raise `RustPdf::Error`. See [`docs/LICENSING.md`](../../docs/LICENSING.md).
51
51
 
52
+ ## Deferred / HSM signing
53
+
54
+ Sign without the private key ever entering this library — the key stays in an
55
+ HSM, cloud KMS, smartcard or PKI token. Works with any PKI (eIDAS, AATL, or
56
+ your own CA). The library builds the CMS and asks your code only for the raw
57
+ RSA signature over the bytes it hands you.
58
+
59
+ ```ruby
60
+ require "rustpdf"
61
+
62
+ pdf = File.binread("contract.pdf")
63
+ cert_der = File.binread("signing-cert.der") # X.509 signer certificate (DER)
64
+
65
+ # Model A — your block returns the raw RSA PKCS#1 v1.5 signature; the key
66
+ # never reaches the library. Call your HSM / cloud KMS / token here.
67
+ signed = RustPdf.sign_with(pdf, cert_der,
68
+ chain: [issuer_der],
69
+ options: RustPdf::SigningOptions.new(reason: "Approved")) do |to_sign|
70
+ hsm.sign_rsa_sha256(to_sign) # bytes in, raw signature out
71
+ end
72
+ File.binwrite("contract.signed.pdf", signed)
73
+
74
+ # Model B — two-phase: prepare, sign the hash out of band, then complete.
75
+ session = RustPdf.begin_signing(pdf)
76
+ container = remote.build_cms(session.hash) # send #hash to a remote signer
77
+ final = session.complete(container) # embed the DER CMS / PKCS#7
78
+ ```
79
+
80
+ `RustPdf.list_signatures(pdf)` inventories existing signature fields before you
81
+ sign. See [`docs/ruby.html`](../../site/public/docs/ruby.html#sign) for the full
82
+ deferred-signing API (`SigningOptions`, `Certify`, `SignaturePolicy`).
83
+
52
84
  ## Test
53
85
 
54
86
  ```sh
@@ -127,19 +127,58 @@ module RustPdf
127
127
  text.split("\n").reject(&:empty?)
128
128
  end
129
129
 
130
- # Stamp a diagonal text watermark across every page.
131
- def watermark_text(text, size: 64.0, color: [0.5, 0.5, 0.5], opacity: 0.30, rotation_deg: 45.0)
130
+ # Stamp a diagonal text watermark across every page. When
131
+ # +opaque_background+ is true, the text is drawn over an opaque white box
132
+ # (otherwise it is blended into the page content).
133
+ def watermark_text(text, size: 64.0, color: [0.5, 0.5, 0.5], opacity: 0.30,
134
+ rotation_deg: 45.0, opaque_background: false)
132
135
  r, g, b = color
133
- RustPdf.check(Native.call("pdf_editable_watermark_text", ptr, text, size, r, g, b, opacity, rotation_deg))
136
+ RustPdf.check(Native.call("pdf_editable_watermark_text", ptr, text, size, r, g, b,
137
+ opacity, rotation_deg, opaque_background ? 1 : 0))
134
138
  self
135
139
  end
136
140
 
137
- # Stamp an image watermark (from a file) across every page.
138
- def watermark_image_file(path, width, height, opacity: 0.30)
139
- RustPdf.check(Native.call("pdf_editable_watermark_image_file", ptr, path, width, height, opacity))
141
+ # Stamp an image watermark (from a file) across every page, rotated
142
+ # +rotation_deg+ degrees counter-clockwise.
143
+ def watermark_image_file(path, width, height, opacity: 0.30, rotation_deg: 0.0)
144
+ RustPdf.check(Native.call("pdf_editable_watermark_image_file", ptr, path, width, height,
145
+ opacity, rotation_deg))
140
146
  self
141
147
  end
142
148
 
149
+ # ---- positioned drawing primitives (issue #45 P1) -----------------------
150
+
151
+ # Paint a filled rectangle at (+x+, +y+) sized +width+ x +height+ on page
152
+ # +page_index+ (0-based), in RGB +color+ (each 0..1, default opaque white) at
153
+ # +opacity+ (0..1). Coordinates are in the page's VISIBLE space (origin
154
+ # lower-left, y up), regardless of the page's /Rotate. The common use is
155
+ # masking a placeholder with an opaque white box. Returns whether the page
156
+ # existed.
157
+ def fill_rect(page_index, x, y, width, height, color = [1.0, 1.0, 1.0], opacity = 1.0)
158
+ r, g, b = color
159
+ found = RustPdf.out_int do |buf|
160
+ Native.call("pdf_editable_fill_rect", ptr, page_index, x.to_f, y.to_f,
161
+ width.to_f, height.to_f, r.to_f, g.to_f, b.to_f, opacity.to_f, buf)
162
+ end
163
+ found != 0
164
+ end
165
+
166
+ # Draw a line of positioned +text+ with baseline at (+x+, +y+) on page
167
+ # +page_index+ (0-based), using standard Helvetica at +size+ points in RGB
168
+ # +color+ (each 0..1, default black). +rotation_deg+ rotates the text
169
+ # counter-clockwise about its anchor (match the page rotation to follow a
170
+ # rotated page). Coordinates are in the page's VISIBLE space (origin
171
+ # lower-left, y up), regardless of the page's /Rotate. Returns whether the
172
+ # page existed.
173
+ def place_text(page_index, x, y, text, size = 12.0, color = [0.0, 0.0, 0.0], rotation_deg = 0.0)
174
+ r, g, b = color
175
+ found = RustPdf.out_int do |buf|
176
+ Native.call("pdf_editable_place_text", ptr, page_index, x.to_f, y.to_f, text,
177
+ size.to_f, r.to_f, g.to_f, b.to_f, rotation_deg.to_f, buf)
178
+ end
179
+ found != 0
180
+ end
181
+
143
182
  # ---- redaction + PDF/A conversion (Tier 2) ------------------------------
144
183
 
145
184
  # Black out rectangles on a page. rects = [[x0,y0,x1,y1], ...].
@@ -156,6 +195,29 @@ module RustPdf
156
195
  self
157
196
  end
158
197
 
198
+ # ---- version normalization (issue #41 P1) -------------------------------
199
+
200
+ # Set the output PDF version (downgrade/normalize). +version+ uses the
201
+ # RustPdf::Version codes (V1_4=0, V1_5=1, V1_7=2, V2_0=3).
202
+ def set_version(version)
203
+ RustPdf.check(Native.call("pdf_editable_set_version", ptr, version))
204
+ self
205
+ end
206
+
207
+ # Strip PDF/A conformance (OutputIntents, XMP pdfaid, /Version) so the file
208
+ # is a plain PDF.
209
+ def strip_pdfa
210
+ RustPdf.check(Native.call("pdf_editable_strip_pdfa", ptr))
211
+ self
212
+ end
213
+
214
+ # Normalize to a plain PDF at +version+ (strip PDF/A + set version). Codes as
215
+ # in #set_version.
216
+ def normalize(version = Version::V1_7)
217
+ RustPdf.check(Native.call("pdf_editable_normalize", ptr, version))
218
+ self
219
+ end
220
+
159
221
  def optimize
160
222
  RustPdf.check(Native.call("pdf_editable_optimize", ptr))
161
223
  self
@@ -97,13 +97,38 @@ module RustPdf
97
97
  "pdf_editable_set_choice" => [[VP, VP, VP, VP], I],
98
98
  "pdf_editable_flatten_forms" => [[VP], I],
99
99
  "pdf_editable_field_names" => [[VP, VP, VP], I],
100
- "pdf_editable_watermark_text" => [[VP, VP, D, D, D, D, D, D], I],
101
- "pdf_editable_watermark_image_file" => [[VP, VP, D, D, D], I],
100
+ # watermark_text gained a trailing int opaque_background; watermark_image
101
+ # gained a trailing double rotation_deg (issue #41 P1).
102
+ "pdf_editable_watermark_text" => [[VP, VP, D, D, D, D, D, D, I], I],
103
+ "pdf_editable_watermark_image_file" => [[VP, VP, D, D, D, D], I],
102
104
  # Tier 2: redaction + PDF/A conversion (EditableDoc)
103
105
  "pdf_editable_redact" => [[VP, SZ, VP, SZ, VP], I],
104
106
  "pdf_editable_convert_to_pdfa" => [[VP, I], I],
105
107
  # Tier 2: signature validation (module-level)
106
108
  "pdf_verify_signatures_json" => [[VP, SZ, VP, VP], I],
109
+
110
+ # Deferred / external (HSM) signing — issue #41 P0. The PdfSigningOptions
111
+ # struct crosses as an opaque pointer (VP); the Model-A callback is a
112
+ # Fiddle::Closure passed as a function pointer (VP).
113
+ "pdf_sign_begin" => [[VP, SZ, VP, VP, VP, VP, VP], I],
114
+ "pdf_sign_complete" => [[VP, SZ, VP, SZ, VP, VP], I],
115
+ "pdf_sign_with" => [[VP, SZ, VP, SZ, VP, VP, SZ, VP, VP, VP, VP, VP], I],
116
+ "pdf_list_signatures" => [[VP, SZ, VP, VP], I],
117
+
118
+ # Issue #41 P1: positional text search, version normalization, network TSA.
119
+ "pdf_find_text_json" => [[VP, SZ, VP, I, VP, VP], I],
120
+ "pdf_editable_set_version" => [[VP, I], I],
121
+ "pdf_editable_strip_pdfa" => [[VP], I],
122
+ "pdf_editable_normalize" => [[VP, I], I],
123
+ "pdf_timestamp_begin" => [[VP, SZ, VP, VP, VP, VP], I],
124
+ "pdf_timestamp_request" => [[VP, SZ, VP, SZ, I, VP, VP], I],
125
+ "pdf_timestamp_token_from_response" => [[VP, SZ, VP, VP], I],
126
+
127
+ # Issue #45 P1: page geometry, document inspection, positioned drawing.
128
+ "pdf_measure_pages_json" => [[VP, SZ, VP, VP], I],
129
+ "pdf_inspect_json" => [[VP, SZ, VP, VP], I],
130
+ "pdf_editable_fill_rect" => [[VP, I, D, D, D, D, D, D, D, D, VP], I],
131
+ "pdf_editable_place_text" => [[VP, I, D, D, VP, D, D, D, D, D, VP], I],
107
132
  }.freeze
108
133
 
109
134
  def lib
data/lib/rustpdf.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  require "fiddle"
2
2
  require "json"
3
+ require "digest"
3
4
  require_relative "rustpdf/native"
4
5
 
5
6
  # Idiomatic Ruby binding for the rust-pdf core over its C ABI (libpdf_ffi),
@@ -55,6 +56,14 @@ module RustPdf
55
56
  AES256 = 2
56
57
  end
57
58
 
59
+ # Output PDF version (for set_version / normalize).
60
+ module Version
61
+ V1_4 = 0
62
+ V1_5 = 1
63
+ V1_7 = 2
64
+ V2_0 = 3
65
+ end
66
+
58
67
  # ZUGFeRD / Factur-X conformance profiles.
59
68
  module FacturxProfile
60
69
  MINIMUM = 0
@@ -91,6 +100,114 @@ module RustPdf
91
100
  end
92
101
  end
93
102
 
103
+ # DocMDP certification level applied by the first (certifying) signature.
104
+ module Certify
105
+ NONE = 0 # not a certifying signature
106
+ LOCKED = 1 # /P 1 — no changes permitted after signing
107
+ FORMS = 2 # /P 2 — form-filling and signing permitted
108
+ FORMS_AND_ANNOTATIONS = 3 # /P 3 — also annotations permitted
109
+ end
110
+
111
+ # A signature-policy identifier (PAdES-EPES / ICP-Brasil AD-RB).
112
+ class SignaturePolicy
113
+ attr_accessor :oid, :hash, :hash_algorithm_oid, :uri
114
+
115
+ # +oid+: dotted-decimal policy OID; +hash+: policy document hash bytes
116
+ # (under +hash_algorithm_oid+, nil = SHA-256); +uri+: optional SPURI.
117
+ def initialize(oid:, hash:, hash_algorithm_oid: nil, uri: nil)
118
+ @oid = oid
119
+ @hash = hash
120
+ @hash_algorithm_oid = hash_algorithm_oid
121
+ @uri = uri
122
+ end
123
+ end
124
+
125
+ # Options for deferred / external signing (issue #41). The +visible_*+ fields
126
+ # request a visible signature appearance: +visible_rect+ is [x0,y0,x1,y1] in
127
+ # points, +visible_text+ is newline-separated appearance lines, and
128
+ # +visible_image+ is PNG/JPEG bytes of a handwritten-signature image.
129
+ class SigningOptions
130
+ attr_accessor :reason, :location, :name, :pades, :certify, :container_size, :policy,
131
+ :visible, :visible_page, :visible_rect, :visible_text, :visible_image
132
+
133
+ def initialize(reason: nil, location: nil, name: nil, pades: false,
134
+ certify: Certify::NONE, container_size: 0, policy: nil,
135
+ visible: false, visible_page: 0, visible_rect: nil,
136
+ visible_text: nil, visible_image: nil)
137
+ @reason = reason
138
+ @location = location
139
+ @name = name
140
+ @pades = pades
141
+ @certify = certify
142
+ @container_size = container_size
143
+ @policy = policy
144
+ @visible = visible
145
+ @visible_page = visible_page
146
+ @visible_rect = visible_rect
147
+ @visible_text = visible_text
148
+ @visible_image = visible_image
149
+ end
150
+ end
151
+
152
+ # A signature field discovered in a PDF (pre-signing inventory). +signed+ is
153
+ # true when the field already carries a signature.
154
+ SignatureField = Struct.new(:name, :signed)
155
+
156
+ # One positional text match from #find_text. Coordinates are in PDF points,
157
+ # origin lower-left; +x+/+y+ is the lower-left corner of the box.
158
+ TextHit = Struct.new(:page, :text, :x, :y, :width, :height)
159
+
160
+ # A rectangle in PDF user space (points, origin lower-left). Used for a page's
161
+ # MediaBox/CropBox. #width / #height are the (non-negative) extents.
162
+ PdfRect = Struct.new(:x0, :y0, :x1, :y1) do
163
+ def width
164
+ (x1 - x0).abs
165
+ end
166
+
167
+ def height
168
+ (y1 - y0).abs
169
+ end
170
+ end
171
+
172
+ # Read-only geometry of one page (from #measure_page / #measure_pages). Sizes
173
+ # are in PDF points; +width+/+height+ ignore rotation while +rotated_width+/
174
+ # +rotated_height+ account for it (swapped for 90/270 pages). +media_box+ and
175
+ # +crop_box+ are PdfRect values.
176
+ PageGeometry = Struct.new(:page, :width, :height, :rotation,
177
+ :rotated_width, :rotated_height, :media_box, :crop_box)
178
+
179
+ # A non-mutating summary of a PDF (from #inspect_pdf). +pdfa_level+ is nil when
180
+ # the document is not PDF/A; +encryption+ is the cipher name ("None" when the
181
+ # document is not encrypted).
182
+ PdfOverview = Struct.new(:version, :pdfa_level, :encrypted, :encryption,
183
+ :requires_password, :page_count)
184
+
185
+ # An in-progress two-phase signature: #document holds the placeholder PDF and
186
+ # #bytes the exact bytes the signature covers. Hand #hash to a remote signer,
187
+ # build the CMS container, then call #complete.
188
+ class SigningSession
189
+ # The prepared PDF (with a zero-filled /Contents placeholder).
190
+ attr_reader :document
191
+ # The exact bytes covered by the signature (the two ByteRange segments).
192
+ attr_reader :bytes
193
+
194
+ def initialize(document, bytes)
195
+ @document = document
196
+ @bytes = bytes
197
+ end
198
+
199
+ # SHA-256 of #bytes — the 32-byte value an HSM signs.
200
+ def hash
201
+ Digest::SHA256.digest(@bytes)
202
+ end
203
+
204
+ # Phase 2: complete the signature by embedding a finished DER CMS / PKCS#7
205
+ # +container+, returning the final signed PDF.
206
+ def complete(container)
207
+ RustPdf.complete_signature(@document, container)
208
+ end
209
+ end
210
+
94
211
  module_function
95
212
 
96
213
  # Native library version string.
@@ -133,10 +250,67 @@ module RustPdf
133
250
  count[0, Native::SIZEOF_SZ].unpack1("J")
134
251
  end
135
252
 
253
+ # Find every occurrence of +query+ in +pdf+, returning positional boxes.
254
+ # Returns an Array of TextHit (page, text, x, y, width, height) in PDF points
255
+ # (origin lower-left). +case_sensitive+ defaults to false. An empty Array
256
+ # means no match.
257
+ def find_text(pdf, query, case_sensitive: false)
258
+ js = take_bytes do |pp, pn|
259
+ Native.call("pdf_find_text_json", pdf, pdf.bytesize, query, case_sensitive ? 1 : 0, pp, pn)
260
+ end.force_encoding(Encoding::UTF_8)
261
+ return [] if js.empty?
262
+
263
+ JSON.parse(js).map do |h|
264
+ TextHit.new(h["page"], h["text"], h["x"], h["y"], h["width"], h["height"])
265
+ end
266
+ end
267
+
268
+ # Read the geometry (size, rotation, MediaBox, CropBox) of every page in +pdf+,
269
+ # in page order, without mutating it. Returns an Array of PageGeometry. Sizes
270
+ # are in PDF points; +rotated_width+/+rotated_height+ swap for 90/270 pages.
271
+ def measure_pages(pdf)
272
+ js = take_bytes { |pp, pn| Native.call("pdf_measure_pages_json", pdf, pdf.bytesize, pp, pn) }
273
+ .force_encoding(Encoding::UTF_8)
274
+ return [] if js.empty?
275
+
276
+ rect = lambda do |arr|
277
+ a = Array(arr)
278
+ a.size == 4 ? PdfRect.new(a[0], a[1], a[2], a[3]) : PdfRect.new(0, 0, 0, 0)
279
+ end
280
+ JSON.parse(js).map do |g|
281
+ PageGeometry.new(g["page"], g["width"], g["height"], g["rotation"],
282
+ g["rotatedWidth"], g["rotatedHeight"],
283
+ rect.call(g["mediaBox"]), rect.call(g["cropBox"]))
284
+ end
285
+ end
286
+
287
+ # Read the geometry of a single 0-based page of +pdf+. Raises IndexError if
288
+ # +index+ is out of range.
289
+ def measure_page(pdf, index)
290
+ pages = measure_pages(pdf)
291
+ raise IndexError, "page index #{index} out of range (#{pages.size} pages)" if index.negative? || index >= pages.size
292
+
293
+ pages[index]
294
+ end
295
+
296
+ # Inspect +pdf+ without mutating it: PDF version, PDF/A level (if any),
297
+ # encryption posture and page count. Works even on password-protected files
298
+ # (the encryption fields are still reported). Returns a PdfOverview. Named
299
+ # +inspect_pdf+ to avoid shadowing Object#inspect.
300
+ def inspect_pdf(pdf)
301
+ js = take_bytes { |pp, pn| Native.call("pdf_inspect_json", pdf, pdf.bytesize, pp, pn) }
302
+ .force_encoding(Encoding::UTF_8)
303
+ o = JSON.parse(js)
304
+ PdfOverview.new(o["version"], o["pdfaLevel"], o["encrypted"] ? true : false,
305
+ o["encryption"], o["requiresPassword"] ? true : false, o["pageCount"])
306
+ end
307
+
136
308
  # Validate every signature in +pdf+. Returns one Hash per signature with keys
137
309
  # "field_name", "sub_filter", "signer", "covers_whole_document",
138
- # "digest_valid", "signature_valid", "is_valid" and "byte_range". An empty
139
- # array means the document is unsigned.
310
+ # "digest_valid", "signature_valid", "is_valid" and "byte_range", plus the
311
+ # richer certificate fields (any may be null): "issuer", "serial_number",
312
+ # "valid_from", "valid_to", "algorithm", "signing_time", "cert_count" and
313
+ # "has_timestamp". An empty array means the document is unsigned.
140
314
  def verify_signatures(pdf)
141
315
  js = take_bytes { |pp, pn| Native.call("pdf_verify_signatures_json", pdf, pdf.bytesize, pp, pn) }
142
316
  .force_encoding(Encoding::UTF_8)
@@ -177,8 +351,213 @@ module RustPdf
177
351
  result
178
352
  end
179
353
 
354
+ # ---- deferred / external (HSM) signing — issue #41 ------------------------
355
+
356
+ # Model A — remote signer. Sign +pdf+ without handing this library a key: it
357
+ # builds the CMS signed attributes and calls the given block for the raw RSA
358
+ # PKCS#1 v1.5 signature (over SHA-256 of the block's argument), then assembles
359
+ # and embeds the CMS. +cert_der+ is the signer certificate (DER); +chain+ are
360
+ # intermediate certificates (DER), supplied independently of the key. The
361
+ # private key never reaches this library. Returns the signed PDF bytes.
362
+ def sign_with(pdf, cert_der, chain: [], options: nil, &block)
363
+ raise Error, "sign_with requires a block that produces the signature" unless block
364
+
365
+ opts_bytes, keep = build_signing_options(options)
366
+ cptrs = chain.map { |c| Fiddle::Pointer[c] }
367
+ cptr_buf = cptrs.map(&:to_i).pack("J*")
368
+ clen_buf = chain.map(&:bytesize).pack("J*")
369
+
370
+ signer_error = nil
371
+ closure = Fiddle::Closure::BlockCaller.new(
372
+ Fiddle::TYPE_INT,
373
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T,
374
+ Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T, Fiddle::TYPE_VOIDP]
375
+ ) do |_ctx, data, data_len, sig_buf, sig_cap, sig_len|
376
+ begin
377
+ sig = block.call(data[0, data_len]).to_s
378
+ if sig.bytesize > sig_cap
379
+ signer_error = Error.new("signature (#{sig.bytesize} bytes) exceeds buffer capacity #{sig_cap}")
380
+ next 2
381
+ end
382
+ sig_buf[0, sig.bytesize] = sig
383
+ sig_len[0, Native::SIZEOF_SZ] = [sig.bytesize].pack("J")
384
+ 0
385
+ rescue StandardError => e
386
+ signer_error = e
387
+ 1
388
+ end
389
+ end
390
+
391
+ begin
392
+ result = take_bytes do |pp, pn|
393
+ Native.call("pdf_sign_with", pdf, pdf.bytesize, cert_der, cert_der.bytesize,
394
+ cptr_buf, clen_buf, chain.size, opts_bytes, closure, Fiddle::NULL, pp, pn)
395
+ end
396
+ rescue Error
397
+ raise signer_error if signer_error
398
+
399
+ raise
400
+ end
401
+ # keep the callback, struct and per-cert pointers alive until the call returned
402
+ cptrs.clear
403
+ keep.clear
404
+ closure.to_i # touch to keep it referenced past the native call
405
+ result
406
+ end
407
+
408
+ # Model B — two-phase signing, phase 1. Prepare +pdf+ for deferred signing:
409
+ # returns a SigningSession whose #hash you send to a remote HSM. Build the CMS
410
+ # container, then call SigningSession#complete (or .complete_signature). The
411
+ # key never reaches this library.
412
+ def begin_signing(pdf, options: nil)
413
+ opts_bytes, keep = build_signing_options(options)
414
+ doc_p = Fiddle::Pointer.malloc(Native::SIZEOF_SZ, Fiddle::RUBY_FREE)
415
+ doc_n = Fiddle::Pointer.malloc(Native::SIZEOF_SZ, Fiddle::RUBY_FREE)
416
+ tbs_p = Fiddle::Pointer.malloc(Native::SIZEOF_SZ, Fiddle::RUBY_FREE)
417
+ tbs_n = Fiddle::Pointer.malloc(Native::SIZEOF_SZ, Fiddle::RUBY_FREE)
418
+ check(Native.call("pdf_sign_begin", pdf, pdf.bytesize, opts_bytes, doc_p, doc_n, tbs_p, tbs_n))
419
+ keep.clear
420
+ SigningSession.new(read_buffer(doc_p, doc_n), read_buffer(tbs_p, tbs_n))
421
+ end
422
+
423
+ # Model B — phase 2. Embed a complete DER CMS / PKCS#7 +container+ into a
424
+ # prepared +document+ (from #begin_signing), returning the final signed PDF.
425
+ def complete_signature(document, container)
426
+ take_bytes do |pp, pn|
427
+ Native.call("pdf_sign_complete", document, document.bytesize, container, container.bytesize, pp, pn)
428
+ end
429
+ end
430
+
431
+ # ---- network TSA (AD-RT) document timestamp — issue #41 -------------------
432
+
433
+ # Phase 1 of a network-TSA document timestamp (/DocTimeStamp). Prepares +pdf+
434
+ # and returns [document_bytes, tbs_bytes]: +tbs_bytes+ are the bytes whose
435
+ # SHA-256 forms the RFC 3161 message imprint. Build a TimeStampReq with
436
+ # #timestamp_request, POST it to the TSA, extract the token with
437
+ # #timestamp_token_from_response, then embed it via #complete_signature.
438
+ def begin_timestamp(pdf)
439
+ doc_p = Fiddle::Pointer.malloc(Native::SIZEOF_SZ, Fiddle::RUBY_FREE)
440
+ doc_n = Fiddle::Pointer.malloc(Native::SIZEOF_SZ, Fiddle::RUBY_FREE)
441
+ tbs_p = Fiddle::Pointer.malloc(Native::SIZEOF_SZ, Fiddle::RUBY_FREE)
442
+ tbs_n = Fiddle::Pointer.malloc(Native::SIZEOF_SZ, Fiddle::RUBY_FREE)
443
+ check(Native.call("pdf_timestamp_begin", pdf, pdf.bytesize, doc_p, doc_n, tbs_p, tbs_n))
444
+ [read_buffer(doc_p, doc_n), read_buffer(tbs_p, tbs_n)]
445
+ end
446
+
447
+ # Build an RFC 3161 TimeStampReq (DER) for +imprint+ (the SHA-256 of the bytes
448
+ # to timestamp, e.g. the +tbs_bytes+ from #begin_timestamp). +nonce+ is
449
+ # optional; +cert_req+ asks the TSA to embed its certificate. Returns the
450
+ # request bytes to POST to the TSA.
451
+ def timestamp_request(imprint, nonce: nil, cert_req: true)
452
+ take_bytes do |pp, pn|
453
+ Native.call("pdf_timestamp_request", imprint, imprint.bytesize,
454
+ nonce, nonce ? nonce.bytesize : 0, cert_req ? 1 : 0, pp, pn)
455
+ end
456
+ end
457
+
458
+ # Extract the TimeStampToken (a CMS ContentInfo) from a TSA's RFC 3161
459
+ # TimeStampResp +response+ bytes. The returned token is embedded via
460
+ # #complete_signature(document, token).
461
+ def timestamp_token_from_response(response)
462
+ take_bytes do |pp, pn|
463
+ Native.call("pdf_timestamp_token_from_response", response, response.bytesize, pp, pn)
464
+ end
465
+ end
466
+
467
+ # List the signature fields in +pdf+ (detect existing signatures before
468
+ # signing). Returns an Array of SignatureField; an empty Array means there are
469
+ # no signature fields.
470
+ def list_signatures(pdf)
471
+ text = take_bytes { |pp, pn| Native.call("pdf_list_signatures", pdf, pdf.bytesize, pp, pn) }
472
+ .force_encoding(Encoding::UTF_8)
473
+ fields = []
474
+ text.each_line do |line|
475
+ line = line.chomp
476
+ tab = line.index("\t")
477
+ next unless tab
478
+
479
+ fields << SignatureField.new(line[(tab + 1)..-1], line[0...tab] == "1")
480
+ end
481
+ fields
482
+ end
483
+
180
484
  # ---- internal helpers (used by Document/EditableDoc) ----------------------
181
485
 
486
+ # Marshal a SigningOptions (or nil) into the C PdfSigningOptions struct bytes,
487
+ # returning [packed_struct, keepalive] where +keepalive+ holds the Fiddle
488
+ # pointers backing the struct's string/byte fields (keep it referenced until
489
+ # the native call returns). 64-bit layout: 3 ptr, 2 int (8 bytes together),
490
+ # size_t, ptr, ptr, size_t, ptr, ptr, then the visible-signature tail:
491
+ # int (+ 4 pad), size_t, double[4], ptr, ptr, size_t.
492
+ def build_signing_options(options)
493
+ keep = []
494
+ cstr = lambda do |s|
495
+ return 0 if s.nil?
496
+
497
+ p = Fiddle::Pointer[s.to_s]
498
+ keep << p
499
+ p.to_i
500
+ end
501
+
502
+ reason = cstr.call(options&.reason)
503
+ location = cstr.call(options&.location)
504
+ name = cstr.call(options&.name)
505
+ pades = options&.pades ? 1 : 0
506
+ cert = (options&.certify || Certify::NONE).to_i
507
+ est = (options&.container_size || 0).to_i
508
+
509
+ policy_oid = 0
510
+ policy_hash = 0
511
+ policy_hash_len = 0
512
+ policy_alg = 0
513
+ policy_uri = 0
514
+ if (pol = options&.policy)
515
+ policy_oid = cstr.call(pol.oid)
516
+ if pol.hash && !pol.hash.empty?
517
+ hp = Fiddle::Pointer[pol.hash]
518
+ keep << hp
519
+ policy_hash = hp.to_i
520
+ policy_hash_len = pol.hash.bytesize
521
+ end
522
+ policy_alg = cstr.call(pol.hash_algorithm_oid)
523
+ policy_uri = cstr.call(pol.uri)
524
+ end
525
+
526
+ visible = options&.visible ? 1 : 0
527
+ vis_page = (options&.visible_page || 0).to_i
528
+ vis_rect = Array(options&.visible_rect || [0.0, 0.0, 0.0, 0.0]).map(&:to_f)[0, 4]
529
+ vis_rect += [0.0] * (4 - vis_rect.size)
530
+ vis_text = cstr.call(options&.visible_text)
531
+ vis_image = 0
532
+ vis_image_len = 0
533
+ if (img = options&.visible_image) && !img.empty?
534
+ ip = Fiddle::Pointer[img]
535
+ keep << ip
536
+ vis_image = ip.to_i
537
+ vis_image_len = img.bytesize
538
+ end
539
+
540
+ bytes = [reason, location, name].pack("J3") +
541
+ [pades, cert].pack("l2") +
542
+ [est, policy_oid, policy_hash, policy_hash_len, policy_alg, policy_uri].pack("J6") +
543
+ [visible].pack("l") + "\x00\x00\x00\x00".b + # int visible + 4-byte pad
544
+ [vis_page].pack("J") + vis_rect.pack("d4") +
545
+ [vis_text, vis_image, vis_image_len].pack("J3")
546
+ [bytes, keep]
547
+ end
548
+
549
+ # Read an out-buffer (pointer-to-pointer +pp+, pointer-to-len +pn+) into a
550
+ # binary String, freeing the native buffer.
551
+ def read_buffer(pp, pn)
552
+ len = pn[0, Native::SIZEOF_SZ].unpack1("J")
553
+ return "".b if len.zero?
554
+
555
+ dptr = pp.ptr
556
+ bytes = dptr[0, len]
557
+ Native.call("pdf_buffer_free", dptr, len)
558
+ bytes
559
+ end
560
+
182
561
  def last_error
183
562
  p = Native.call("pdf_last_error_message")
184
563
  p.null? ? "unknown error" : p.to_s
@@ -194,13 +573,7 @@ module RustPdf
194
573
  pp = Fiddle::Pointer.malloc(Native::SIZEOF_SZ, Fiddle::RUBY_FREE)
195
574
  pn = Fiddle::Pointer.malloc(Native::SIZEOF_SZ, Fiddle::RUBY_FREE)
196
575
  check(yield(pp, pn))
197
- len = pn[0, Native::SIZEOF_SZ].unpack1("J")
198
- return "".b if len.zero?
199
-
200
- dptr = pp.ptr
201
- bytes = dptr[0, len]
202
- Native.call("pdf_buffer_free", dptr, len)
203
- bytes
576
+ read_buffer(pp, pn)
204
577
  end
205
578
 
206
579
  # Run a producer { |out_int| status } and return the written int.
Binary file
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rustpdf
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.3
4
+ version: 0.4.5
5
5
  platform: x86_64-linux
6
6
  authors:
7
7
  - rust-pdf
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-29 00:00:00.000000000 Z
11
+ date: 2026-06-30 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Idiomatic Ruby binding over the rust-pdf C ABI (libpdf_ffi) using the
14
14
  built-in Fiddle stdlib.