himg 0.0.5 → 0.0.6
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 +7 -0
- data/Cargo.lock +548 -1047
- data/LICENSE-APACHE.txt +203 -0
- data/README.md +45 -36
- data/ext/himg/Cargo.toml +10 -7
- data/ext/himg/examples/assets/github_profile_offline.html +86 -0
- data/ext/himg/examples/file.rs +6 -6
- data/ext/himg/src/html_to_image.rs +31 -35
- data/ext/himg/src/image_size.rs +1 -1
- data/ext/himg/src/lib.rs +15 -14
- data/ext/himg/src/net_fetcher.rs +139 -0
- data/ext/himg/src/options.rs +21 -3
- data/ext/himg/src/renderer.rs +1 -3
- data/gemfiles/plain_ruby.gemfile.lock +1 -1
- data/gemfiles/rails_6.gemfile.lock +1 -1
- data/gemfiles/rails_7_0.gemfile.lock +1 -1
- data/gemfiles/rails_7_1.gemfile.lock +1 -1
- data/gemfiles/rails_7_2.gemfile.lock +1 -1
- data/gemfiles/rails_8.gemfile.lock +1 -1
- data/lib/himg/base_url.rb +21 -0
- data/lib/himg/cli.rb +64 -0
- data/lib/himg/version.rb +1 -1
- data/lib/himg.rb +3 -2
- data/readme_hero.svg +54 -0
- metadata +8 -2
- /data/{LICENSE.txt → LICENSE-MIT.txt} +0 -0
data/ext/himg/src/lib.rs
CHANGED
@@ -4,6 +4,7 @@ pub mod image_size;
|
|
4
4
|
pub mod options;
|
5
5
|
pub mod writer;
|
6
6
|
pub mod logger;
|
7
|
+
pub mod net_fetcher;
|
7
8
|
|
8
9
|
pub use renderer::render_blocking;
|
9
10
|
pub use image_size::ImageSize;
|
@@ -11,28 +12,29 @@ pub use options::Options;
|
|
11
12
|
pub use html_to_image::html_to_image;
|
12
13
|
pub use writer::write_png;
|
13
14
|
|
14
|
-
use blitz_traits::ColorScheme;
|
15
|
+
use blitz_traits::shell::ColorScheme;
|
15
16
|
use magnus::{function, prelude::*, ExceptionClass, Error, Ruby, RString, RHash};
|
16
17
|
|
17
18
|
impl Options {
|
18
|
-
fn get_option<V: magnus::TryConvert + magnus::IntoValue>(optional_hash: Option<RHash>, key: &str, default: V) -> Result<V, Error> {
|
19
|
-
match optional_hash {
|
20
|
-
Some(hash) => hash.lookup2::<&str, V, V>(key, default),
|
21
|
-
None => Ok(default),
|
22
|
-
}
|
23
|
-
}
|
24
|
-
|
25
19
|
pub fn from_ruby(hash: Option<RHash>) -> Result<Self, Error> {
|
20
|
+
let defaults = Options::default();
|
21
|
+
|
22
|
+
let hash = match hash {
|
23
|
+
None => return Ok(defaults),
|
24
|
+
Some(r) => r,
|
25
|
+
};
|
26
|
+
|
26
27
|
let options = Options {
|
27
28
|
image_size: ImageSize {
|
28
|
-
width:
|
29
|
-
height:
|
29
|
+
width: hash.lookup2("width", 720)?,
|
30
|
+
height: hash.lookup2("height", 405)?,
|
30
31
|
hidpi_scale: 1.0,
|
31
32
|
},
|
32
|
-
truncate:
|
33
|
-
verbose:
|
33
|
+
truncate: hash.lookup2("truncate", true)?,
|
34
|
+
verbose: hash.lookup2("verbose", false)?,
|
35
|
+
base_url: hash.lookup("base_url")?,
|
36
|
+
disable_fetch: hash.lookup2("disable_fetch", false)?,
|
34
37
|
color_scheme: ColorScheme::Light,
|
35
|
-
allow_net_requests: true, //TODO: Implement using this
|
36
38
|
};
|
37
39
|
|
38
40
|
Ok(options)
|
@@ -53,7 +55,6 @@ pub fn render_blocking_rb(ruby: &Ruby, html: String, options: Option<RHash>) ->
|
|
53
55
|
fn init(ruby: &Ruby) -> Result<(), Error> {
|
54
56
|
let module = ruby.define_module("Himg")?;
|
55
57
|
|
56
|
-
//TODO: Allow optional base_url for resolving linked resources (stylesheets, images, fonts, etc)
|
57
58
|
module.define_singleton_method("render_to_string", function!(render_blocking_rb, 2))?;
|
58
59
|
|
59
60
|
Ok(())
|
@@ -0,0 +1,139 @@
|
|
1
|
+
use blitz_html::HtmlDocument;
|
2
|
+
use blitz_net::Provider;
|
3
|
+
use blitz_dom::net::Resource;
|
4
|
+
use blitz_traits::net::{NetCallback, NetProvider, BoxedHandler, Request};
|
5
|
+
use std::sync::Arc;
|
6
|
+
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel};
|
7
|
+
|
8
|
+
#[derive(Clone)]
|
9
|
+
struct PendingCount {
|
10
|
+
count: Arc<std::sync::atomic::AtomicUsize>,
|
11
|
+
}
|
12
|
+
|
13
|
+
impl PendingCount {
|
14
|
+
fn new() -> Self {
|
15
|
+
Self {
|
16
|
+
count: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
|
17
|
+
}
|
18
|
+
}
|
19
|
+
|
20
|
+
fn current(&self) -> usize {
|
21
|
+
self.count.load(std::sync::atomic::Ordering::SeqCst)
|
22
|
+
}
|
23
|
+
|
24
|
+
fn increment(&self) {
|
25
|
+
self.count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
26
|
+
}
|
27
|
+
|
28
|
+
fn decrement(&self) {
|
29
|
+
self.count.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
|
30
|
+
}
|
31
|
+
|
32
|
+
fn is_empty(&self) -> bool {
|
33
|
+
self.current() == 0
|
34
|
+
}
|
35
|
+
}
|
36
|
+
|
37
|
+
pub struct ErrorHandlingCallback<T>(UnboundedSender<(usize, Result<T, Option<String>>)>);
|
38
|
+
impl<T> ErrorHandlingCallback<T> {
|
39
|
+
pub fn new() -> (UnboundedReceiver<(usize, Result<T, Option<String>>)>, Self) {
|
40
|
+
let (send, recv) = unbounded_channel();
|
41
|
+
(recv, Self(send))
|
42
|
+
}
|
43
|
+
}
|
44
|
+
impl<T: Send + Sync + 'static> NetCallback<T> for ErrorHandlingCallback<T> {
|
45
|
+
fn call(&self, doc_id: usize, result: Result<T, Option<String>>) {
|
46
|
+
let _ = self.0.send((doc_id, result));
|
47
|
+
}
|
48
|
+
}
|
49
|
+
|
50
|
+
pub struct ErrorHandlingProvider<D> {
|
51
|
+
inner: Arc<Provider<D>>,
|
52
|
+
callback: Arc<dyn NetCallback<D>>,
|
53
|
+
pending_requests: PendingCount,
|
54
|
+
}
|
55
|
+
|
56
|
+
impl<D: Send + Sync + 'static> ErrorHandlingProvider<D> {
|
57
|
+
pub fn new(callback: Arc<dyn NetCallback<D>>) -> Self {
|
58
|
+
let inner = Arc::new(Provider::new(callback.clone()));
|
59
|
+
Self {
|
60
|
+
inner,
|
61
|
+
callback,
|
62
|
+
pending_requests: PendingCount::new(),
|
63
|
+
}
|
64
|
+
}
|
65
|
+
|
66
|
+
pub fn is_empty(&self) -> bool {
|
67
|
+
self.pending_requests.is_empty()
|
68
|
+
}
|
69
|
+
}
|
70
|
+
|
71
|
+
impl<D: Send + Sync + 'static> NetProvider<D> for ErrorHandlingProvider<D> {
|
72
|
+
fn fetch(&self, doc_id: usize, request: Request, handler: BoxedHandler<D>) {
|
73
|
+
self.pending_requests.increment();
|
74
|
+
|
75
|
+
let callback = self.callback.clone();
|
76
|
+
let request_url = request.url.to_string();
|
77
|
+
let pending_counter = self.pending_requests.clone();
|
78
|
+
|
79
|
+
self.inner.fetch_with_callback(request, Box::new(move |fetch_result| {
|
80
|
+
pending_counter.decrement();
|
81
|
+
|
82
|
+
match fetch_result {
|
83
|
+
Ok((_url, bytes)) => {
|
84
|
+
println!("Fetched {}", request_url);
|
85
|
+
handler.bytes(doc_id, bytes, callback);
|
86
|
+
}
|
87
|
+
Err(e) => {
|
88
|
+
let error_msg = Some(format!("Failed to fetch {}: {:?}", request_url, e));
|
89
|
+
callback.call(doc_id, Err(error_msg));
|
90
|
+
}
|
91
|
+
}
|
92
|
+
}));
|
93
|
+
}
|
94
|
+
}
|
95
|
+
|
96
|
+
pub struct NetFetcher {
|
97
|
+
provider: Arc<ErrorHandlingProvider<Resource>>,
|
98
|
+
receiver: UnboundedReceiver<(usize, Result<Resource, Option<String>>)>,
|
99
|
+
}
|
100
|
+
|
101
|
+
impl NetFetcher {
|
102
|
+
pub fn new() -> Self {
|
103
|
+
let (receiver, callback) = ErrorHandlingCallback::new();
|
104
|
+
let callback = Arc::new(callback);
|
105
|
+
let provider = Arc::new(ErrorHandlingProvider::new(callback));
|
106
|
+
|
107
|
+
Self { provider, receiver }
|
108
|
+
}
|
109
|
+
|
110
|
+
pub fn get_provider(&self) -> Arc<dyn NetProvider<Resource>> {
|
111
|
+
Arc::clone(&self.provider) as Arc<dyn NetProvider<Resource>>
|
112
|
+
}
|
113
|
+
|
114
|
+
pub async fn fetch_resources(&mut self, document: &mut HtmlDocument) {
|
115
|
+
loop {
|
116
|
+
// Synchronous fetch before checking is_empty to avoid race condition
|
117
|
+
// where is_empty's reference counting thinks there is nothing to
|
118
|
+
// process. This happens when the fetch fails very early.
|
119
|
+
let res = match self.receiver.try_recv() {
|
120
|
+
Ok((_, res)) => res,
|
121
|
+
Err(_) => {
|
122
|
+
if self.provider.is_empty() {
|
123
|
+
break;
|
124
|
+
}
|
125
|
+
|
126
|
+
match self.receiver.recv().await {
|
127
|
+
Some((_, res)) => res,
|
128
|
+
None => break,
|
129
|
+
}
|
130
|
+
}
|
131
|
+
};
|
132
|
+
|
133
|
+
match res {
|
134
|
+
Ok(res) => document.as_mut().load_resource(res),
|
135
|
+
Err(_) => {}
|
136
|
+
}
|
137
|
+
}
|
138
|
+
}
|
139
|
+
}
|
data/ext/himg/src/options.rs
CHANGED
@@ -1,12 +1,30 @@
|
|
1
1
|
use crate::image_size::ImageSize;
|
2
2
|
|
3
|
-
use blitz_traits::ColorScheme;
|
3
|
+
use blitz_traits::shell::ColorScheme;
|
4
4
|
|
5
|
-
#[derive(Clone
|
5
|
+
#[derive(Clone)]
|
6
6
|
pub struct Options {
|
7
7
|
pub image_size: ImageSize,
|
8
8
|
pub color_scheme: ColorScheme,
|
9
|
-
pub
|
9
|
+
pub disable_fetch: bool,
|
10
|
+
pub base_url: Option<String>,
|
10
11
|
pub truncate: bool,
|
11
12
|
pub verbose: bool,
|
12
13
|
}
|
14
|
+
|
15
|
+
impl Options {
|
16
|
+
pub fn default() -> Self {
|
17
|
+
Self {
|
18
|
+
image_size: ImageSize {
|
19
|
+
width: 720,
|
20
|
+
height: 405,
|
21
|
+
hidpi_scale: 1.0,
|
22
|
+
},
|
23
|
+
truncate: true,
|
24
|
+
verbose: false,
|
25
|
+
base_url: None,
|
26
|
+
disable_fetch: false,
|
27
|
+
color_scheme: ColorScheme::Light,
|
28
|
+
}
|
29
|
+
}
|
30
|
+
}
|
data/ext/himg/src/renderer.rs
CHANGED
@@ -18,9 +18,7 @@ pub async fn render(html: String, options: Options) -> Result<Vec<u8>, std::io::
|
|
18
18
|
};
|
19
19
|
|
20
20
|
// Render to Image
|
21
|
-
|
22
|
-
let base_url = None;
|
23
|
-
let render_output = html_to_image(&html, base_url, options, &mut *logger).await;
|
21
|
+
let render_output = html_to_image(&html, options, &mut *logger).await;
|
24
22
|
|
25
23
|
// Determine output path, and open a file at that path.
|
26
24
|
let mut output_buffer: Vec<u8> = Vec::new();
|
@@ -0,0 +1,21 @@
|
|
1
|
+
require "uri"
|
2
|
+
|
3
|
+
module Himg
|
4
|
+
class BaseUrl
|
5
|
+
def initialize(path_or_url)
|
6
|
+
path_or_url = path_or_url.to_s.strip
|
7
|
+
return if path_or_url&.empty?
|
8
|
+
|
9
|
+
@url = URI.parse(path_or_url)
|
10
|
+
@url.scheme = "file" unless @url.scheme
|
11
|
+
|
12
|
+
raise Himg::Error, "Invalid base_url #{path_or_url}" if @url.path.empty?
|
13
|
+
|
14
|
+
@url.path += "/" unless @url.path.end_with?("/")
|
15
|
+
end
|
16
|
+
|
17
|
+
def to_s
|
18
|
+
@url&.to_s
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
data/lib/himg/cli.rb
ADDED
@@ -0,0 +1,64 @@
|
|
1
|
+
require "thor"
|
2
|
+
require "himg"
|
3
|
+
require "open-uri"
|
4
|
+
|
5
|
+
module Himg
|
6
|
+
class CLI < Thor
|
7
|
+
desc "screenshot SOURCE_HTML DESTINATION_PNG [OPTIONS]", "Render HTML to a png screenshot"
|
8
|
+
|
9
|
+
option :width, type: :numeric, desc: "Sets the width of the rendered content.", default: 720
|
10
|
+
option :height, type: :numeric, desc: "Sets the desired height of the rendered output.", default: 405
|
11
|
+
option :truncate, type: :boolean, desc: "Keeps the image height fixed instead of expanding to include the full page.", default: true
|
12
|
+
option :verbose, type: :boolean, desc: "Enables detailed logging for debugging and profiling.", default: false
|
13
|
+
option :disable_fetch, type: :boolean, desc: "Skip fetching file/http resources (stylesheets, images, fonts, etc)", default: false
|
14
|
+
option :http_headers, desc: "HTTP Headers to use when fetching remote resource"
|
15
|
+
option :base_url, desc: "Base URL used to resolve relative URLs"
|
16
|
+
|
17
|
+
long_desc <<-LONGDESC
|
18
|
+
`himg screenshot` takes a path to an HTML file and will render a png image with the output.
|
19
|
+
|
20
|
+
It takes a SOURCE, which can be a file path or a URL to fetch.
|
21
|
+
|
22
|
+
The DESTINATION_PNG must be a local file path.
|
23
|
+
|
24
|
+
CAVEATS: This uses a lightweight HTML parser instead of a full browser, so does not support all features.
|
25
|
+
Additionally it does not use a JavaScript engine, so will screenshot the page as-is and would not work for all webpages.
|
26
|
+
LONGDESC
|
27
|
+
def screenshot(url, destination)
|
28
|
+
Document.new(url, options).load do |content|
|
29
|
+
png = Himg.render(content, **options.transform_keys(&:to_sym))
|
30
|
+
|
31
|
+
File.open(destination, "wb") { |f| f.write(png) }
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def self.exit_on_failure?
|
38
|
+
true
|
39
|
+
end
|
40
|
+
|
41
|
+
class Document
|
42
|
+
def initialize(source, options)
|
43
|
+
@source = source
|
44
|
+
@options = options
|
45
|
+
end
|
46
|
+
|
47
|
+
def http_url?
|
48
|
+
@source =~ %r{\Ahttps?\://}
|
49
|
+
end
|
50
|
+
|
51
|
+
def load(&block)
|
52
|
+
if http_url?
|
53
|
+
URI.send(:open, @source) do |input|
|
54
|
+
yield(input.binmode.read)
|
55
|
+
end
|
56
|
+
else
|
57
|
+
File.open(@source) do |f|
|
58
|
+
yield(f.read)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
data/lib/himg/version.rb
CHANGED
data/lib/himg.rb
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require_relative "himg/version"
|
4
|
+
require_relative "himg/base_url"
|
4
5
|
require "himg/railtie" if defined?(Rails::Railtie)
|
5
6
|
|
6
7
|
# Attempt to load a versioned extension based on the Ruby version.
|
@@ -19,7 +20,7 @@ module Himg
|
|
19
20
|
RENDER_OPTIONS = %i[width height truncate verbose].freeze
|
20
21
|
class Error < StandardError; end
|
21
22
|
|
22
|
-
def self.render(html, width: 720, height: 405, truncate: true, verbose: false)
|
23
|
-
render_to_string(html, "width" => width.to_i, "height" => height.to_i, "truncate" => truncate, "verbose" => verbose)
|
23
|
+
def self.render(html, width: 720, height: 405, truncate: true, verbose: false, base_url: nil, disable_fetch: false)
|
24
|
+
render_to_string(html, "width" => width.to_i, "height" => height.to_i, "truncate" => truncate, "verbose" => verbose, "base_url" => BaseUrl.new(base_url).to_s, "disable_fetch" => disable_fetch)
|
24
25
|
end
|
25
26
|
end
|