rustpdf 0.4.3-universal-darwin → 0.4.5-universal-darwin
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 +4 -4
- data/README.md +32 -0
- data/lib/rustpdf/editable_doc.rb +68 -6
- data/lib/rustpdf/native.rb +27 -2
- data/lib/rustpdf.rb +382 -9
- data/vendor/universal-darwin/libpdf_ffi.dylib +0 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bf26136e96e0bb69576cf294f81bbe62b5b9974c4a0646fac69ed40e72ffa82d
|
|
4
|
+
data.tar.gz: f60566e9fc77e467e8b29328769a86fd3ec013d5a93a00bc7453ba8a12e7cef7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c7330892e700726c8f1f8d4b3e8bd4876afc20b3d24909d8e3a3bc154f5a3130a9fe456aadbae0b1f31c8a3dd33046c17945cc6cf32bb0f3f1a55d16be67dd40
|
|
7
|
+
data.tar.gz: ddaaf60885bdccea7dd06eb990d9af4b2e021b313f51a8f5e9dd035de833c1d15a8ba6981b6b8a0359dde450c8669bf977b221a3a18a45f7e6bcc8e8b8a06294
|
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
|
data/lib/rustpdf/editable_doc.rb
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
|
|
139
|
-
|
|
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
|
data/lib/rustpdf/native.rb
CHANGED
|
@@ -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
|
-
|
|
101
|
-
|
|
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"
|
|
139
|
-
#
|
|
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
|
-
|
|
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.
|
|
4
|
+
version: 0.4.5
|
|
5
5
|
platform: universal-darwin
|
|
6
6
|
authors:
|
|
7
7
|
- rust-pdf
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-06-
|
|
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.
|