forme-ruby 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,17 +1,36 @@
1
1
  //! Native result ownership boundary. Each call owns an independent result.
2
2
  use base64::{engine::general_purpose::STANDARD, Engine};
3
- use forme_pdf_html::{render_html, FontSpec, HtmlOptions};
3
+ use forme::{
4
+ model::{Document, NodeKind},
5
+ render_with_options, RenderOptions,
6
+ };
7
+ use forme_pdf_html::{html_to_document, FontSpec, HtmlOptions};
8
+ use html5ever::{parse_document, tendril::TendrilSink};
9
+ use markup5ever_rcdom::{NodeData, RcDom};
4
10
  use serde::Deserialize;
5
11
  use std::{
12
+ collections::HashMap,
13
+ fs::File,
14
+ io::Read,
6
15
  panic::{catch_unwind, AssertUnwindSafe},
16
+ path::{Path, PathBuf},
7
17
  ptr, slice,
18
+ thread::{self, Builder},
8
19
  };
9
20
 
21
+ /// Deepest element nesting accepted. Parsing, mapping and layout recurse per
22
+ /// level, so deeper documents fail cleanly instead of overflowing the stack.
23
+ const MAX_DEPTH: usize = 512;
24
+ /// Renders run on their own thread: Ruby threads often have a 1 MiB stack.
25
+ const RENDER_STACK_BYTES: usize = 64 << 20;
26
+ const MAX_IMAGE_BYTES: u64 = 64 << 20;
27
+
10
28
  #[derive(Default, Deserialize)]
11
29
  #[serde(default, deny_unknown_fields)]
12
30
  struct Options {
13
31
  css: Option<String>,
14
32
  fonts: Vec<Font>,
33
+ image_root: Option<String>,
15
34
  }
16
35
  #[derive(Deserialize)]
17
36
  #[serde(deny_unknown_fields)]
@@ -41,6 +60,14 @@ impl RenderResult {
41
60
  fn render(html: &[u8], options: &[u8]) -> Result<RenderResult, String> {
42
61
  let html = std::str::from_utf8(html).map_err(|_| "HTML must be UTF-8")?;
43
62
  let input: Options = serde_json::from_slice(options).map_err(|e| e.to_string())?;
63
+ let image_root = input
64
+ .image_root
65
+ .map(|root| Path::new(&root).canonicalize())
66
+ .transpose()
67
+ .map_err(|_| "image_root must be a readable directory")?;
68
+ if nesting_depth(html) > MAX_DEPTH {
69
+ return Err(format!("HTML nesting exceeds {MAX_DEPTH} levels"));
70
+ }
44
71
  let mut opts = HtmlOptions {
45
72
  css: input.css,
46
73
  ..HtmlOptions::default()
@@ -60,18 +87,109 @@ fn render(html: &[u8], options: &[u8]) -> Result<RenderResult, String> {
60
87
  italic: font.italic,
61
88
  });
62
89
  }
63
- let output = render_html(html, &opts).map_err(|e| e.to_string())?;
90
+ // forme_pdf_html::render_html, with file images resolved by this binding.
91
+ let (mut doc, mut warnings) = html_to_document(html, &opts);
92
+ resolve_local_images(&mut doc, image_root.as_deref(), &mut warnings);
93
+ let (pdf, engine_warnings, passes) = render_with_options(
94
+ &doc,
95
+ RenderOptions {
96
+ audit_content: opts.audit_content,
97
+ },
98
+ )
99
+ .map_err(|e| e.to_string())?;
100
+ warnings.extend(engine_warnings);
64
101
  let metadata = serde_json::to_vec(
65
- &serde_json::json!({"warnings": output.warnings, "passes": output.passes}),
102
+ &serde_json::json!({"warnings": dedup_warnings(warnings), "passes": passes}),
66
103
  )
67
104
  .map_err(|e| e.to_string())?;
68
105
  Ok(RenderResult {
69
106
  status: 0,
70
- pdf: output.pdf,
107
+ pdf,
71
108
  metadata,
72
109
  error: vec![],
73
110
  })
74
111
  }
112
+
113
+ /// Element depth of the parsed DOM, measured without recursion.
114
+ fn nesting_depth(html: &str) -> usize {
115
+ let dom = parse_document(RcDom::default(), Default::default()).one(html);
116
+ let mut deepest = 0;
117
+ // Hold `dom` until the walk ends: rcdom's iterative Drop empties child
118
+ // lists even while other handles still reference those children.
119
+ let mut pending = vec![(dom.document.clone(), 0)];
120
+ while let Some((node, depth)) = pending.pop() {
121
+ let depth = depth + usize::from(matches!(node.data, NodeData::Element { .. }));
122
+ deepest = deepest.max(depth);
123
+ pending.extend(node.children.borrow().iter().map(|c| (c.clone(), depth)));
124
+ }
125
+ deepest
126
+ }
127
+
128
+ /// The engine reads `/`, `./` and `../` image sources from disk itself. Inline
129
+ /// them as data URIs when they resolve to a regular file inside `image_root`;
130
+ /// otherwise omit the image. Warnings never echo paths or OS errors.
131
+ fn resolve_local_images(doc: &mut Document, root: Option<&Path>, warnings: &mut Vec<String>) {
132
+ let mut pending: Vec<_> = doc.children.iter_mut().collect();
133
+ while let Some(node) = pending.pop() {
134
+ if let NodeKind::Image { src, .. } = &mut node.kind {
135
+ if src.starts_with('/') || src.starts_with("./") || src.starts_with("../") {
136
+ match local_image(src, root) {
137
+ Ok(data) => *src = format!("data:image/*;base64,{}", STANDARD.encode(data)),
138
+ Err(reason) => {
139
+ warnings.push(format!("Local image omitted: {reason}"));
140
+ src.clear();
141
+ }
142
+ }
143
+ }
144
+ }
145
+ pending.extend(node.children.iter_mut());
146
+ }
147
+ }
148
+
149
+ fn local_image(src: &str, root: Option<&Path>) -> Result<Vec<u8>, &'static str> {
150
+ let root = root.ok_or("pass image_root to allow file images")?;
151
+ let path = if src.starts_with('/') {
152
+ PathBuf::from(src)
153
+ } else {
154
+ root.join(src)
155
+ };
156
+ let path = path.canonicalize().map_err(|_| "file not found")?;
157
+ if !path.starts_with(root) {
158
+ return Err("path is outside image_root");
159
+ }
160
+ let file = File::open(&path).map_err(|_| "file not readable")?;
161
+ if !file.metadata().map_err(|_| "file not readable")?.is_file() {
162
+ return Err("not a regular file");
163
+ }
164
+ let mut data = Vec::new();
165
+ file.take(MAX_IMAGE_BYTES + 1)
166
+ .read_to_end(&mut data)
167
+ .map_err(|_| "file not readable")?;
168
+ if data.len() as u64 > MAX_IMAGE_BYTES {
169
+ return Err("file exceeds 64 MiB");
170
+ }
171
+ Ok(data)
172
+ }
173
+
174
+ /// Same as forme_pdf_html's private helper: collapse repeats into "… (×N)".
175
+ fn dedup_warnings(warnings: Vec<String>) -> Vec<String> {
176
+ let mut order = Vec::new();
177
+ let mut counts: HashMap<String, usize> = HashMap::new();
178
+ for warning in warnings {
179
+ let count = counts.entry(warning.clone()).or_insert(0);
180
+ if *count == 0 {
181
+ order.push(warning);
182
+ }
183
+ *count += 1;
184
+ }
185
+ order
186
+ .into_iter()
187
+ .map(|w| match counts[&w] {
188
+ 1 => w,
189
+ n => format!("{w} (×{n})"),
190
+ })
191
+ .collect()
192
+ }
75
193
  #[no_mangle]
76
194
  pub extern "C" fn forme_abi_version() -> u32 {
77
195
  1
@@ -105,7 +223,19 @@ pub unsafe extern "C" fn forme_render_html(
105
223
  } else {
106
224
  slice::from_raw_parts(options, options_len)
107
225
  };
108
- render(h, o).unwrap_or_else(|e| RenderResult::error(1, e))
226
+ thread::scope(|scope| {
227
+ match Builder::new()
228
+ .name("forme-render".into())
229
+ .stack_size(RENDER_STACK_BYTES)
230
+ .spawn_scoped(scope, || render(h, o))
231
+ {
232
+ Ok(worker) => match worker.join() {
233
+ Ok(output) => output.unwrap_or_else(|e| RenderResult::error(1, e)),
234
+ Err(_) => RenderResult::error(2, "Native renderer panicked".into()),
235
+ },
236
+ Err(_) => RenderResult::error(1, "Cannot start render thread".into()),
237
+ }
238
+ })
109
239
  }))
110
240
  .unwrap_or_else(|_| RenderResult::error(2, "Native renderer panicked".into()));
111
241
  Box::into_raw(Box::new(output))
@@ -176,6 +306,71 @@ mod tests {
176
306
  forme_result_destroy(ptr::null_mut());
177
307
  }
178
308
  }
309
+ /// Render through the C ABI, as Ruby does: status and error text.
310
+ fn ffi_render(html: &str, options: &str) -> (i32, String) {
311
+ unsafe {
312
+ let r = forme_render_html(html.as_ptr(), html.len(), options.as_ptr(), options.len());
313
+ let mut len = 0;
314
+ let error = forme_result_bytes(r, 2, &mut len);
315
+ let error = String::from_utf8_lossy(slice::from_raw_parts(error, len)).into_owned();
316
+ let status = forme_result_status(r);
317
+ forme_result_destroy(r);
318
+ (status, error)
319
+ }
320
+ }
321
+ #[test]
322
+ fn nesting_limit() {
323
+ let nested = |depth: usize| "<div>".repeat(depth) + "x" + &"</div>".repeat(depth);
324
+ assert_eq!(nesting_depth(&nested(10)), 12);
325
+ assert_eq!(ffi_render(&nested(MAX_DEPTH - 2), "{}").0, 0);
326
+ let (status, error) = ffi_render(&nested(MAX_DEPTH), "{}");
327
+ assert_eq!(status, 1);
328
+ assert!(error.contains("nesting"));
329
+ }
330
+ #[test]
331
+ fn deep_render_on_small_caller_stack() {
332
+ let html = "<div>".repeat(MAX_DEPTH - 2);
333
+ let status = thread::Builder::new()
334
+ .stack_size(256 << 10)
335
+ .spawn(move || unsafe {
336
+ let r = forme_render_html(html.as_ptr(), html.len(), ptr::null(), 0);
337
+ let status = forme_result_status(r);
338
+ forme_result_destroy(r);
339
+ status
340
+ })
341
+ .unwrap()
342
+ .join()
343
+ .unwrap();
344
+ assert_eq!(status, 0);
345
+ }
346
+ #[test]
347
+ fn file_images_need_image_root() {
348
+ let html = br#"<img width="10" height="10" src="/etc/hosts">"#;
349
+ let metadata = render(html, b"{}").unwrap().metadata;
350
+ let metadata = String::from_utf8(metadata).unwrap();
351
+ assert!(metadata.contains("Local image omitted: pass image_root"));
352
+ assert!(!metadata.contains("/etc/hosts"));
353
+ let outside = render(html, br#"{"image_root":"/tmp"}"#).unwrap().metadata;
354
+ assert!(String::from_utf8(outside)
355
+ .unwrap()
356
+ .contains("outside image_root"));
357
+ assert!(render(html, br#"{"image_root":"/nonexistent/forme"}"#).is_err());
358
+ }
359
+ #[test]
360
+ fn result_accessors_reject_invalid_arguments() {
361
+ unsafe {
362
+ assert_eq!(forme_result_status(ptr::null()), 3);
363
+ let r = forme_render_html(b"hi".as_ptr(), 2, ptr::null(), 0);
364
+ let mut len = 7;
365
+ assert!(forme_result_bytes(r, 3, &mut len).is_null());
366
+ assert_eq!(len, 0);
367
+ assert!(forme_result_bytes(r, 0, ptr::null_mut()).is_null());
368
+ assert!(forme_result_bytes(ptr::null(), 0, &mut len).is_null());
369
+ assert!(!forme_result_bytes(r, 0, &mut len).is_null());
370
+ assert!(len > 0);
371
+ forme_result_destroy(r);
372
+ }
373
+ }
179
374
  #[test]
180
375
  fn parallel() {
181
376
  let tasks: Vec<_> = (0..8)
@@ -5,6 +5,8 @@ require "forme_pdf"
5
5
  module FormePDF
6
6
  # Explicitly include this module in an ActionController; no Railtie or middleware.
7
7
  module Rails
8
+ private
9
+
8
10
  # Render a caller-selected Rails template into binary PDF bytes.
9
11
  # The controller retains responsibility for authorization and send_data.
10
12
  def render_forme_pdf(template:, layout:, locals: {}, **options)
@@ -6,19 +6,12 @@ module FormePDF
6
6
  # One native render call with caller-owned input and independently owned output.
7
7
  class Renderer
8
8
  # Return PDF bytes, renderer warnings and layout pass count.
9
- def self.call(html, css: nil, fonts: [])
9
+ def self.call(html, css: nil, fonts: [], image_root: nil)
10
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)
11
+ html = utf8(html, "HTML")
15
12
  fail ArgumentError, "CSS must be a String or nil" unless css.nil? || css.is_a?(String)
16
13
  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
- })
14
+ options = JSON.generate(css: css && utf8(css, "CSS"), fonts: fonts.map { |font| font_option(font) }, image_root: image_root_option(image_root))
22
15
  Native.load!
23
16
  input = FFI::MemoryPointer.from_string(html)
24
17
  opts = FFI::MemoryPointer.from_string(options)
@@ -36,5 +29,32 @@ module FormePDF
36
29
  Native.forme_result_destroy(result) if result && !result.null?
37
30
  end
38
31
  end
32
+
33
+ # Binary text is read as UTF-8; other valid encodings are transcoded.
34
+ def self.utf8(text, name)
35
+ text = text.dup
36
+ text.force_encoding(Encoding::UTF_8) if text.encoding == Encoding::BINARY
37
+ fail ArgumentError, "#{name} must be valid UTF-8" unless text.valid_encoding?
38
+ text.encode(Encoding::UTF_8)
39
+ end
40
+
41
+ def self.font_option(font)
42
+ fail ArgumentError, "font must contain family and data" unless font.is_a?(Hash) && font[:family].is_a?(String) && font[:data].is_a?(String)
43
+ fail ArgumentError, "unknown font option" unless (font.keys - %i[family data weight italic]).empty?
44
+ weight = font.fetch(:weight, 400)
45
+ italic = font.fetch(:italic, false)
46
+ fail ArgumentError, "font weight must be an Integer from 1 to 1000" unless weight.is_a?(Integer) && weight.between?(1, 1000)
47
+ fail ArgumentError, "font italic must be true or false" unless [true, false].include?(italic)
48
+ {family: utf8(font[:family], "font family"), data: Base64.strict_encode64(font[:data]), weight: weight, italic: italic}
49
+ end
50
+
51
+ def self.image_root_option(root)
52
+ return if root.nil?
53
+ fail ArgumentError, "image_root must be a String or Pathname" unless root.is_a?(String) || root.respond_to?(:to_path)
54
+ root = File.expand_path(root.respond_to?(:to_path) ? root.to_path : root)
55
+ fail ArgumentError, "image_root must be an existing directory" unless File.directory?(root)
56
+ utf8(root, "image_root")
57
+ end
58
+ private_class_method :utf8, :font_option, :image_root_option
39
59
  end
40
60
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FormePDF
4
- VERSION = "0.1.0"
5
- ENGINE_REVISION = "f408920e632c59da0651b5b6d32f8c1397477673"
4
+ VERSION = "0.2.0"
5
+ ENGINE_REVISION = "b97dc101b705ea5d7fea6fb1b20fabfb5bd2d63d"
6
6
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: forme-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ajaya Agrawalla
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-19 00:00:00.000000000 Z
11
+ date: 2026-10-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ffi
@@ -47,7 +47,7 @@ dependencies:
47
47
  version: '2.0'
48
48
  - - "<"
49
49
  - !ruby/object:Gem::Version
50
- version: '3'
50
+ version: '4'
51
51
  type: :runtime
52
52
  prerelease: false
53
53
  version_requirements: !ruby/object:Gem::Requirement
@@ -57,7 +57,7 @@ dependencies:
57
57
  version: '2.0'
58
58
  - - "<"
59
59
  - !ruby/object:Gem::Version
60
- version: '3'
60
+ version: '4'
61
61
  description: Independent Ruby bindings for the Rust Forme HTML renderer. No browser,
62
62
  Node.js, WebAssembly or separate rendering service.
63
63
  email:
@@ -99,9 +99,9 @@ licenses:
99
99
  - MIT
100
100
  metadata:
101
101
  source_code_uri: https://github.com/clearstackio/forme-ruby
102
- documentation_uri: https://github.com/clearstackio/forme-ruby/blob/main/docs/README.md
102
+ documentation_uri: https://github.com/clearstackio/forme-ruby/blob/master/docs/README.md
103
103
  bug_tracker_uri: https://github.com/clearstackio/forme-ruby/issues
104
- changelog_uri: https://github.com/clearstackio/forme-ruby/blob/main/CHANGELOG.md
104
+ changelog_uri: https://github.com/clearstackio/forme-ruby/blob/master/CHANGELOG.md
105
105
  rubygems_mfa_required: 'true'
106
106
  allowed_push_host: https://rubygems.org
107
107
  post_install_message:
@@ -112,7 +112,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
112
112
  requirements:
113
113
  - - ">="
114
114
  - !ruby/object:Gem::Version
115
- version: '3.2'
115
+ version: '3.3'
116
116
  required_rubygems_version: !ruby/object:Gem::Requirement
117
117
  requirements:
118
118
  - - ">="