rustpdf 0.4.2-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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ce17d194965c20b86223b43559f0ca79474bbf68aa91f0056450f37dcec8ce10
4
+ data.tar.gz: 577d19f25343d9b07eb83c71c0937972fd750b8975b424e49b1a2bfaf9246772
5
+ SHA512:
6
+ metadata.gz: 5899b6c3bc70d54924495be0fa652a256603f100694b98c888dcb4fe49c791a347cd3859aa1ba133c51a2da3d741ae2c67857c1ed907efeaa33a3a5264698117
7
+ data.tar.gz: 9196f54d4fc7beef6e6c5d8bb2196ae8dfdc39b7722d025879a00d59b47f5b04e7fff842bd7f770d8ab42321129a51475646a8e1ab3a679b6b490508db2cb683
data/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # rustpdf (Ruby binding)
2
+
3
+ Idiomatic Ruby binding for the `rust-pdf` core over its C ABI (`libpdf_ffi`),
4
+ using the built-in **Fiddle** standard library — no native gem to compile. It
5
+ covers the whole product surface: vector graphics, embedded/subsetted fonts and
6
+ text, wrapping paragraphs, images, **PDF/A** (levels 1b–3a),
7
+ **tagged/accessible** output, embedded-file attachments, **AcroForm** fields,
8
+ manipulation (merge/split/rotate/optimize/incremental update), **text
9
+ extraction**, **page rendering** (page to PNG image), **encryption** (RC4 / AES-128 / AES-256) and **digital signatures**
10
+ (PKCS#7 / PAdES) — plus **feature licensing**.
11
+
12
+ Files (module `RustPdf`):
13
+
14
+ * `lib/rustpdf.rb` — module functions (`version`, `activate_license`,
15
+ `extract_text`, `sign`, `timestamp`, `add_dss`), enums, error, helpers;
16
+ * `lib/rustpdf/native.rb` — the Fiddle signature table + loader;
17
+ * `lib/rustpdf/document.rb`, `editable_doc.rb` — the `Document` / `EditableDoc`
18
+ classes.
19
+
20
+ ## Loading the native library
21
+
22
+ `Native` finds `libpdf_ffi` via `RUSTPDF_LIB`, then by walking up from `lib/` to
23
+ `target/{debug,release}`. Build it from the repo root with
24
+ `cargo build -p pdf-ffi`. Requires Ruby ≥ 2.6 (Fiddle is bundled).
25
+
26
+ ## Quick start
27
+
28
+ ```ruby
29
+ require "rustpdf"
30
+
31
+ RustPdf.activate_license(token) # or set RUSTPDF_LICENSE (auto-activated)
32
+
33
+ doc = RustPdf::Document.new
34
+ doc.pdfa(RustPdf::Pdfa::A2A).info(title: "Report")
35
+ f = doc.add_font_file("assets/fonts/Roboto-Regular.ttf")
36
+ doc.add_page
37
+ .show_text(f, 20, 72, 760, "Title", heading_level: 1)
38
+ .paragraph(f, 12, 72, 720, 450, "A wrapping body…", align: RustPdf::Align::JUSTIFY)
39
+ data = doc.to_bytes
40
+
41
+ puts RustPdf.extract_text(data)
42
+
43
+ ed = RustPdf::EditableDoc.load(data)
44
+ ed.encrypt(method: RustPdf::Cipher::AES256, owner: "owner").save("secured.pdf")
45
+
46
+ signed = RustPdf.sign(data, key_der, cert_der, pades: true)
47
+ ```
48
+
49
+ Corporate features (PDF/A, signing, encryption, accessibility, page rendering — a **Pro** feature) require a license;
50
+ without one they raise `RustPdf::Error`. See [`docs/LICENSING.md`](../../docs/LICENSING.md).
51
+
52
+ ## Test
53
+
54
+ ```sh
55
+ cargo build -p pdf-ffi
56
+ ruby bindings/ruby/test/run.rb # or: make ruby-test
57
+ ```
@@ -0,0 +1,224 @@
1
+ module RustPdf
2
+ # A PDF document being authored. Call #close (or rely on GC) to free.
3
+ class Document
4
+ def initialize
5
+ @ptr = Native.call("pdf_document_new")
6
+ raise Error, "pdf_document_new returned NULL" if @ptr.null?
7
+ end
8
+
9
+ def close
10
+ return if @ptr.nil? || @ptr.null?
11
+
12
+ Native.call("pdf_document_free", @ptr)
13
+ @ptr = nil
14
+ end
15
+
16
+ # ---- configuration ------------------------------------------------------
17
+
18
+ def pdfa(level = nil)
19
+ RustPdf.check(
20
+ level ? Native.call("pdf_document_pdfa_level", ptr, level) : Native.call("pdf_document_pdfa", ptr)
21
+ )
22
+ self
23
+ end
24
+
25
+ def tagged
26
+ RustPdf.check(Native.call("pdf_document_tagged", ptr))
27
+ self
28
+ end
29
+
30
+ def version=(v)
31
+ RustPdf.check(Native.call("pdf_document_set_version", ptr, v))
32
+ end
33
+
34
+ def default_size(width, height)
35
+ RustPdf.check(Native.call("pdf_document_set_default_size", ptr, width, height))
36
+ self
37
+ end
38
+
39
+ def info(title: nil, author: nil, subject: nil, keywords: nil, creator: nil)
40
+ RustPdf.check(Native.call("pdf_document_set_info", ptr, title, author, subject, keywords, creator))
41
+ self
42
+ end
43
+
44
+ # ---- pages + graphics ---------------------------------------------------
45
+
46
+ def add_page(width: nil, height: nil)
47
+ RustPdf.check(
48
+ width && height ? Native.call("pdf_document_add_page_sized", ptr, width, height)
49
+ : Native.call("pdf_document_add_page", ptr)
50
+ )
51
+ self
52
+ end
53
+
54
+ def fill_rgb(r, g, b)
55
+ RustPdf.check(Native.call("pdf_page_set_fill_rgb", ptr, r, g, b))
56
+ self
57
+ end
58
+
59
+ def stroke_rgb(r, g, b)
60
+ RustPdf.check(Native.call("pdf_page_set_stroke_rgb", ptr, r, g, b))
61
+ self
62
+ end
63
+
64
+ def line_width(w)
65
+ RustPdf.check(Native.call("pdf_page_set_line_width", ptr, w))
66
+ self
67
+ end
68
+
69
+ def rect(x, y, w, h)
70
+ RustPdf.check(Native.call("pdf_page_rect", ptr, x, y, w, h))
71
+ self
72
+ end
73
+
74
+ def fill
75
+ RustPdf.check(Native.call("pdf_page_fill", ptr))
76
+ self
77
+ end
78
+
79
+ def stroke
80
+ RustPdf.check(Native.call("pdf_page_stroke", ptr))
81
+ self
82
+ end
83
+
84
+ # ---- fonts + text -------------------------------------------------------
85
+
86
+ def add_font_file(path)
87
+ RustPdf.out_int { |buf| Native.call("pdf_document_add_font_file", ptr, path, buf) }
88
+ end
89
+
90
+ def add_font(data)
91
+ RustPdf.out_int { |buf| Native.call("pdf_document_add_font", ptr, data, data.bytesize, buf) }
92
+ end
93
+
94
+ def show_text(font, size, x, y, text, heading_level: 0)
95
+ RustPdf.check(Native.call("pdf_page_show_text", ptr, font, size, x, y, text, heading_level))
96
+ self
97
+ end
98
+
99
+ def paragraph(font, size, x, y, width, text, align: Align::LEFT)
100
+ RustPdf.check(Native.call("pdf_page_paragraph", ptr, font, size, x, y, width, align, text))
101
+ self
102
+ end
103
+
104
+ # ---- images -------------------------------------------------------------
105
+
106
+ def add_image_file(path)
107
+ RustPdf.out_int { |buf| Native.call("pdf_document_add_image_file", ptr, path, buf) }
108
+ end
109
+
110
+ def add_image_png(data)
111
+ RustPdf.out_int { |buf| Native.call("pdf_document_add_image_png", ptr, data, data.bytesize, buf) }
112
+ end
113
+
114
+ def add_image_jpeg(data)
115
+ RustPdf.out_int { |buf| Native.call("pdf_document_add_image_jpeg", ptr, data, data.bytesize, buf) }
116
+ end
117
+
118
+ def draw_image(image, x, y, w, h)
119
+ RustPdf.check(Native.call("pdf_page_draw_image", ptr, image, x, y, w, h))
120
+ self
121
+ end
122
+
123
+ def figure(image, x, y, w, h, alt)
124
+ RustPdf.check(Native.call("pdf_page_figure", ptr, image, x, y, w, h, alt))
125
+ self
126
+ end
127
+
128
+ # ---- attachments + forms ------------------------------------------------
129
+
130
+ def attach_file(name, mime, data, relationship: Relationship::SOURCE, description: "")
131
+ RustPdf.check(Native.call("pdf_document_attach_file", ptr, name, mime, data, data.bytesize, relationship, description))
132
+ self
133
+ end
134
+
135
+ # rect = [x0, y0, x1, y1]
136
+ def text_field(name, page, rect, value: "", size: 0.0)
137
+ RustPdf.check(Native.call("pdf_document_text_field", ptr, name, page, rect[0], rect[1], rect[2], rect[3], value, size))
138
+ self
139
+ end
140
+
141
+ def checkbox(name, page, rect, checked)
142
+ RustPdf.check(Native.call("pdf_document_checkbox", ptr, name, page, rect[0], rect[1], rect[2], rect[3], checked ? 1 : 0))
143
+ self
144
+ end
145
+
146
+ # options: Array<String>; selected: index or nil
147
+ def dropdown(name, page, rect, options, selected: nil, size: 0.0)
148
+ RustPdf.check(Native.call("pdf_document_dropdown", ptr, name, page, rect[0], rect[1], rect[2], rect[3],
149
+ options.join("\n"), selected || -1, size))
150
+ self
151
+ end
152
+
153
+ # buttons: Array<[ [x0,y0,x1,y1], "export" ]>; selected: index or nil
154
+ def radio_group(name, page, buttons, selected: nil)
155
+ rects = buttons.flat_map { |(r, _)| r }.pack("d*")
156
+ cstrs = buttons.map { |(_, e)| Fiddle::Pointer[e.to_s] }
157
+ addrs = cstrs.map(&:to_i).pack("J*")
158
+ RustPdf.check(Native.call("pdf_document_radio_group", ptr, name, page, buttons.size, rects, addrs, selected || -1))
159
+ cstrs.clear # released after the call returned
160
+ self
161
+ end
162
+
163
+ # ---- hyperlinks + bookmarks (Tier 1) ------------------------------------
164
+
165
+ # rect = [x0, y0, x1, y1]; link to an external URI.
166
+ def link_uri(rect, uri)
167
+ RustPdf.check(Native.call("pdf_page_link_uri", ptr, rect[0], rect[1], rect[2], rect[3], uri))
168
+ self
169
+ end
170
+
171
+ # rect = [x0, y0, x1, y1]; link to another page (optional +top+ y-offset).
172
+ def link_to_page(rect, page_index, top: nil)
173
+ RustPdf.check(Native.call("pdf_page_link_to_page", ptr, rect[0], rect[1], rect[2], rect[3],
174
+ page_index, top || 0.0, top.nil? ? 0 : 1))
175
+ self
176
+ end
177
+
178
+ # Append one outline tree (a RustPdf::Bookmark). Pre-order flattened into
179
+ # parallel arrays and emitted in a single native call.
180
+ def add_bookmark(bookmark)
181
+ entries = bookmark.flatten_into(0, [])
182
+ n = entries.size
183
+ levels = entries.map { |e| e[0] }.pack("i!*")
184
+ pages = entries.map { |e| e[2] }.pack("J*")
185
+ tops = entries.map { |e| e[3] || 0.0 }.pack("d*")
186
+ has = entries.map { |e| e[3].nil? ? 0 : 1 }.pack("i!*")
187
+ cstrs = entries.map { |e| Fiddle::Pointer[e[1].to_s] }
188
+ titles = cstrs.map(&:to_i).pack("J*")
189
+ RustPdf.check(Native.call("pdf_document_add_bookmarks", ptr, n, levels, titles, pages, tops, has))
190
+ cstrs.clear # released after the call returned
191
+ self
192
+ end
193
+
194
+ # ---- ZUGFeRD / Factur-X (Tier 2) ----------------------------------------
195
+
196
+ def facturx(xml, profile: FacturxProfile::EN16931)
197
+ RustPdf.check(Native.call("pdf_document_facturx", ptr, xml, xml.bytesize, profile))
198
+ self
199
+ end
200
+
201
+ # ---- output -------------------------------------------------------------
202
+
203
+ def page_count
204
+ Native.call("pdf_document_page_count", ptr)
205
+ end
206
+
207
+ def to_bytes
208
+ p = ptr
209
+ RustPdf.take_bytes { |pp, pn| Native.call("pdf_document_write", p, pp, pn) }
210
+ end
211
+
212
+ def save(path)
213
+ RustPdf.check(Native.call("pdf_document_save", ptr, path))
214
+ end
215
+
216
+ private
217
+
218
+ def ptr
219
+ raise Error, "operation on a closed Document" if @ptr.nil? || @ptr.null?
220
+
221
+ @ptr
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,199 @@
1
+ module RustPdf
2
+ # An existing PDF loaded for manipulation. Call #close (or rely on GC) to free.
3
+ class EditableDoc
4
+ # Load a PDF from bytes (optionally with a password).
5
+ def self.load(data, password: nil)
6
+ ptr = if password
7
+ Native.call("pdf_editable_load_password", data, data.bytesize, password)
8
+ else
9
+ Native.call("pdf_editable_load", data, data.bytesize)
10
+ end
11
+ raise Error, RustPdf.last_error if ptr.null?
12
+
13
+ new(ptr)
14
+ end
15
+
16
+ def self.load_file(path, password: nil)
17
+ load(File.binread(path), password: password)
18
+ end
19
+
20
+ def initialize(ptr)
21
+ @ptr = ptr
22
+ end
23
+ private_class_method :new
24
+
25
+ def close
26
+ return if @ptr.nil? || @ptr.null?
27
+
28
+ Native.call("pdf_editable_free", @ptr)
29
+ @ptr = nil
30
+ end
31
+
32
+ def page_count
33
+ Native.call("pdf_editable_page_count", ptr)
34
+ end
35
+
36
+ def merge(other)
37
+ RustPdf.check(Native.call("pdf_editable_merge", ptr, other.send(:ptr)))
38
+ self
39
+ end
40
+
41
+ def rotate_page(index, degrees)
42
+ RustPdf.check(Native.call("pdf_editable_rotate_page", ptr, index, degrees))
43
+ self
44
+ end
45
+
46
+ def delete_page(index)
47
+ RustPdf.check(Native.call("pdf_editable_delete_page", ptr, index))
48
+ self
49
+ end
50
+
51
+ def reorder_pages(order)
52
+ buf = order.pack("J*")
53
+ RustPdf.check(Native.call("pdf_editable_reorder_pages", ptr, buf, order.size))
54
+ self
55
+ end
56
+
57
+ def extract_pages(indices)
58
+ buf = indices.pack("J*")
59
+ out = Fiddle::Pointer.malloc(Native::SIZEOF_SZ, Fiddle::RUBY_FREE)
60
+ RustPdf.check(Native.call("pdf_editable_extract_pages", ptr, buf, indices.size, out))
61
+ EditableDoc.send(:new, out.ptr)
62
+ end
63
+
64
+ def info=(pair)
65
+ key, value = pair
66
+ RustPdf.check(Native.call("pdf_editable_set_info", ptr, key, value))
67
+ end
68
+
69
+ def set_info(key, value)
70
+ RustPdf.check(Native.call("pdf_editable_set_info", ptr, key, value))
71
+ self
72
+ end
73
+
74
+ def get_info(key)
75
+ p = ptr
76
+ RustPdf.take_bytes { |pp, pn| Native.call("pdf_editable_get_info", p, key, pp, pn) }
77
+ .force_encoding(Encoding::UTF_8)
78
+ end
79
+
80
+ def set_xmp(xml)
81
+ RustPdf.check(Native.call("pdf_editable_set_xmp", ptr, xml, xml.bytesize))
82
+ self
83
+ end
84
+
85
+ def overlay_page(index, content)
86
+ RustPdf.check(Native.call("pdf_editable_overlay_page", ptr, index, content, content.bytesize))
87
+ self
88
+ end
89
+
90
+ # Returns whether the field existed.
91
+ def fill_text_field(name, value)
92
+ found = RustPdf.out_int { |buf| Native.call("pdf_editable_fill_text_field", ptr, name, value, buf) }
93
+ found != 0
94
+ end
95
+
96
+ # ---- form fill + flatten + watermark (Tier 1) ---------------------------
97
+
98
+ # Set a checkbox by field name. Returns whether the field existed.
99
+ def set_checkbox(name, checked = true)
100
+ found = RustPdf.out_int { |buf| Native.call("pdf_editable_set_checkbox", ptr, name, checked ? 1 : 0, buf) }
101
+ found != 0
102
+ end
103
+
104
+ # Select a radio button by field name + export value. Returns whether found.
105
+ def set_radio(name, export_value)
106
+ found = RustPdf.out_int { |buf| Native.call("pdf_editable_set_radio", ptr, name, export_value, buf) }
107
+ found != 0
108
+ end
109
+
110
+ # Set a choice (dropdown/list) value by field name. Returns whether found.
111
+ def set_choice(name, value)
112
+ found = RustPdf.out_int { |buf| Native.call("pdf_editable_set_choice", ptr, name, value, buf) }
113
+ found != 0
114
+ end
115
+
116
+ # Flatten all AcroForm fields into page content (removes interactivity).
117
+ def flatten_forms
118
+ RustPdf.check(Native.call("pdf_editable_flatten_forms", ptr))
119
+ self
120
+ end
121
+
122
+ # Returns the list of AcroForm field names.
123
+ def field_names
124
+ p = ptr
125
+ text = RustPdf.take_bytes { |pp, pn| Native.call("pdf_editable_field_names", p, pp, pn) }
126
+ .force_encoding(Encoding::UTF_8)
127
+ text.split("\n").reject(&:empty?)
128
+ end
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)
132
+ r, g, b = color
133
+ RustPdf.check(Native.call("pdf_editable_watermark_text", ptr, text, size, r, g, b, opacity, rotation_deg))
134
+ self
135
+ end
136
+
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))
140
+ self
141
+ end
142
+
143
+ # ---- redaction + PDF/A conversion (Tier 2) ------------------------------
144
+
145
+ # Black out rectangles on a page. rects = [[x0,y0,x1,y1], ...].
146
+ # Returns whether the page existed.
147
+ def redact(page_index, rects)
148
+ flat = rects.flatten.pack("d*")
149
+ found = RustPdf.out_int { |buf| Native.call("pdf_editable_redact", ptr, page_index, flat, rects.size, buf) }
150
+ found != 0
151
+ end
152
+
153
+ # Convert the document to PDF/A (B-levels only: A1B=0, A2B=1, A3B=3).
154
+ def convert_to_pdfa(level = Pdfa::A2B)
155
+ RustPdf.check(Native.call("pdf_editable_convert_to_pdfa", ptr, level))
156
+ self
157
+ end
158
+
159
+ def optimize
160
+ RustPdf.check(Native.call("pdf_editable_optimize", ptr))
161
+ self
162
+ end
163
+
164
+ def compact(on = true)
165
+ RustPdf.check(Native.call("pdf_editable_compact", ptr, on ? 1 : 0))
166
+ self
167
+ end
168
+
169
+ # Encrypt on save (requires a license).
170
+ def encrypt(method: Cipher::AES256, user: "", owner: "", read_only: false)
171
+ RustPdf.check(Native.call("pdf_editable_encrypt", ptr, method, user, owner, read_only ? 1 : 0))
172
+ self
173
+ end
174
+
175
+ def to_bytes
176
+ p = ptr
177
+ RustPdf.take_bytes { |pp, pn| Native.call("pdf_editable_to_bytes", p, pp, pn) }
178
+ end
179
+
180
+ def to_bytes_incremental(original)
181
+ p = ptr
182
+ RustPdf.take_bytes do |pp, pn|
183
+ Native.call("pdf_editable_to_bytes_incremental", p, original, original.bytesize, pp, pn)
184
+ end
185
+ end
186
+
187
+ def save(path)
188
+ RustPdf.check(Native.call("pdf_editable_save", ptr, path))
189
+ end
190
+
191
+ private
192
+
193
+ def ptr
194
+ raise Error, "operation on a closed EditableDoc" if @ptr.nil? || @ptr.null?
195
+
196
+ @ptr
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,166 @@
1
+ require "fiddle"
2
+
3
+ module RustPdf
4
+ # Internal: dlopen's libpdf_ffi and builds memoized Fiddle::Function objects
5
+ # from a signature table. Not part of the public API.
6
+ module Native
7
+ module_function
8
+
9
+ VP = Fiddle::TYPE_VOIDP
10
+ I = Fiddle::TYPE_INT
11
+ D = Fiddle::TYPE_DOUBLE
12
+ SZ = Fiddle::TYPE_SIZE_T
13
+ VOID = Fiddle::TYPE_VOID
14
+
15
+ # size_t / uintptr_t and int widths (for out-parameter buffers).
16
+ SIZEOF_SZ = Fiddle::SIZEOF_VOIDP
17
+ SIZEOF_INT = Fiddle::SIZEOF_INT
18
+
19
+ SIGS = {
20
+ "pdf_version" => [[], VP],
21
+ "pdf_last_error_message" => [[], VP],
22
+ "pdf_activate_license" => [[VP], I],
23
+ "pdf_buffer_free" => [[VP, SZ], VOID],
24
+
25
+ "pdf_document_new" => [[], VP],
26
+ "pdf_document_free" => [[VP], VOID],
27
+ "pdf_document_add_page" => [[VP], I],
28
+ "pdf_document_add_page_sized" => [[VP, D, D], I],
29
+ "pdf_document_page_count" => [[VP], I],
30
+ "pdf_page_set_fill_rgb" => [[VP, D, D, D], I],
31
+ "pdf_page_set_stroke_rgb" => [[VP, D, D, D], I],
32
+ "pdf_page_set_line_width" => [[VP, D], I],
33
+ "pdf_page_rect" => [[VP, D, D, D, D], I],
34
+ "pdf_page_fill" => [[VP], I],
35
+ "pdf_page_stroke" => [[VP], I],
36
+ "pdf_document_save" => [[VP, VP], I],
37
+ "pdf_document_write" => [[VP, VP, VP], I],
38
+ "pdf_document_pdfa" => [[VP], I],
39
+ "pdf_document_pdfa_level" => [[VP, I], I],
40
+ "pdf_document_tagged" => [[VP], I],
41
+ "pdf_document_set_version" => [[VP, I], I],
42
+ "pdf_document_set_default_size" => [[VP, D, D], I],
43
+ "pdf_document_set_info" => [[VP, VP, VP, VP, VP, VP], I],
44
+ "pdf_document_add_font_file" => [[VP, VP, VP], I],
45
+ "pdf_document_add_font" => [[VP, VP, SZ, VP], I],
46
+ "pdf_page_show_text" => [[VP, I, D, D, D, VP, I], I],
47
+ "pdf_page_paragraph" => [[VP, I, D, D, D, D, I, VP], I],
48
+ "pdf_document_add_image_file" => [[VP, VP, VP], I],
49
+ "pdf_document_add_image_png" => [[VP, VP, SZ, VP], I],
50
+ "pdf_document_add_image_jpeg" => [[VP, VP, SZ, VP], I],
51
+ "pdf_page_draw_image" => [[VP, I, D, D, D, D], I],
52
+ "pdf_page_figure" => [[VP, I, D, D, D, D, VP], I],
53
+ "pdf_document_attach_file" => [[VP, VP, VP, VP, SZ, I, VP], I],
54
+ "pdf_document_text_field" => [[VP, VP, SZ, D, D, D, D, VP, D], I],
55
+ "pdf_document_checkbox" => [[VP, VP, SZ, D, D, D, D, I], I],
56
+ "pdf_document_dropdown" => [[VP, VP, SZ, D, D, D, D, VP, I, D], I],
57
+ "pdf_document_radio_group" => [[VP, VP, SZ, SZ, VP, VP, I], I],
58
+
59
+ "pdf_editable_load" => [[VP, SZ], VP],
60
+ "pdf_editable_load_password" => [[VP, SZ, VP], VP],
61
+ "pdf_editable_free" => [[VP], VOID],
62
+ "pdf_editable_page_count" => [[VP], I],
63
+ "pdf_editable_merge" => [[VP, VP], I],
64
+ "pdf_editable_rotate_page" => [[VP, SZ, I], I],
65
+ "pdf_editable_delete_page" => [[VP, SZ], I],
66
+ "pdf_editable_reorder_pages" => [[VP, VP, SZ], I],
67
+ "pdf_editable_extract_pages" => [[VP, VP, SZ, VP], I],
68
+ "pdf_editable_set_info" => [[VP, VP, VP], I],
69
+ "pdf_editable_get_info" => [[VP, VP, VP, VP], I],
70
+ "pdf_editable_set_xmp" => [[VP, VP, SZ], I],
71
+ "pdf_editable_overlay_page" => [[VP, SZ, VP, SZ], I],
72
+ "pdf_editable_fill_text_field" => [[VP, VP, VP, VP], I],
73
+ "pdf_editable_optimize" => [[VP], I],
74
+ "pdf_editable_compact" => [[VP, I], I],
75
+ "pdf_editable_encrypt" => [[VP, I, VP, VP, I], I],
76
+ "pdf_editable_to_bytes" => [[VP, VP, VP], I],
77
+ "pdf_editable_to_bytes_incremental" => [[VP, VP, SZ, VP, VP], I],
78
+ "pdf_editable_save" => [[VP, VP], I],
79
+
80
+ "pdf_extract_text" => [[VP, SZ, VP, VP], I],
81
+ "pdf_extract_images_to_dir" => [[VP, SZ, VP, VP], I],
82
+ "pdf_render_page_to_png" => [[VP, SZ, SZ, D, VP, VP], I],
83
+ "pdf_page_count" => [[VP, SZ, VP], I],
84
+ "pdf_sign" => [[VP, SZ, VP, SZ, VP, SZ, VP, VP, VP, I, VP, VP], I],
85
+ "pdf_timestamp" => [[VP, SZ, VP, SZ, VP, SZ, VP, VP, VP], I],
86
+ "pdf_add_dss" => [[VP, SZ, VP, VP, SZ, VP, VP, SZ, VP, VP], I],
87
+
88
+ # Tier 1: hyperlinks + bookmarks (Document)
89
+ "pdf_page_link_uri" => [[VP, D, D, D, D, VP], I],
90
+ "pdf_page_link_to_page" => [[VP, D, D, D, D, SZ, D, I], I],
91
+ "pdf_document_add_bookmarks" => [[VP, SZ, VP, VP, VP, VP, VP], I],
92
+ # Tier 2: ZUGFeRD / Factur-X (Document)
93
+ "pdf_document_facturx" => [[VP, VP, SZ, I], I],
94
+ # Tier 1: form fill + flatten + watermark (EditableDoc)
95
+ "pdf_editable_set_checkbox" => [[VP, VP, I, VP], I],
96
+ "pdf_editable_set_radio" => [[VP, VP, VP, VP], I],
97
+ "pdf_editable_set_choice" => [[VP, VP, VP, VP], I],
98
+ "pdf_editable_flatten_forms" => [[VP], I],
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],
102
+ # Tier 2: redaction + PDF/A conversion (EditableDoc)
103
+ "pdf_editable_redact" => [[VP, SZ, VP, SZ, VP], I],
104
+ "pdf_editable_convert_to_pdfa" => [[VP, I], I],
105
+ # Tier 2: signature validation (module-level)
106
+ "pdf_verify_signatures_json" => [[VP, SZ, VP, VP], I],
107
+ }.freeze
108
+
109
+ def lib
110
+ @lib ||= Fiddle.dlopen(lib_path)
111
+ end
112
+
113
+ def [](name)
114
+ @fns ||= {}
115
+ @fns[name] ||= begin
116
+ args, ret = SIGS.fetch(name)
117
+ Fiddle::Function.new(lib[name], args, ret)
118
+ end
119
+ end
120
+
121
+ def call(name, *args)
122
+ self[name].call(*args)
123
+ end
124
+
125
+ def lib_path
126
+ env = ENV["RUSTPDF_LIB"]
127
+ return env if env && !env.empty? && File.file?(env)
128
+
129
+ file = lib_file_name
130
+
131
+ # Packaged gem: the platform-specific cdylib is vendored under
132
+ # vendor/<gem-platform>/ (staged by CI, see release-ruby.yml). Matches the
133
+ # per-platform wheel/npm-package layout used by the Python/Node bindings.
134
+ # A platform gem ships exactly one vendor/<plat>/ dir, so the first match
135
+ # for this OS's lib file name is the right one — no platform-string parsing.
136
+ vendored = Dir[File.join(gem_root, "vendor", "*", file)].find { |p| File.file?(p) }
137
+ return vendored if vendored
138
+
139
+ # Monorepo dev: walk up from lib/ to the Cargo build tree.
140
+ dir = __dir__
141
+ 10.times do
142
+ %w[debug release].each do |profile|
143
+ candidate = File.join(dir, "target", profile, file)
144
+ return candidate if File.file?(candidate)
145
+ end
146
+ parent = File.dirname(dir)
147
+ break if parent == dir
148
+ dir = parent
149
+ end
150
+ raise Error, "could not locate #{file}; build it with `cargo build -p pdf-ffi` or set RUSTPDF_LIB"
151
+ end
152
+
153
+ # Repo/gem root = two levels up from lib/rustpdf/.
154
+ def gem_root
155
+ File.expand_path("../..", __dir__)
156
+ end
157
+
158
+ def lib_file_name
159
+ case RbConfig::CONFIG["host_os"]
160
+ when /mswin|mingw|cygwin/ then "pdf_ffi.dll"
161
+ when /darwin/ then "libpdf_ffi.dylib"
162
+ else "libpdf_ffi.so"
163
+ end
164
+ end
165
+ end
166
+ end
data/lib/rustpdf.rb ADDED
@@ -0,0 +1,215 @@
1
+ require "fiddle"
2
+ require "json"
3
+ require_relative "rustpdf/native"
4
+
5
+ # Idiomatic Ruby binding for the rust-pdf core over its C ABI (libpdf_ffi),
6
+ # using the built-in Fiddle stdlib. Covers the whole product surface: vector
7
+ # graphics, fonts/text, paragraphs, images, PDF/A (1b-3a), tagged/accessible
8
+ # output, attachments, AcroForm fields, manipulation, text extraction,
9
+ # encryption and digital signatures, plus feature licensing.
10
+ module RustPdf
11
+ # Raised when a native call returns a non-zero PdfStatus.
12
+ class Error < StandardError
13
+ attr_reader :status
14
+
15
+ def initialize(message, status = 0)
16
+ @status = status
17
+ super(status.zero? ? message : "PdfStatus=#{status}: #{message}")
18
+ end
19
+ end
20
+
21
+ # PDF/A conformance levels.
22
+ module Pdfa
23
+ A1B = 0
24
+ A2B = 1
25
+ A2A = 2
26
+ A3B = 3
27
+ A3A = 4
28
+ # PDF/A-4 (ISO 19005-4), based on PDF 2.0.
29
+ A4 = 5
30
+ A4E = 6
31
+ A4F = 7
32
+ end
33
+
34
+ # Paragraph alignment.
35
+ module Align
36
+ LEFT = 0
37
+ RIGHT = 1
38
+ CENTER = 2
39
+ JUSTIFY = 3
40
+ end
41
+
42
+ # Embedded-file relationship (PDF/A-3).
43
+ module Relationship
44
+ SOURCE = 0
45
+ DATA = 1
46
+ ALTERNATIVE = 2
47
+ SUPPLEMENT = 3
48
+ UNSPECIFIED = 4
49
+ end
50
+
51
+ # Encryption ciphers.
52
+ module Cipher
53
+ RC4 = 0
54
+ AES128 = 1
55
+ AES256 = 2
56
+ end
57
+
58
+ # ZUGFeRD / Factur-X conformance profiles.
59
+ module FacturxProfile
60
+ MINIMUM = 0
61
+ BASIC_WL = 1
62
+ BASIC = 2
63
+ EN16931 = 3
64
+ EXTENDED = 4
65
+ end
66
+
67
+ # A document outline (bookmark) entry. Nest with #child to build a tree.
68
+ # Each #add_bookmark call on a Document appends one root tree (pre-order
69
+ # flattened into parallel arrays).
70
+ class Bookmark
71
+ attr_accessor :title, :page, :top, :children
72
+
73
+ def initialize(title, page, top: nil, children: nil)
74
+ @title = title
75
+ @page = page
76
+ @top = top
77
+ @children = children || []
78
+ end
79
+
80
+ # Append a child bookmark; returns self for chaining.
81
+ def child(bookmark)
82
+ @children << bookmark
83
+ self
84
+ end
85
+
86
+ # Pre-order flatten into +out+ as [level, title, page, top] tuples.
87
+ def flatten_into(level, out)
88
+ out << [level, title, page, top]
89
+ children.each { |c| c.flatten_into(level + 1, out) }
90
+ out
91
+ end
92
+ end
93
+
94
+ module_function
95
+
96
+ # Native library version string.
97
+ def version
98
+ Native.call("pdf_version").to_s
99
+ end
100
+
101
+ # Activate a license token (unlocks PDF/A, signing, encryption,
102
+ # accessibility). Tokens may also be supplied via the RUSTPDF_LICENSE /
103
+ # RUSTPDF_LICENSE_FILE environment variables (auto-activated).
104
+ def activate_license(token)
105
+ check(Native.call("pdf_activate_license", token))
106
+ end
107
+
108
+ # Extract a document's text (Unicode via ToUnicode).
109
+ def extract_text(pdf)
110
+ take_bytes { |pp, pn| Native.call("pdf_extract_text", pdf, pdf.bytesize, pp, pn) }
111
+ .force_encoding(Encoding::UTF_8)
112
+ end
113
+
114
+ # Extract every raster image into +dir+ (JPEG verbatim as .jpg, others as
115
+ # .png; files named page{N}_{name}.{ext}). Returns the number written.
116
+ def extract_images_to_dir(pdf, dir)
117
+ count = Fiddle::Pointer.malloc(Native::SIZEOF_SZ, Fiddle::RUBY_FREE)
118
+ check(Native.call("pdf_extract_images_to_dir", pdf, pdf.bytesize, dir, count))
119
+ count[0, Native::SIZEOF_SZ].unpack1("J")
120
+ end
121
+
122
+ # Render page +page+ (0-based) of +pdf+ to a PNG image at +dpi+
123
+ # dots-per-inch. Page rendering is a licensed Pro feature: raises unless a
124
+ # license granting it is active.
125
+ def render_page_to_png(pdf, page = 0, dpi = 150.0)
126
+ take_bytes { |pp, pn| Native.call("pdf_render_page_to_png", pdf, pdf.bytesize, page, dpi.to_f, pp, pn) }
127
+ end
128
+
129
+ # Number of pages in +pdf+ (free — no license required).
130
+ def page_count(pdf)
131
+ count = Fiddle::Pointer.malloc(Native::SIZEOF_SZ, Fiddle::RUBY_FREE)
132
+ check(Native.call("pdf_page_count", pdf, pdf.bytesize, count))
133
+ count[0, Native::SIZEOF_SZ].unpack1("J")
134
+ end
135
+
136
+ # Validate every signature in +pdf+. Returns one Hash per signature with keys
137
+ # "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.
140
+ def verify_signatures(pdf)
141
+ js = take_bytes { |pp, pn| Native.call("pdf_verify_signatures_json", pdf, pdf.bytesize, pp, pn) }
142
+ .force_encoding(Encoding::UTF_8)
143
+ js.empty? ? [] : JSON.parse(js)
144
+ end
145
+
146
+ # Sign a PDF (PKCS#7 detached, incremental update). Requires a license.
147
+ def sign(pdf, key_der, cert_der, reason: nil, location: nil, name: nil, pades: false)
148
+ take_bytes do |pp, pn|
149
+ Native.call("pdf_sign", pdf, pdf.bytesize, key_der, key_der.bytesize,
150
+ cert_der, cert_der.bytesize, reason, location, name, pades ? 1 : 0, pp, pn)
151
+ end
152
+ end
153
+
154
+ # Append a document timestamp (/DocTimeStamp, PAdES-B-LTA).
155
+ def timestamp(pdf, tsa_key_der, tsa_cert_der, date: nil)
156
+ take_bytes do |pp, pn|
157
+ Native.call("pdf_timestamp", pdf, pdf.bytesize, tsa_key_der, tsa_key_der.bytesize,
158
+ tsa_cert_der, tsa_cert_der.bytesize, date, pp, pn)
159
+ end
160
+ end
161
+
162
+ # Append a Document Security Store (/DSS, PAdES-B-LT).
163
+ def add_dss(pdf, certs: [], crls: [])
164
+ cptrs = certs.map { |c| Fiddle::Pointer[c] }
165
+ rptrs = crls.map { |c| Fiddle::Pointer[c] }
166
+ cptr_buf = cptrs.map(&:to_i).pack("J*")
167
+ clen_buf = certs.map(&:bytesize).pack("J*")
168
+ rptr_buf = rptrs.map(&:to_i).pack("J*")
169
+ rlen_buf = crls.map(&:bytesize).pack("J*")
170
+ result = take_bytes do |pp, pn|
171
+ Native.call("pdf_add_dss", pdf, pdf.bytesize, cptr_buf, clen_buf, certs.size,
172
+ rptr_buf, rlen_buf, crls.size, pp, pn)
173
+ end
174
+ # keep the per-item pointers alive until the call has returned
175
+ cptrs.clear
176
+ rptrs.clear
177
+ result
178
+ end
179
+
180
+ # ---- internal helpers (used by Document/EditableDoc) ----------------------
181
+
182
+ def last_error
183
+ p = Native.call("pdf_last_error_message")
184
+ p.null? ? "unknown error" : p.to_s
185
+ end
186
+
187
+ def check(status)
188
+ raise Error.new(last_error, status) unless status.zero?
189
+ end
190
+
191
+ # Run an out-buffer producer { |out_ptr, out_len| status } and return the
192
+ # produced bytes, always freeing the native buffer.
193
+ def take_bytes
194
+ pp = Fiddle::Pointer.malloc(Native::SIZEOF_SZ, Fiddle::RUBY_FREE)
195
+ pn = Fiddle::Pointer.malloc(Native::SIZEOF_SZ, Fiddle::RUBY_FREE)
196
+ 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
204
+ end
205
+
206
+ # Run a producer { |out_int| status } and return the written int.
207
+ def out_int
208
+ buf = Fiddle::Pointer.malloc(Native::SIZEOF_INT, Fiddle::RUBY_FREE)
209
+ check(yield(buf))
210
+ buf[0, Native::SIZEOF_INT].unpack1("i!")
211
+ end
212
+ end
213
+
214
+ require_relative "rustpdf/document"
215
+ require_relative "rustpdf/editable_doc"
metadata ADDED
@@ -0,0 +1,50 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rustpdf
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.4.2
5
+ platform: universal-darwin
6
+ authors:
7
+ - rust-pdf
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-06-29 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Idiomatic Ruby binding over the rust-pdf C ABI (libpdf_ffi) using the
14
+ built-in Fiddle stdlib.
15
+ email:
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - README.md
21
+ - lib/rustpdf.rb
22
+ - lib/rustpdf/document.rb
23
+ - lib/rustpdf/editable_doc.rb
24
+ - lib/rustpdf/native.rb
25
+ - vendor/universal-darwin/libpdf_ffi.dylib
26
+ homepage:
27
+ licenses:
28
+ - Nonstandard
29
+ metadata: {}
30
+ post_install_message:
31
+ rdoc_options: []
32
+ require_paths:
33
+ - lib
34
+ required_ruby_version: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '2.6'
39
+ required_rubygems_version: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: '0'
44
+ requirements: []
45
+ rubygems_version: 3.5.22
46
+ signing_key:
47
+ specification_version: 4
48
+ summary: Ruby binding for the rust-pdf core (generate, manipulate, sign and validate
49
+ PDFs).
50
+ test_files: []