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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +46 -2
- data/CONTRIBUTING.md +1 -1
- data/NOTICE +1 -1
- data/README.md +20 -20
- data/SECURITY.md +4 -5
- data/THIRD_PARTY_LICENSES.txt +238 -17
- data/docs/github-setup.md +13 -11
- data/docs/installation.md +16 -32
- data/docs/releasing.md +54 -40
- data/docs/rendering.md +26 -7
- data/docs/security-exceptions.md +7 -4
- data/ext/forme_pdf/Cargo.lock +37 -34
- data/ext/forme_pdf/Cargo.toml +5 -2
- data/ext/forme_pdf/extconf.rb +3 -2
- data/ext/forme_pdf/src/lib.rs +200 -5
- data/lib/forme_pdf/rails.rb +2 -0
- data/lib/forme_pdf/renderer.rb +30 -10
- data/lib/forme_pdf/version.rb +2 -2
- metadata +7 -7
data/ext/forme_pdf/src/lib.rs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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":
|
|
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
|
|
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
|
-
|
|
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)
|
data/lib/forme_pdf/rails.rb
CHANGED
|
@@ -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)
|
data/lib/forme_pdf/renderer.rb
CHANGED
|
@@ -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
|
|
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
|
data/lib/forme_pdf/version.rb
CHANGED
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.
|
|
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-
|
|
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: '
|
|
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: '
|
|
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/
|
|
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/
|
|
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.
|
|
115
|
+
version: '3.3'
|
|
116
116
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
117
117
|
requirements:
|
|
118
118
|
- - ">="
|