himg 0.0.5 → 0.0.7

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.
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
15
  use magnus::{function, prelude::*, ExceptionClass, Error, Ruby, RString, RHash};
16
16
 
17
17
  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
18
  pub fn from_ruby(hash: Option<RHash>) -> Result<Self, Error> {
19
+ let defaults = Options::default();
20
+
21
+ let hash = match hash {
22
+ None => return Ok(defaults),
23
+ Some(r) => r,
24
+ };
25
+
26
26
  let options = Options {
27
27
  image_size: ImageSize {
28
- width: Self::get_option(hash, "width", 720)?,
29
- height: Self::get_option(hash, "height", 405)?,
30
- hidpi_scale: 1.0,
28
+ width: hash.lookup2("width", defaults.image_size.width)?,
29
+ height: hash.lookup2("height", defaults.image_size.height)?,
30
+ hidpi_scale: defaults.image_size.hidpi_scale,
31
31
  },
32
- truncate: Self::get_option(hash, "truncate", true)?,
33
- verbose: Self::get_option(hash, "verbose", false)?,
34
- color_scheme: ColorScheme::Light,
35
- allow_net_requests: true, //TODO: Implement using this
32
+ truncate: hash.lookup2("truncate", defaults.truncate)?,
33
+ verbose: hash.lookup2("verbose", defaults.verbose)?,
34
+ base_url: hash.lookup("base_url")?,
35
+ disable_fetch: hash.lookup2("disable_fetch", defaults.disable_fetch)?,
36
+ fetch_timeout: hash.lookup2("fetch_timeout", defaults.fetch_timeout)?,
37
+ color_scheme: defaults.color_scheme,
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
+ }
@@ -1,12 +1,32 @@
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, Copy)]
5
+ #[derive(Clone)]
6
6
  pub struct Options {
7
7
  pub image_size: ImageSize,
8
8
  pub color_scheme: ColorScheme,
9
- pub allow_net_requests: bool,
9
+ pub disable_fetch: bool,
10
+ pub fetch_timeout: f64,
11
+ pub base_url: Option<String>,
10
12
  pub truncate: bool,
11
13
  pub verbose: bool,
12
14
  }
15
+
16
+ impl Options {
17
+ pub fn default() -> Self {
18
+ Self {
19
+ image_size: ImageSize {
20
+ width: 720,
21
+ height: 405,
22
+ hidpi_scale: 1.0,
23
+ },
24
+ truncate: true,
25
+ verbose: false,
26
+ base_url: None,
27
+ disable_fetch: false,
28
+ fetch_timeout: 10.0,
29
+ color_scheme: ColorScheme::Light,
30
+ }
31
+ }
32
+ }
@@ -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
- //let base_url = format!("file://{}", path_string.clone());
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();
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- himg (0.0.4)
4
+ himg (0.0.6)
5
5
  rb_sys (~> 0.9)
6
6
 
7
7
  GEM
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- himg (0.0.4)
4
+ himg (0.0.6)
5
5
  rb_sys (~> 0.9)
6
6
 
7
7
  GEM
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- himg (0.0.4)
4
+ himg (0.0.6)
5
5
  rb_sys (~> 0.9)
6
6
 
7
7
  GEM
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- himg (0.0.4)
4
+ himg (0.0.6)
5
5
  rb_sys (~> 0.9)
6
6
 
7
7
  GEM
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- himg (0.0.4)
4
+ himg (0.0.6)
5
5
  rb_sys (~> 0.9)
6
6
 
7
7
  GEM
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- himg (0.0.4)
4
+ himg (0.0.6)
5
5
  rb_sys (~> 0.9)
6
6
 
7
7
  GEM
@@ -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,65 @@
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 :fetch_timeout, type: :numeric, desc: "Timeout in seconds for fetching resources", default: 10
15
+ option :http_headers, desc: "HTTP Headers to use when fetching remote resource"
16
+ option :base_url, desc: "Base URL used to resolve relative URLs"
17
+
18
+ long_desc <<-LONGDESC
19
+ `himg screenshot` takes a path to an HTML file and will render a png image with the output.
20
+
21
+ It takes a SOURCE, which can be a file path or a URL to fetch.
22
+
23
+ The DESTINATION_PNG must be a local file path.
24
+
25
+ CAVEATS: This uses a lightweight HTML parser instead of a full browser, so does not support all features.
26
+ Additionally it does not use a JavaScript engine, so will screenshot the page as-is and would not work for all webpages.
27
+ LONGDESC
28
+ def screenshot(url, destination)
29
+ Document.new(url, options).load do |content|
30
+ png = Himg.render(content, **options.transform_keys(&:to_sym))
31
+
32
+ File.open(destination, "wb") { |f| f.write(png) }
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def self.exit_on_failure?
39
+ true
40
+ end
41
+
42
+ class Document
43
+ def initialize(source, options)
44
+ @source = source
45
+ @options = options
46
+ end
47
+
48
+ def http_url?
49
+ @source =~ %r{\Ahttps?\://}
50
+ end
51
+
52
+ def load(&block)
53
+ if http_url?
54
+ URI.send(:open, @source) do |input|
55
+ yield(input.binmode.read)
56
+ end
57
+ else
58
+ File.open(@source) do |f|
59
+ yield(f.read)
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
data/lib/himg/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Himg
4
- VERSION = "0.0.5"
4
+ VERSION = "0.0.7"
5
5
  end
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, fetch_timeout: 10)
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, "fetch_timeout" => fetch_timeout.to_f)
24
25
  end
25
26
  end