sghtmltopdf 0.4.0 → 0.5.1
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/Cargo.lock +14 -14
- data/README.md +27 -2
- data/ext/sghtmltopdf/Cargo.toml +14 -8
- data/ext/sghtmltopdf/extconf.rb +7 -7
- data/ext/sghtmltopdf/src/callback_sink.rs +89 -90
- data/ext/sghtmltopdf/src/errors.rs +27 -25
- data/ext/sghtmltopdf/src/gvl.rs +34 -34
- data/ext/sghtmltopdf/src/lib.rs +53 -56
- data/lib/sghtmltopdf/configuration.rb +17 -17
- data/lib/sghtmltopdf/options.rb +37 -40
- data/lib/sghtmltopdf/railtie.rb +5 -5
- data/lib/sghtmltopdf/renderer.rb +22 -22
- data/lib/sghtmltopdf/server_client.rb +18 -19
- data/lib/sghtmltopdf/version.rb +1 -1
- data/lib/sghtmltopdf.rb +33 -34
- metadata +1 -1
data/lib/sghtmltopdf/renderer.rb
CHANGED
|
@@ -1,25 +1,25 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Sghtmltopdf
|
|
4
|
-
# `render pdf: "invoice"
|
|
4
|
+
# Sorts the options of `render pdf: "invoice"` into three groups:
|
|
5
5
|
#
|
|
6
|
-
# * Rails
|
|
7
|
-
# *
|
|
8
|
-
# * PDF
|
|
6
|
+
# * those passed to Rails view rendering (`render_to_string`)
|
|
7
|
+
# * those passed to building the response (`send_data`)
|
|
8
|
+
# * those passed to PDF conversion (`Sghtmltopdf.render`)
|
|
9
9
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
10
|
+
#
|
|
11
|
+
# It is a pure Ruby class with no dependency on Rails, so it can be unit tested without Rails.
|
|
12
12
|
class Renderer
|
|
13
|
-
# `render_to_string
|
|
13
|
+
# The keys passed straight to `render_to_string`.
|
|
14
14
|
RAILS_RENDER_KEYS = %i[
|
|
15
15
|
action assigns body collection file formats handlers html inline layout
|
|
16
16
|
locals object partial plain prefixes template variants
|
|
17
17
|
].freeze
|
|
18
18
|
|
|
19
|
-
#
|
|
19
|
+
# The keys used to build the response (passed to `send_data`).
|
|
20
20
|
RESPONSE_KEYS = %i[disposition filename status].freeze
|
|
21
21
|
|
|
22
|
-
#
|
|
22
|
+
# The keys the renderer itself interprets (for debugging: return the HTML rather than a PDF).
|
|
23
23
|
RENDERER_KEYS = %i[show_as_html].freeze
|
|
24
24
|
|
|
25
25
|
PDF_CONTENT_TYPE = "application/pdf"
|
|
@@ -27,17 +27,17 @@ module Sghtmltopdf
|
|
|
27
27
|
|
|
28
28
|
attr_reader :name, :options
|
|
29
29
|
|
|
30
|
-
# @param name [String, Symbol, nil] `pdf
|
|
31
|
-
# @param options [Hash] `render
|
|
32
|
-
# @param default_name [String, nil] `name
|
|
33
|
-
# (
|
|
30
|
+
# @param name [String, Symbol, nil] the value given to `pdf:` (the basis of the file name)
|
|
31
|
+
# @param options [Hash] the other options given to `render`
|
|
32
|
+
# @param default_name [String, nil] the file name when `name` is empty
|
|
33
|
+
# (the controller's `action_name` is expected)
|
|
34
34
|
def initialize(name, options = {}, default_name: nil)
|
|
35
35
|
@name = blank?(name) ? (default_name || "document").to_s : name.to_s
|
|
36
36
|
@options = options.to_h { |key, value| [key.to_sym, value] }
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
-
# `ActionController::Renderers.add(:pdf)
|
|
40
|
-
# Railtie
|
|
39
|
+
# Register the renderer with `ActionController::Renderers.add(:pdf)`.
|
|
40
|
+
# Called from the Railtie's Action Controller load hook (`on_load`).
|
|
41
41
|
def self.register!
|
|
42
42
|
::ActionController::Renderers.add(:pdf) do |name, options|
|
|
43
43
|
renderer = ::Sghtmltopdf::Renderer.new(name, options, default_name: action_name)
|
|
@@ -46,18 +46,18 @@ module Sghtmltopdf
|
|
|
46
46
|
end
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
-
#
|
|
49
|
+
# The options used to render the view.
|
|
50
50
|
def render_options
|
|
51
51
|
options.select { |key, _| RAILS_RENDER_KEYS.include?(key) }
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
-
# PDF
|
|
54
|
+
# The options used for the PDF conversion.
|
|
55
55
|
def convert_options
|
|
56
56
|
known = RAILS_RENDER_KEYS + RESPONSE_KEYS + RENDERER_KEYS
|
|
57
57
|
options.reject { |key, _| known.include?(key) }
|
|
58
58
|
end
|
|
59
59
|
|
|
60
|
-
#
|
|
60
|
+
# Turn the rendered HTML into the response body.
|
|
61
61
|
def body_for(html)
|
|
62
62
|
show_as_html? ? html : Sghtmltopdf.render(html, **convert_options)
|
|
63
63
|
end
|
|
@@ -73,19 +73,19 @@ module Sghtmltopdf
|
|
|
73
73
|
show_as_html? ? HTML_CONTENT_TYPE : PDF_CONTENT_TYPE
|
|
74
74
|
end
|
|
75
75
|
|
|
76
|
-
# `filename: "x.pdf"`
|
|
76
|
+
# `filename: "x.pdf"` wins over `pdf: "x"`. The extension is not doubled up.
|
|
77
77
|
def filename
|
|
78
78
|
base = blank?(options[:filename]) ? name : options[:filename].to_s
|
|
79
79
|
base.downcase.end_with?(".pdf") ? base : "#{base}.pdf"
|
|
80
80
|
end
|
|
81
81
|
|
|
82
|
-
#
|
|
82
|
+
# The default is `inline` (opened in the browser), as in wicked_pdf.
|
|
83
83
|
def disposition
|
|
84
84
|
blank?(options[:disposition]) ? "inline" : options[:disposition].to_s
|
|
85
85
|
end
|
|
86
86
|
|
|
87
|
-
# wicked_pdf
|
|
88
|
-
#
|
|
87
|
+
# The equivalent of wicked_pdf's `show_as_html`. It returns the HTML rather than a PDF, so
|
|
88
|
+
# the layout can be inspected in the browser's developer tools.
|
|
89
89
|
def show_as_html?
|
|
90
90
|
value = options[:show_as_html]
|
|
91
91
|
!(value.nil? || value == false || value == "false")
|
|
@@ -6,7 +6,7 @@ require "uri"
|
|
|
6
6
|
module Sghtmltopdf
|
|
7
7
|
class ServerError < Error; end
|
|
8
8
|
|
|
9
|
-
# HTTP
|
|
9
|
+
# The client delegating conversion to HTTP server mode (`sghtmltopdf server`).
|
|
10
10
|
#
|
|
11
11
|
# Sghtmltopdf.configure { |c| c.server_url = "http://pdf.internal:8080" }
|
|
12
12
|
# pdf = Sghtmltopdf.render(html, page_size: "A4")
|
|
@@ -14,29 +14,28 @@ module Sghtmltopdf
|
|
|
14
14
|
DEFAULT_OPEN_TIMEOUT = 5
|
|
15
15
|
DEFAULT_READ_TIMEOUT = 120
|
|
16
16
|
|
|
17
|
-
#
|
|
17
|
+
# The rough size of each read. With `?stream=1` this is the unit handed to the block.
|
|
18
18
|
CHUNK_SIZE = 64 * 1024
|
|
19
19
|
|
|
20
20
|
attr_reader :uri, :open_timeout, :read_timeout
|
|
21
21
|
|
|
22
|
-
# @param url [String]
|
|
22
|
+
# @param url [String] the server's base URL (`http://host:port`)
|
|
23
23
|
def initialize(url, open_timeout: nil, read_timeout: nil)
|
|
24
24
|
@uri = parse(url)
|
|
25
25
|
@open_timeout = (open_timeout || DEFAULT_OPEN_TIMEOUT).to_f
|
|
26
26
|
@read_timeout = (read_timeout || DEFAULT_READ_TIMEOUT).to_f
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
# HTML
|
|
29
|
+
# Convert HTML to PDF.
|
|
30
30
|
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
# PDF全体をStringで返す。
|
|
31
|
+
# Given a block it uses `?stream=1` (chunked transfer encoding) and hands over chunks as
|
|
32
|
+
# the server settles each page. With no block it returns the whole PDF as a String.
|
|
34
33
|
def render(html, options, &block)
|
|
35
34
|
request = build_request(html, options, stream: !block.nil?)
|
|
36
35
|
pdf = nil
|
|
37
36
|
start do |http|
|
|
38
|
-
# `request
|
|
39
|
-
#
|
|
37
|
+
# With a block, `request` returns the response object, so the result is received in an
|
|
38
|
+
# outer variable.
|
|
40
39
|
http.request(request) do |response|
|
|
41
40
|
ensure_success!(response)
|
|
42
41
|
if block
|
|
@@ -49,9 +48,9 @@ module Sghtmltopdf
|
|
|
49
48
|
pdf
|
|
50
49
|
end
|
|
51
50
|
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
51
|
+
# Write the conversion result to `path`. To leave no broken PDF on a failure part-way
|
|
52
|
+
# through, it writes to a temporary file and renames (matching the behaviour of the
|
|
53
|
+
# native extension's `FileSink`).
|
|
55
54
|
def render_to_file(html, options, path)
|
|
56
55
|
tmp = "#{path}.#{Process.pid}.tmp"
|
|
57
56
|
begin
|
|
@@ -60,7 +59,7 @@ module Sghtmltopdf
|
|
|
60
59
|
end
|
|
61
60
|
rescue SystemCallError => e
|
|
62
61
|
File.unlink(tmp) if File.exist?(tmp)
|
|
63
|
-
raise InputError, "#{path}
|
|
62
|
+
raise InputError, "failed to write to #{path}: #{e.message}"
|
|
64
63
|
rescue StandardError
|
|
65
64
|
File.unlink(tmp) if File.exist?(tmp)
|
|
66
65
|
raise
|
|
@@ -74,7 +73,7 @@ module Sghtmltopdf
|
|
|
74
73
|
def parse(url)
|
|
75
74
|
uri = URI.parse(url.to_s)
|
|
76
75
|
unless uri.is_a?(URI::HTTP) && uri.host
|
|
77
|
-
raise ArgumentError, "server_url
|
|
76
|
+
raise ArgumentError, "server_url must be an http(s) URL: #{url.inspect}"
|
|
78
77
|
end
|
|
79
78
|
|
|
80
79
|
uri
|
|
@@ -102,12 +101,12 @@ module Sghtmltopdf
|
|
|
102
101
|
&block
|
|
103
102
|
)
|
|
104
103
|
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
105
|
-
raise ServerError, "#{base}
|
|
104
|
+
raise ServerError, "the connection to #{base} timed out: #{e.class}"
|
|
106
105
|
rescue SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError => e
|
|
107
|
-
raise ServerError, "#{base}
|
|
106
|
+
raise ServerError, "the connection to #{base} failed: #{e.message}"
|
|
108
107
|
end
|
|
109
108
|
|
|
110
|
-
#
|
|
109
|
+
# An error response's body is a `text/plain` message (the same wording as the CLI).
|
|
111
110
|
def ensure_success!(response)
|
|
112
111
|
return if response.is_a?(Net::HTTPOK)
|
|
113
112
|
|
|
@@ -120,8 +119,8 @@ module Sghtmltopdf
|
|
|
120
119
|
when 400 then UsageError
|
|
121
120
|
when 413 then InputError
|
|
122
121
|
when 500 then RenderError
|
|
123
|
-
# 404
|
|
124
|
-
#
|
|
122
|
+
# A 404 or 405 means a wrong path or method, most likely because the other end is not
|
|
123
|
+
# an sghtmltopdf server. A 503 or 504 means the queue overflowed or the queue wait timed out.
|
|
125
124
|
else ServerError
|
|
126
125
|
end
|
|
127
126
|
end
|
data/lib/sghtmltopdf/version.rb
CHANGED
data/lib/sghtmltopdf.rb
CHANGED
|
@@ -5,9 +5,9 @@ require_relative "sghtmltopdf/options"
|
|
|
5
5
|
require_relative "sghtmltopdf/configuration"
|
|
6
6
|
require_relative "sghtmltopdf/renderer"
|
|
7
7
|
|
|
8
|
-
# precompiled gem
|
|
9
|
-
# (rake-compiler
|
|
10
|
-
# `lib/sghtmltopdf/sghtmltopdf.so
|
|
8
|
+
# A precompiled gem puts the `.so` in a directory per Ruby minor version
|
|
9
|
+
# (rake-compiler's cross-build convention). During development `rake compile` puts it in
|
|
10
|
+
# `lib/sghtmltopdf/sghtmltopdf.so`, so both are tried.
|
|
11
11
|
begin
|
|
12
12
|
RUBY_VERSION =~ /(\d+\.\d+)/
|
|
13
13
|
require "sghtmltopdf/#{Regexp.last_match(1)}/sghtmltopdf"
|
|
@@ -15,37 +15,36 @@ rescue LoadError
|
|
|
15
15
|
require "sghtmltopdf/sghtmltopdf"
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
# `Error`(
|
|
18
|
+
# It inherits `Error` (defined by the native extension), so it comes after the extension is loaded.
|
|
19
19
|
require_relative "sghtmltopdf/server_client"
|
|
20
20
|
|
|
21
21
|
module Sghtmltopdf
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
22
|
+
# The rough number of bytes handed over per call to a block-taking `render` (local conversion only).
|
|
23
|
+
# Calling the block on every settled page would mean reacquiring the GVL too often, so this
|
|
24
|
+
# much is accumulated first.
|
|
25
25
|
DEFAULT_CHUNK_SIZE = 64 * 1024
|
|
26
26
|
|
|
27
27
|
class << self
|
|
28
|
-
# HTML
|
|
28
|
+
# Convert HTML and return the PDF bytes (an ASCII-8BIT String).
|
|
29
29
|
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
# を意識しない設計に対応する)。
|
|
30
|
+
# Given a block, it calls the block per chunk rather than assembling the whole PDF first
|
|
31
|
+
# (returning nil). It is the hook for streaming into Rack's `response.stream` or into an
|
|
32
|
+
# S3 multipart upload (matching the engine's design, which is unaware of the output sink).
|
|
34
33
|
#
|
|
35
34
|
# Sghtmltopdf.render(html) { |bytes| response.stream.write(bytes) }
|
|
36
35
|
#
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
# chunked transfer encoding
|
|
36
|
+
# Both locally and when delegating to a server, it can be written out without waiting for
|
|
37
|
+
# the whole PDF to be assembled (locally page by page as they settle; from a server, the
|
|
38
|
+
# `?stream=1` chunked transfer encoding passed straight through).
|
|
40
39
|
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
# (
|
|
40
|
+
# Only the PDF writing is incremental, though: HTML parsing and layout still happen for
|
|
41
|
+
# the whole document first. The first chunk arrives late in the conversion, and the peak
|
|
42
|
+
# memory is no different from the block-less case. To settle pages while the HTML is being
|
|
43
|
+
# read, use it together with `streaming: true`
|
|
44
|
+
# (which trades constraints for a large reduction in memory).
|
|
46
45
|
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
46
|
+
# The rough bytes per call can be changed with `chunk_size:` (64KiB by default; local
|
|
47
|
+
# conversion only; a smaller value means reacquiring the GVL more often).
|
|
49
48
|
def render(html, **options, &block)
|
|
50
49
|
options = Options.canonicalize(options)
|
|
51
50
|
client = server_client(options)
|
|
@@ -56,10 +55,10 @@ module Sghtmltopdf
|
|
|
56
55
|
nil
|
|
57
56
|
end
|
|
58
57
|
|
|
59
|
-
# HTML
|
|
58
|
+
# Convert HTML and write it to `path`.
|
|
60
59
|
#
|
|
61
|
-
#
|
|
62
|
-
#
|
|
60
|
+
# It writes to a temporary file and renames only on success, so a failure part-way through
|
|
61
|
+
# leaves no broken PDF at the destination (the same when delegating to a server).
|
|
63
62
|
def render_to_file(html, path, **options)
|
|
64
63
|
options = Options.canonicalize(options)
|
|
65
64
|
client = server_client(options)
|
|
@@ -69,7 +68,7 @@ module Sghtmltopdf
|
|
|
69
68
|
nil
|
|
70
69
|
end
|
|
71
70
|
|
|
72
|
-
#
|
|
71
|
+
# The global default options.
|
|
73
72
|
def configure
|
|
74
73
|
yield config
|
|
75
74
|
config
|
|
@@ -79,19 +78,19 @@ module Sghtmltopdf
|
|
|
79
78
|
@config ||= Configuration.new
|
|
80
79
|
end
|
|
81
80
|
|
|
82
|
-
#
|
|
81
|
+
# Mainly for tests. Resets the configuration to empty.
|
|
83
82
|
def reset_config!
|
|
84
83
|
@config = Configuration.new
|
|
85
84
|
end
|
|
86
85
|
|
|
87
86
|
private
|
|
88
87
|
|
|
89
|
-
#
|
|
88
|
+
# Merge in the order global settings, then call-time options, and turn that into argv.
|
|
90
89
|
def argv_for(options)
|
|
91
90
|
Options.to_argv(config.to_h.merge(options))
|
|
92
91
|
end
|
|
93
92
|
|
|
94
|
-
# `server_url
|
|
93
|
+
# With a `server_url` it delegates to the server. The timeout merges in the same order.
|
|
95
94
|
def server_client(options)
|
|
96
95
|
merged = config.to_h.merge(options)
|
|
97
96
|
url = merged[:server_url]
|
|
@@ -104,16 +103,16 @@ module Sghtmltopdf
|
|
|
104
103
|
)
|
|
105
104
|
end
|
|
106
105
|
|
|
107
|
-
#
|
|
106
|
+
# The rough bytes handed to the block per call (local conversion only).
|
|
108
107
|
def chunk_size(options)
|
|
109
108
|
value = config.to_h.merge(options)[:chunk_size]
|
|
110
109
|
value.nil? ? DEFAULT_CHUNK_SIZE : Integer(value)
|
|
111
110
|
end
|
|
112
111
|
|
|
113
|
-
#
|
|
114
|
-
# (Rails
|
|
115
|
-
#
|
|
116
|
-
#
|
|
112
|
+
# The options passed to the server. The injected defaults are removed
|
|
113
|
+
# (the Rails-oriented `base_url` and `allow` are defaults for local file resolution, and
|
|
114
|
+
# server mode cannot take them from a request, giving a 400.
|
|
115
|
+
# An explicitly set value is sent as-is and the server decides whether to accept it).
|
|
117
116
|
def server_options(options)
|
|
118
117
|
config.to_h(with_defaults: false).merge(options)
|
|
119
118
|
end
|