himg 0.0.4 → 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.
@@ -1,15 +1,14 @@
1
- use blitz_dom::net::Resource;
2
1
  use blitz_html::HtmlDocument;
3
- use blitz_net::{MpscCallback, Provider};
4
- use blitz_renderer_vello::render_to_buffer;
5
- use blitz_traits::navigation::DummyNavigationProvider;
6
- use blitz_traits::net::SharedProvider;
7
- use blitz_traits::{ColorScheme, Viewport};
8
- use std::sync::Arc;
2
+ use blitz_dom::DocumentConfig;
3
+ use anyrender_vello_cpu::VelloCpuImageRenderer;
4
+ use anyrender::render_to_buffer;
5
+ use blitz_paint::paint_scene;
6
+ use blitz_traits::shell::{Viewport};
9
7
 
10
8
  use crate::image_size::ImageSize;
11
9
  use crate::logger::Logger;
12
10
  use crate::options::Options;
11
+ use crate::net_fetcher::NetFetcher;
13
12
 
14
13
  pub struct RenderOutput {
15
14
  pub buffer: Vec<u8>,
@@ -18,47 +17,43 @@ pub struct RenderOutput {
18
17
 
19
18
  pub async fn html_to_image(
20
19
  html: &str,
21
- base_url: Option<String>,
22
20
  options: Options,
23
21
  logger: &mut dyn Logger,
24
22
  ) -> RenderOutput {
25
- let (mut recv, callback) = MpscCallback::new();
26
- logger.log("Initial config");
23
+ let mut net_fetcher = if options.disable_fetch {
24
+ logger.log("Disabled fetching resources");
27
25
 
28
- let callback = Arc::new(callback);
29
- let net = Arc::new(Provider::new(callback));
30
- logger.log("Setup blitz-net Provider");
26
+ None
27
+ } else {
28
+ let fetcher = NetFetcher::new();
29
+ logger.log("Setup remote resource fetcher");
31
30
 
32
- let navigation_provider = Arc::new(DummyNavigationProvider);
33
- logger.log("Setup dummy navigation provider");
31
+ Some(fetcher)
32
+ };
34
33
 
35
34
  // Create HtmlDocument
36
35
  let mut document = HtmlDocument::from_html(
37
36
  &html,
38
- base_url,
39
- Vec::new(),
40
- Arc::clone(&net) as SharedProvider<Resource>,
41
- None,
42
- navigation_provider,
37
+ DocumentConfig {
38
+ base_url: options.base_url,
39
+ net_provider: net_fetcher.as_ref().map(|fetcher| fetcher.get_provider() as _),
40
+ ..Default::default()
41
+ },
43
42
  );
44
43
  logger.log("Parsed document");
45
44
 
46
45
  document.as_mut().set_viewport(Viewport::new(
47
46
  options.image_size.scaled_width(),
48
47
  options.image_size.scaled_height(),
49
- options.image_size.hidpi_scale,
48
+ options.image_size.hidpi_scale as f32,
50
49
  options.color_scheme,
51
50
  ));
52
51
 
53
- while !net.is_empty() {
54
- let Some((_, res)) = recv.recv().await else {
55
- break;
56
- };
57
- document.as_mut().load_resource(res);
52
+ if let Some(ref mut net_fetcher) = net_fetcher {
53
+ net_fetcher.fetch_resources(&mut document).await;
54
+ logger.log("Fetched assets");
58
55
  }
59
56
 
60
- logger.log("Fetched assets");
61
-
62
57
  // Compute style, layout, etc for HtmlDocument
63
58
  document.as_mut().resolve();
64
59
  logger.log("Resolved styles and layout");
@@ -76,19 +71,22 @@ pub async fn html_to_image(
76
71
  };
77
72
  logger.log("Calculated render dimensions from document");
78
73
 
79
- println!("Screenshot is ({}x{})",render_size.scaled_width(), render_size.scaled_height());
74
+ if options.verbose {
75
+ println!("Screenshot is ({}x{})",render_size.scaled_width(), render_size.scaled_height());
76
+ }
80
77
 
81
78
  // Render document to RGBA buffer
82
- let buffer = render_to_buffer(
83
- document.as_ref(),
84
- Viewport::new(
79
+ let buffer = render_to_buffer::<VelloCpuImageRenderer, _>(
80
+ |scene| paint_scene(
81
+ scene,
82
+ document.as_ref(),
83
+ render_size.hidpi_scale,
85
84
  render_size.scaled_width(),
86
85
  render_size.scaled_height(),
87
- render_size.hidpi_scale,
88
- ColorScheme::Light,
89
86
  ),
90
- )
91
- .await;
87
+ render_size.scaled_width(),
88
+ render_size.scaled_height(),
89
+ );
92
90
 
93
91
  logger.log("Rendered to buffer");
94
92
 
@@ -2,7 +2,7 @@
2
2
  pub struct ImageSize {
3
3
  pub width: u32,
4
4
  pub height: u32,
5
- pub hidpi_scale: f32,
5
+ pub hidpi_scale: f64,
6
6
  }
7
7
 
8
8
  impl ImageSize {
data/ext/himg/src/lib.rs CHANGED
@@ -4,32 +4,37 @@ 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;
10
+ pub use image_size::ImageSize;
11
+ pub use options::Options;
12
+ pub use html_to_image::html_to_image;
13
+ pub use writer::write_png;
9
14
 
10
- use crate::image_size::ImageSize;
11
- use crate::options::Options;
12
- use blitz_traits::ColorScheme;
15
+ use blitz_traits::shell::ColorScheme;
13
16
  use magnus::{function, prelude::*, ExceptionClass, Error, Ruby, RString, RHash};
14
17
 
15
18
  impl Options {
16
- fn get_option<V: magnus::TryConvert + magnus::IntoValue>(optional_hash: Option<RHash>, key: &str, default: V) -> Result<V, Error> {
17
- match optional_hash {
18
- Some(hash) => hash.lookup2::<&str, V, V>(key, default),
19
- None => Ok(default),
20
- }
21
- }
22
-
23
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
+
24
27
  let options = Options {
25
28
  image_size: ImageSize {
26
- width: Self::get_option(hash, "width", 720)?,
27
- height: Self::get_option(hash, "height", 405)?,
29
+ width: hash.lookup2("width", 720)?,
30
+ height: hash.lookup2("height", 405)?,
28
31
  hidpi_scale: 1.0,
29
32
  },
30
- truncate: Self::get_option(hash, "truncate", true)?,
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)?,
31
37
  color_scheme: ColorScheme::Light,
32
- allow_net_requests: true, //TODO: Implement using this
33
38
  };
34
39
 
35
40
  Ok(options)
@@ -50,7 +55,6 @@ pub fn render_blocking_rb(ruby: &Ruby, html: String, options: Option<RHash>) ->
50
55
  fn init(ruby: &Ruby) -> Result<(), Error> {
51
56
  let module = ruby.define_module("Himg")?;
52
57
 
53
- //TODO: Allow optional base_url for resolving linked resources (stylesheets, images, fonts, etc)
54
58
  module.define_singleton_method("render_to_string", function!(render_blocking_rb, 2))?;
55
59
 
56
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,11 +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,
12
+ pub verbose: bool,
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
+ }
11
30
  }
@@ -1,7 +1,7 @@
1
1
  use crate::html_to_image::html_to_image;
2
2
  use crate::options::Options;
3
3
  use crate::writer::write_png;
4
- use crate::logger::{TimedLogger};
4
+ use crate::logger::{Logger, NullLogger, TimedLogger};
5
5
 
6
6
  pub fn render_blocking(html: String, options: Options) -> Result<Vec<u8>, std::io::Error> {
7
7
  let runtime = tokio::runtime::Runtime::new()?;
@@ -11,12 +11,14 @@ pub fn render_blocking(html: String, options: Options) -> Result<Vec<u8>, std::i
11
11
 
12
12
  // render_to_bytes, render_to_string, render_to_file, render_to_io
13
13
  pub async fn render(html: String, options: Options) -> Result<Vec<u8>, std::io::Error> {
14
- let mut logger = TimedLogger::init();
14
+ let mut logger: Box<dyn Logger> = if options.verbose {
15
+ Box::new(TimedLogger::init())
16
+ } else {
17
+ Box::new(NullLogger{})
18
+ };
15
19
 
16
20
  // Render to Image
17
- //let base_url = format!("file://{}", path_string.clone());
18
- let base_url = None;
19
- 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;
20
22
 
21
23
  // Determine output path, and open a file at that path.
22
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
@@ -0,0 +1,31 @@
1
+ module Himg
2
+ class Railtie
3
+ module ControllerConfig
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ before_action :_apply_himg_config
8
+ end
9
+
10
+ class_methods do
11
+ def himg_config(options = {})
12
+ @_himg_global_config ||= {}
13
+ @_himg_global_config.merge!(options)
14
+ end
15
+
16
+ def _himg_global_config
17
+ @_himg_global_config&.dup || {}
18
+ end
19
+ end
20
+
21
+ def himg_config(options = {})
22
+ @_himg_config.merge!(options)
23
+ @_himg_config
24
+ end
25
+
26
+ def _apply_himg_config
27
+ @_himg_config = self.class._himg_global_config
28
+ end
29
+ end
30
+ end
31
+ end
@@ -4,7 +4,7 @@ module Himg
4
4
  class TemplateHandler
5
5
  def self.call(_template, source)
6
6
  <<-CODE
7
- Himg.render(#{source.inspect})
7
+ Himg.render(#{source.inspect}, **@_himg_config)
8
8
  CODE
9
9
  end
10
10
  end
@@ -21,7 +21,7 @@ module Himg
21
21
  output = begin
22
22
  #{preprocessed_view_code}
23
23
  end
24
- Himg.render(output)
24
+ Himg.render(output, **@_himg_config)
25
25
  CODE
26
26
  end
27
27
  end
data/lib/himg/railtie.rb CHANGED
@@ -29,10 +29,21 @@ module Himg
29
29
  end
30
30
 
31
31
  initializer "himg.controller_renderer" do
32
- ActionController::Renderers.add :himg do |obj, _options|
33
- png_data = Himg.render(obj)
32
+ ActionController::Renderers.add :himg do |obj, options = {}|
33
+ configured_options = options[:config] || {}
34
+ direct_options = options.symbolize_keys.slice(*Himg::RENDER_OPTIONS)
35
+ merged_options = configured_options.merge(direct_options)
36
+
37
+ png_data = Himg.render(obj, **merged_options)
34
38
  send_data png_data, type: "image/png", disposition: "inline"
35
39
  end
36
40
  end
41
+
42
+ initializer 'himg.controller_config' do
43
+ require "himg/railtie/controller_config"
44
+ ActiveSupport.on_load(:action_controller) do
45
+ include Himg::Railtie::ControllerConfig
46
+ end
47
+ end
37
48
  end
38
49
  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.4"
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.
@@ -16,9 +17,10 @@ end
16
17
  #
17
18
  # Converts HTML to an Image for a minimal subset of HTML and CSS
18
19
  module Himg
20
+ RENDER_OPTIONS = %i[width height truncate verbose].freeze
19
21
  class Error < StandardError; end
20
22
 
21
- def self.render(html, width: 720, height: 405, truncate: true)
22
- render_to_string(html, "width" => width, "height" => height, "truncate" => truncate)
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)
23
25
  end
24
26
  end