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.
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: Self::get_option(hash, "width", 720)?,
29
- height: Self::get_option(hash, "height", 405)?,
29
+ width: hash.lookup2("width", 720)?,
30
+ height: hash.lookup2("height", 405)?,
30
31
  hidpi_scale: 1.0,
31
32
  },
32
- truncate: Self::get_option(hash, "truncate", true)?,
33
- verbose: Self::get_option(hash, "verbose", false)?,
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
+ }
@@ -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, 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 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
+ }
@@ -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.5)
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.5)
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.5)
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.5)
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.5)
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.5)
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,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
@@ -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.6"
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)
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