forme-ruby 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,16 @@
1
+ [package]
2
+ name = "forme-pdf-native"
3
+ version = "0.1.0"
4
+ edition = "2021"
5
+ license = "MIT"
6
+ [lib]
7
+ name = "forme_pdf_native"
8
+ crate-type = ["cdylib", "rlib"]
9
+ [dependencies]
10
+ forme-pdf-html = { git = "https://github.com/danmolitor/forme", rev = "f408920e632c59da0651b5b6d32f8c1397477673" }
11
+ serde = { version = "1", features = ["derive"] }
12
+ serde_json = "1"
13
+ base64 = "0.22"
14
+ ttf-parser = "0.25"
15
+ [profile.release]
16
+ panic = "unwind"
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "rbconfig"
5
+ require "open3"
6
+ require "json"
7
+ require "tempfile"
8
+
9
+ keep_build = ARGV.delete("--keep-build")
10
+ Dir.chdir(__dir__) do
11
+ abort "forme-ruby source installation requires Rust/Cargo. Install a supported platform gem or install Rust first." unless system("cargo", "--version", out: File::NULL)
12
+ output, status = Open3.capture2("cargo", "build", "--locked", "--release", "--message-format=json", "--target-dir", File.join(__dir__, "target"), "--manifest-path", File.join(__dir__, "Cargo.toml"))
13
+ abort "Forme native build failed" unless status.success?
14
+ extension = RbConfig::CONFIG["host_os"].include?("darwin") ? "dylib" : "so"
15
+ artifacts = output.lines.filter_map do |line|
16
+ record = JSON.parse(line)
17
+ record.fetch("filenames", []) if record["reason"] == "compiler-artifact" && record.dig("target", "name") == "forme_pdf_native"
18
+ end.flatten
19
+ source = artifacts.find { |path| File.basename(path) == "libforme_pdf_native.#{extension}" }
20
+ abort "Cargo did not produce a Forme shared library for this platform" unless source
21
+ destination = File.expand_path("../../lib/forme_pdf/native", __dir__)
22
+ FileUtils.mkdir_p(destination)
23
+ # Replace the inode atomically: overwriting a loaded signed dylib breaks macOS code-signing caches.
24
+ Tempfile.create(["forme-native-", ".tmp"], destination) do |temporary|
25
+ FileUtils.cp(source, temporary.path)
26
+ temporary.chmod(0o644)
27
+ File.rename(temporary.path, File.join(destination, File.basename(source)))
28
+ end
29
+ FileUtils.rm_rf(File.join(__dir__, "target")) unless keep_build
30
+ File.write("Makefile", "all:\n\t@true\ninstall:\n\t@true\nclean:\n\t@true\n")
31
+ end
@@ -0,0 +1,191 @@
1
+ //! Native result ownership boundary. Each call owns an independent result.
2
+ use base64::{engine::general_purpose::STANDARD, Engine};
3
+ use forme_pdf_html::{render_html, FontSpec, HtmlOptions};
4
+ use serde::Deserialize;
5
+ use std::{
6
+ panic::{catch_unwind, AssertUnwindSafe},
7
+ ptr, slice,
8
+ };
9
+
10
+ #[derive(Default, Deserialize)]
11
+ #[serde(default, deny_unknown_fields)]
12
+ struct Options {
13
+ css: Option<String>,
14
+ fonts: Vec<Font>,
15
+ }
16
+ #[derive(Deserialize)]
17
+ #[serde(deny_unknown_fields)]
18
+ struct Font {
19
+ family: String,
20
+ data: String,
21
+ weight: u32,
22
+ italic: bool,
23
+ }
24
+
25
+ pub struct RenderResult {
26
+ status: i32,
27
+ pdf: Vec<u8>,
28
+ metadata: Vec<u8>,
29
+ error: Vec<u8>,
30
+ }
31
+ impl RenderResult {
32
+ fn error(status: i32, message: String) -> Self {
33
+ Self {
34
+ status,
35
+ pdf: vec![],
36
+ metadata: vec![],
37
+ error: message.into_bytes(),
38
+ }
39
+ }
40
+ }
41
+ fn render(html: &[u8], options: &[u8]) -> Result<RenderResult, String> {
42
+ let html = std::str::from_utf8(html).map_err(|_| "HTML must be UTF-8")?;
43
+ let input: Options = serde_json::from_slice(options).map_err(|e| e.to_string())?;
44
+ let mut opts = HtmlOptions {
45
+ css: input.css,
46
+ ..HtmlOptions::default()
47
+ };
48
+ for font in input.fonts {
49
+ let data = STANDARD
50
+ .decode(font.data)
51
+ .map_err(|_| "Invalid font Base64")?;
52
+ ttf_parser::Face::parse(&data, 0).map_err(|_| "Invalid font data")?;
53
+ if font.family.is_empty() || !(1..=1000).contains(&font.weight) {
54
+ return Err("Invalid font family or weight".into());
55
+ }
56
+ opts.fonts.push(FontSpec {
57
+ family: font.family,
58
+ data,
59
+ weight: font.weight,
60
+ italic: font.italic,
61
+ });
62
+ }
63
+ let output = render_html(html, &opts).map_err(|e| e.to_string())?;
64
+ let metadata = serde_json::to_vec(
65
+ &serde_json::json!({"warnings": output.warnings, "passes": output.passes}),
66
+ )
67
+ .map_err(|e| e.to_string())?;
68
+ Ok(RenderResult {
69
+ status: 0,
70
+ pdf: output.pdf,
71
+ metadata,
72
+ error: vec![],
73
+ })
74
+ }
75
+ #[no_mangle]
76
+ pub extern "C" fn forme_abi_version() -> u32 {
77
+ 1
78
+ }
79
+
80
+ /// Render valid caller-owned buffers; null input is allowed only at length zero.
81
+ /// # Safety
82
+ /// Non-null pointers must address readable buffers of the supplied lengths.
83
+ #[no_mangle]
84
+ pub unsafe extern "C" fn forme_render_html(
85
+ html: *const u8,
86
+ html_len: usize,
87
+ options: *const u8,
88
+ options_len: usize,
89
+ ) -> *mut RenderResult {
90
+ let output = catch_unwind(AssertUnwindSafe(|| {
91
+ if (html.is_null() && html_len != 0)
92
+ || (options.is_null() && options_len != 0)
93
+ || html_len > isize::MAX as usize
94
+ || options_len > isize::MAX as usize
95
+ {
96
+ return RenderResult::error(1, "Invalid input buffer".into());
97
+ }
98
+ let h = if html_len == 0 {
99
+ &[]
100
+ } else {
101
+ slice::from_raw_parts(html, html_len)
102
+ };
103
+ let o = if options_len == 0 {
104
+ b"{}"
105
+ } else {
106
+ slice::from_raw_parts(options, options_len)
107
+ };
108
+ render(h, o).unwrap_or_else(|e| RenderResult::error(1, e))
109
+ }))
110
+ .unwrap_or_else(|_| RenderResult::error(2, "Native renderer panicked".into()));
111
+ Box::into_raw(Box::new(output))
112
+ }
113
+ /// # Safety
114
+ /// Result must be a live handle returned by forme_render_html, or null.
115
+ #[no_mangle]
116
+ pub unsafe extern "C" fn forme_result_status(result: *const RenderResult) -> i32 {
117
+ result.as_ref().map_or(3, |r| r.status)
118
+ }
119
+ /// Borrow result bytes: field 0 = PDF, 1 = metadata JSON, 2 = error UTF-8.
120
+ /// # Safety
121
+ /// Result must be live and len must point to writable size_t storage.
122
+ #[no_mangle]
123
+ pub unsafe extern "C" fn forme_result_bytes(
124
+ result: *const RenderResult,
125
+ field: u32,
126
+ len: *mut usize,
127
+ ) -> *const u8 {
128
+ if len.is_null() {
129
+ return ptr::null();
130
+ }
131
+ *len = 0;
132
+ let Some(r) = result.as_ref() else {
133
+ return ptr::null();
134
+ };
135
+ let bytes = match field {
136
+ 0 => &r.pdf,
137
+ 1 => &r.metadata,
138
+ 2 => &r.error,
139
+ _ => return ptr::null(),
140
+ };
141
+ *len = bytes.len();
142
+ bytes.as_ptr()
143
+ }
144
+ /// # Safety
145
+ /// Handle must be null or a live allocation from forme_render_html, destroyed once.
146
+ #[no_mangle]
147
+ pub unsafe extern "C" fn forme_result_destroy(result: *mut RenderResult) {
148
+ if !result.is_null() {
149
+ drop(Box::from_raw(result));
150
+ }
151
+ }
152
+ #[cfg(test)]
153
+ mod tests {
154
+ use super::*;
155
+ #[test]
156
+ fn pdf() {
157
+ assert!(render(b"<p>hello</p>", b"{}")
158
+ .unwrap()
159
+ .pdf
160
+ .starts_with(b"%PDF-"));
161
+ }
162
+ #[test]
163
+ fn invalid_utf8() {
164
+ assert!(render(&[255], b"{}").is_err());
165
+ }
166
+ #[test]
167
+ fn unknown_option() {
168
+ assert!(render(b"hi", br#"{"typo":1}"#).is_err());
169
+ }
170
+ #[test]
171
+ fn null_buffer() {
172
+ unsafe {
173
+ let r = forme_render_html(ptr::null(), 1, ptr::null(), 0);
174
+ assert_ne!(forme_result_status(r), 0);
175
+ forme_result_destroy(r);
176
+ forme_result_destroy(ptr::null_mut());
177
+ }
178
+ }
179
+ #[test]
180
+ fn parallel() {
181
+ let tasks: Vec<_> = (0..8)
182
+ .map(|i| {
183
+ std::thread::spawn(move || {
184
+ render(format!("<p>{i}</p>").as_bytes(), b"{}").unwrap().pdf
185
+ })
186
+ })
187
+ .collect();
188
+ let pdfs: Vec<_> = tasks.into_iter().map(|t| t.join().unwrap()).collect();
189
+ assert_ne!(pdfs[0], pdfs[1]);
190
+ }
191
+ }
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FormePDF
4
+ class Error < StandardError; end
5
+ class RenderError < Error; end
6
+ class LoadError < Error; end
7
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ffi"
4
+ module FormePDF
5
+ # Loads only a library supplied by this gem's build or package.
6
+ module Native
7
+ extend FFI::Library
8
+
9
+ @load_mutex = Mutex.new
10
+
11
+ # Load once and validate the versioned C ABI before use.
12
+ def self.load!
13
+ @load_mutex.synchronize do
14
+ return if @loaded
15
+ extension = FFI::Platform.mac? ? "dylib" : "so"
16
+ path = File.expand_path("native/libforme_pdf_native.#{extension}", __dir__)
17
+ fail FormePDF::LoadError, "Forme native library missing; reinstall forme-ruby for your platform (source installs require Rust/Cargo)" unless File.file?(path)
18
+ ffi_lib path
19
+ attach_function :forme_abi_version, [], :uint32
20
+ fail FormePDF::LoadError, "Incompatible Forme native ABI" unless forme_abi_version == 1
21
+ attach_function :forme_render_html, [:pointer, :size_t, :pointer, :size_t], :pointer, blocking: true
22
+ attach_function :forme_result_status, [:pointer], :int32
23
+ attach_function :forme_result_bytes, [:pointer, :uint32, :pointer], :pointer
24
+ attach_function :forme_result_destroy, [:pointer], :void
25
+ @loaded = true
26
+ end
27
+ rescue ::LoadError => e
28
+ fail FormePDF::LoadError, "Cannot load Forme native library: #{e.message}"
29
+ end
30
+
31
+ # Copy a borrowed native buffer before its owning result is destroyed.
32
+ def self.copy(result, field)
33
+ length = FFI::MemoryPointer.new(:size_t)
34
+ pointer = forme_result_bytes(result, field, length)
35
+ size = length.read(:size_t)
36
+ return "".b if size.zero?
37
+ fail FormePDF::RenderError, "Native result has a null buffer" if pointer.null?
38
+ pointer.read_string_length(size).b
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forme_pdf"
4
+
5
+ module FormePDF
6
+ # Explicitly include this module in an ActionController; no Railtie or middleware.
7
+ module Rails
8
+ # Render a caller-selected Rails template into binary PDF bytes.
9
+ # The controller retains responsibility for authorization and send_data.
10
+ def render_forme_pdf(template:, layout:, locals: {}, **options)
11
+ html = render_to_string(template: template, layout: layout, locals: locals, formats: [:html])
12
+ FormePDF.render_html(html, **options)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "base64"
5
+ module FormePDF
6
+ # One native render call with caller-owned input and independently owned output.
7
+ class Renderer
8
+ # Return PDF bytes, renderer warnings and layout pass count.
9
+ def self.call(html, css: nil, fonts: [])
10
+ fail ArgumentError, "HTML must be a String" unless html.is_a?(String)
11
+ html = html.dup
12
+ html.force_encoding(Encoding::UTF_8) if html.encoding == Encoding::BINARY
13
+ fail ArgumentError, "HTML must be valid UTF-8" unless html.valid_encoding?
14
+ html = html.encode(Encoding::UTF_8)
15
+ fail ArgumentError, "CSS must be a String or nil" unless css.nil? || css.is_a?(String)
16
+ fail ArgumentError, "fonts must be an Array" unless fonts.is_a?(Array)
17
+ options = JSON.generate(css: css, fonts: fonts.map { |font|
18
+ fail ArgumentError, "font must contain family and data" unless font.is_a?(Hash) && font[:family].is_a?(String) && font[:data].is_a?(String)
19
+ fail ArgumentError, "unknown font option" unless (font.keys - %i[family data weight italic]).empty?
20
+ {family: font.fetch(:family), data: Base64.strict_encode64(font.fetch(:data)), weight: font.fetch(:weight, 400), italic: font.fetch(:italic, false)}
21
+ })
22
+ Native.load!
23
+ input = FFI::MemoryPointer.from_string(html)
24
+ opts = FFI::MemoryPointer.from_string(options)
25
+ # Async interruption must not arrive between native allocation and ownership.
26
+ Thread.handle_interrupt(Object => :never) do
27
+ result = Native.forme_render_html(input, html.bytesize, opts, options.bytesize)
28
+ fail RenderError, "Native renderer returned no result" if result.null?
29
+ if Native.forme_result_status(result) != 0
30
+ fail RenderError, Native.copy(result, 2).force_encoding(Encoding::UTF_8)
31
+ end
32
+ pdf = Native.copy(result, 0)
33
+ metadata = JSON.parse(Native.copy(result, 1))
34
+ Result.new(pdf: pdf.freeze, warnings: metadata.fetch("warnings").map(&:freeze).freeze, passes: metadata.fetch("passes"))
35
+ ensure
36
+ Native.forme_result_destroy(result) if result && !result.null?
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FormePDF
4
+ Result = Data.define(:pdf, :warnings, :passes)
5
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FormePDF
4
+ VERSION = "0.1.0"
5
+ ENGINE_REVISION = "f408920e632c59da0651b5b6d32f8c1397477673"
6
+ end
data/lib/forme_pdf.rb ADDED
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "forme_pdf/version"
4
+ require_relative "forme_pdf/errors"
5
+ require_relative "forme_pdf/result"
6
+ require_relative "forme_pdf/native"
7
+ require_relative "forme_pdf/renderer"
8
+
9
+ module FormePDF
10
+ # Render HTML to a binary PDF String.
11
+ def self.render_html(html, **options)
12
+ render_html_result(html, **options).pdf
13
+ end
14
+
15
+ # Render HTML and preserve diagnostics.
16
+ def self.render_html_result(html, **options)
17
+ Renderer.call(html, **options)
18
+ end
19
+
20
+ # Validate the gem-owned native library eagerly, e.g. at application startup.
21
+ def self.verify!
22
+ Native.load!
23
+ true
24
+ end
25
+ end
metadata ADDED
@@ -0,0 +1,126 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: forme-ruby
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ajaya Agrawalla
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-09-19 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: ffi
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.17'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.17'
27
+ - !ruby/object:Gem::Dependency
28
+ name: base64
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.2'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.2'
41
+ - !ruby/object:Gem::Dependency
42
+ name: json
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '2.0'
48
+ - - "<"
49
+ - !ruby/object:Gem::Version
50
+ version: '3'
51
+ type: :runtime
52
+ prerelease: false
53
+ version_requirements: !ruby/object:Gem::Requirement
54
+ requirements:
55
+ - - ">="
56
+ - !ruby/object:Gem::Version
57
+ version: '2.0'
58
+ - - "<"
59
+ - !ruby/object:Gem::Version
60
+ version: '3'
61
+ description: Independent Ruby bindings for the Rust Forme HTML renderer. No browser,
62
+ Node.js, WebAssembly or separate rendering service.
63
+ email:
64
+ - ajaya@clearstack.io
65
+ executables: []
66
+ extensions:
67
+ - ext/forme_pdf/extconf.rb
68
+ extra_rdoc_files: []
69
+ files:
70
+ - CHANGELOG.md
71
+ - CODE_OF_CONDUCT.md
72
+ - CONTRIBUTING.md
73
+ - LICENSE
74
+ - NOTICE
75
+ - README.md
76
+ - SECURITY.md
77
+ - THIRD_PARTY_LICENSES.txt
78
+ - docs/README.md
79
+ - docs/github-setup.md
80
+ - docs/installation.md
81
+ - docs/rails.md
82
+ - docs/releasing.md
83
+ - docs/rendering.md
84
+ - docs/security-exceptions.md
85
+ - examples/hello.rb
86
+ - ext/forme_pdf/Cargo.lock
87
+ - ext/forme_pdf/Cargo.toml
88
+ - ext/forme_pdf/extconf.rb
89
+ - ext/forme_pdf/src/lib.rs
90
+ - lib/forme_pdf.rb
91
+ - lib/forme_pdf/errors.rb
92
+ - lib/forme_pdf/native.rb
93
+ - lib/forme_pdf/rails.rb
94
+ - lib/forme_pdf/renderer.rb
95
+ - lib/forme_pdf/result.rb
96
+ - lib/forme_pdf/version.rb
97
+ homepage: https://github.com/clearstackio/forme-ruby
98
+ licenses:
99
+ - MIT
100
+ metadata:
101
+ source_code_uri: https://github.com/clearstackio/forme-ruby
102
+ documentation_uri: https://github.com/clearstackio/forme-ruby/blob/main/docs/README.md
103
+ bug_tracker_uri: https://github.com/clearstackio/forme-ruby/issues
104
+ changelog_uri: https://github.com/clearstackio/forme-ruby/blob/main/CHANGELOG.md
105
+ rubygems_mfa_required: 'true'
106
+ allowed_push_host: https://rubygems.org
107
+ post_install_message:
108
+ rdoc_options: []
109
+ require_paths:
110
+ - lib
111
+ required_ruby_version: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - ">="
114
+ - !ruby/object:Gem::Version
115
+ version: '3.2'
116
+ required_rubygems_version: !ruby/object:Gem::Requirement
117
+ requirements:
118
+ - - ">="
119
+ - !ruby/object:Gem::Version
120
+ version: '0'
121
+ requirements: []
122
+ rubygems_version: 3.5.22
123
+ signing_key:
124
+ specification_version: 4
125
+ summary: Native HTML to PDF rendering for Ruby and Rails
126
+ test_files: []