htmlToPdf 2.0.0 → 3.0.0
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/.github/workflows/ci.yml +39 -0
- data/.github/workflows/release.yml +39 -0
- data/.gitignore +9 -0
- data/.rspec +3 -0
- data/CHANGELOG.md +48 -0
- data/Gemfile.lock +313 -5
- data/README.md +100 -19
- data/Rakefile +4 -0
- data/config.ru +9 -0
- data/docs/architecture.md +99 -0
- data/docs/contributing.md +60 -0
- data/docs/rails-integration.md +111 -0
- data/docs/security.md +81 -0
- data/htmlToPdf.gemspec +18 -4
- data/lib/htmlToPdf/browser_pool.rb +50 -0
- data/lib/htmlToPdf/configuration.rb +30 -5
- data/lib/htmlToPdf/print_options.rb +122 -0
- data/lib/htmlToPdf/version.rb +1 -1
- data/lib/htmlToPdf.rb +67 -60
- data/spec/htmlToPdf/browser_pool_spec.rb +89 -0
- data/spec/htmlToPdf/configuration_spec.rb +51 -0
- data/spec/htmlToPdf/print_options_spec.rb +154 -0
- data/spec/integration/controller_spec.rb +75 -0
- data/spec/internal/app/controllers/application_controller.rb +3 -0
- data/spec/internal/app/controllers/booms_controller.rb +16 -0
- data/spec/internal/app/controllers/widgets_controller.rb +25 -0
- data/spec/internal/app/views/layouts/application.html.erb +4 -0
- data/spec/internal/app/views/layouts/pdf.html.erb +4 -0
- data/spec/internal/app/views/widgets/show.html.erb +2 -0
- data/spec/internal/config/routes.rb +6 -0
- data/spec/internal/log/.gitignore +1 -0
- data/spec/internal/public/favicon.ico +0 -0
- data/spec/spec_helper.rb +31 -0
- metadata +187 -10
data/lib/htmlToPdf.rb
CHANGED
|
@@ -1,90 +1,97 @@
|
|
|
1
1
|
require "securerandom"
|
|
2
|
+
require "active_support/concern"
|
|
3
|
+
require "active_support/core_ext/object/blank"
|
|
2
4
|
require "htmlToPdf/version"
|
|
3
5
|
require "htmlToPdf/configuration"
|
|
6
|
+
require "htmlToPdf/browser_pool"
|
|
7
|
+
require "htmlToPdf/print_options"
|
|
4
8
|
|
|
5
9
|
module HtmlToPdf
|
|
10
|
+
extend ActiveSupport::Concern
|
|
11
|
+
|
|
12
|
+
# Wire up with `include HtmlToPdf` then `around_action :html_to_pdf` (not
|
|
13
|
+
# `before_action`) in the controller(s) that should support it — typically
|
|
14
|
+
# ApplicationController. An around_action is required: it lets the rest of
|
|
15
|
+
# the callback chain and the action itself run normally via `yield` (so
|
|
16
|
+
# other before_actions' instance variables are available), rather than
|
|
17
|
+
# re-invoking the action out-of-band the way earlier versions of this gem
|
|
18
|
+
# did.
|
|
19
|
+
def html_to_pdf
|
|
20
|
+
unless pdf_request?
|
|
21
|
+
yield
|
|
22
|
+
return
|
|
23
|
+
end
|
|
6
24
|
|
|
7
|
-
def html_to_pdf()
|
|
8
25
|
pdf_options = params[:pdf_options] ? params[:pdf_options] : {}
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
end
|
|
26
|
+
initialize_downloads(pdf_options)
|
|
27
|
+
|
|
28
|
+
# Run the real callback chain/action with the format forced to :html, so
|
|
29
|
+
# Rails' own implicit rendering resolves the normal HTML template instead
|
|
30
|
+
# of raising UnknownFormat for a nonexistent .pdf template. That render's
|
|
31
|
+
# output is discarded — render_to_string below (not response.body) is
|
|
32
|
+
# what actually gets converted — this only needs to happen so the action
|
|
33
|
+
# itself runs in its ordinary place in the chain.
|
|
34
|
+
original_format = request.format
|
|
35
|
+
request.format = :html
|
|
36
|
+
begin
|
|
37
|
+
yield
|
|
38
|
+
ensure
|
|
39
|
+
request.format = original_format
|
|
40
|
+
end
|
|
25
41
|
|
|
26
|
-
|
|
27
|
-
|
|
42
|
+
html = render_to_string(template: "#{controller_name}/#{action_name}", layout: @layout, locals: { url: Rails.application.routes.url_helpers })
|
|
43
|
+
file_content = html.gsub(/\\"/, "")
|
|
28
44
|
|
|
45
|
+
tmp_dir = HtmlToPdf.configuration.tmp_dir || File.join(Rails.root, 'tmp')
|
|
46
|
+
FileUtils.mkdir_p(tmp_dir)
|
|
47
|
+
source_file = File.join(tmp_dir, "#{Time.now.to_i}_#{SecureRandom.hex(8)}.html")
|
|
48
|
+
File.write(source_file, file_content)
|
|
29
49
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
File.delete(_source_file) if File.exist?(_source_file)
|
|
35
|
-
send_data File.open(_destination_file, "rb") { |f| f.read }, :disposition => 'attachment', :filename => "#{sanitized_pdf_name}.pdf"
|
|
36
|
-
File.delete(_destination_file)
|
|
50
|
+
begin
|
|
51
|
+
pdf_bytes = render_pdf(source_file)
|
|
52
|
+
ensure
|
|
53
|
+
File.delete(source_file) if File.exist?(source_file)
|
|
37
54
|
end
|
|
55
|
+
|
|
56
|
+
deliver_pdf(pdf_bytes)
|
|
38
57
|
end
|
|
39
58
|
|
|
40
59
|
def initialize_downloads(pdf_options = {})
|
|
41
60
|
pdf_options ||= {}
|
|
42
61
|
@name = pdf_options[:title].nil? ? "#{Time.now.to_i.to_s}" : pdf_options[:title]
|
|
43
62
|
@layout = pdf_options[:layout].nil? ? HtmlToPdf.configuration.default_layout : pdf_options[:layout]
|
|
44
|
-
@
|
|
63
|
+
@print_options, @javascript_delay = HtmlToPdf::PrintOptions.build(pdf_options)
|
|
45
64
|
end
|
|
46
65
|
|
|
47
66
|
def sanitized_pdf_name
|
|
48
67
|
@name.to_s.gsub(/[^a-zA-Z0-9_\- ]/, '').strip.presence || Time.now.to_i.to_s
|
|
49
68
|
end
|
|
50
69
|
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
padding = pdf_options[:padding].to_s
|
|
61
|
-
%w[margin-top margin-bottom margin-left margin-right].each { |flag| options[flag] = padding }
|
|
70
|
+
# Renders the given HTML file to PDF bytes via a pooled headless Chrome
|
|
71
|
+
# instance. Navigating to a file:// URL (rather than feeding Chrome the
|
|
72
|
+
# markup directly) gives relative asset paths a real base URI to resolve
|
|
73
|
+
# against, matching how a browser would load the page.
|
|
74
|
+
def render_pdf(source_file)
|
|
75
|
+
HtmlToPdf::BrowserPool.with_page do |page|
|
|
76
|
+
page.go_to("file://#{source_file}")
|
|
77
|
+
sleep(@javascript_delay / 1000.0) if @javascript_delay.to_i.positive?
|
|
78
|
+
page.pdf(encoding: :binary, **(@print_options || {}))
|
|
62
79
|
end
|
|
63
|
-
|
|
64
|
-
options.merge!(dasherize_keys(pdf_options[:wkhtmltopdf_options] || {}))
|
|
65
|
-
options
|
|
66
80
|
end
|
|
67
81
|
|
|
68
|
-
|
|
69
|
-
hash.each_with_object({}) { |(key, value), acc| acc[key.to_s.tr('_', '-')] = value }
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
# Query-string values arrive as strings (e.g. grayscale: true serializes to
|
|
73
|
-
# "true" in a link_to href and comes back as that literal string), so plain
|
|
74
|
-
# booleans aren't enough to detect a falsy/truthy flag.
|
|
75
|
-
FALSEY_VALUES = [nil, false, '', '0', 'false', 'off'].freeze
|
|
76
|
-
TRUTHY_FLAG_VALUES = [true, 'true'].freeze
|
|
77
|
-
|
|
78
|
-
def wkhtmltopdf_args
|
|
79
|
-
(@wkhtmltopdf_options || {}).each_with_object([]) do |(flag, value), args|
|
|
80
|
-
normalized = value.is_a?(String) ? value.downcase : value
|
|
81
|
-
next if FALSEY_VALUES.include?(normalized)
|
|
82
|
+
private
|
|
82
83
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
end
|
|
84
|
+
def pdf_request?
|
|
85
|
+
request.format == 'application/pdf'
|
|
86
86
|
end
|
|
87
87
|
|
|
88
|
+
# Equivalent to send_data, but assigns response_body directly instead of
|
|
89
|
+
# going through #render. #render refuses a second render in the same
|
|
90
|
+
# action (AbstractController::DoubleRenderError) — and by this point one
|
|
91
|
+
# already happened, deliberately, via the forced-:html yield above.
|
|
92
|
+
def deliver_pdf(pdf_bytes)
|
|
93
|
+
send_file_headers!(disposition: 'attachment', filename: "#{sanitized_pdf_name}.pdf")
|
|
94
|
+
self.status = 200
|
|
95
|
+
self.response_body = pdf_bytes
|
|
96
|
+
end
|
|
88
97
|
end
|
|
89
|
-
|
|
90
|
-
ActionController::Base.send(:include, HtmlToPdf)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
require "spec_helper"
|
|
2
|
+
|
|
3
|
+
RSpec.describe HtmlToPdf::BrowserPool do
|
|
4
|
+
after { described_class.reset! }
|
|
5
|
+
|
|
6
|
+
describe "pooling (stubbed Ferrum, no real Chrome)" do
|
|
7
|
+
let(:fake_browser) { instance_double(Ferrum::Browser, quit: nil) }
|
|
8
|
+
|
|
9
|
+
before do
|
|
10
|
+
allow(Ferrum::Browser).to receive(:new).and_return(fake_browser)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
it "memoizes one browser across multiple with_page calls" do
|
|
14
|
+
allow(fake_browser).to receive(:create_page).and_yield(double("page")).twice
|
|
15
|
+
|
|
16
|
+
described_class.with_page { |_page| nil }
|
|
17
|
+
described_class.with_page { |_page| nil }
|
|
18
|
+
|
|
19
|
+
expect(Ferrum::Browser).to have_received(:new).once
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it "quits and drops the memoized browser on reset!" do
|
|
23
|
+
described_class.browser
|
|
24
|
+
|
|
25
|
+
described_class.reset!
|
|
26
|
+
|
|
27
|
+
expect(fake_browser).to have_received(:quit)
|
|
28
|
+
allow(Ferrum::Browser).to receive(:new).and_return(fake_browser)
|
|
29
|
+
described_class.browser
|
|
30
|
+
expect(Ferrum::Browser).to have_received(:new).twice
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it "retries once after a recoverable error, then succeeds on the new browser" do
|
|
34
|
+
call_count = 0
|
|
35
|
+
allow(fake_browser).to receive(:create_page) do |&block|
|
|
36
|
+
call_count += 1
|
|
37
|
+
raise Ferrum::DeadBrowserError if call_count == 1
|
|
38
|
+
|
|
39
|
+
block.call(double("page"))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
result = described_class.with_page { |_page| :ok }
|
|
43
|
+
|
|
44
|
+
expect(result).to eq(:ok)
|
|
45
|
+
expect(call_count).to eq(2)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it "gives up and raises after the retry also fails" do
|
|
49
|
+
allow(fake_browser).to receive(:create_page).and_raise(Ferrum::DeadBrowserError)
|
|
50
|
+
|
|
51
|
+
expect { described_class.with_page { |_page| nil } }.to raise_error(Ferrum::DeadBrowserError)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it "does not retry a non-recoverable error" do
|
|
55
|
+
allow(fake_browser).to receive(:create_page).and_raise(ArgumentError, "boom")
|
|
56
|
+
|
|
57
|
+
expect { described_class.with_page { |_page| nil } }.to raise_error(ArgumentError)
|
|
58
|
+
expect(fake_browser).to have_received(:create_page).once
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it "passes configuration.browser_options straight to Ferrum::Browser.new" do
|
|
62
|
+
HtmlToPdf.configure { |c| c.browser_options = { timeout: 42 } }
|
|
63
|
+
allow(fake_browser).to receive(:create_page).and_yield(double("page"))
|
|
64
|
+
|
|
65
|
+
described_class.with_page { |_page| nil }
|
|
66
|
+
|
|
67
|
+
expect(Ferrum::Browser).to have_received(:new).with(timeout: 42)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
describe "against a real Chrome process", :chrome do
|
|
72
|
+
it "reuses the same browser process across renders" do
|
|
73
|
+
pids = Array.new(3) do
|
|
74
|
+
described_class.with_page do |page|
|
|
75
|
+
page.go_to("about:blank")
|
|
76
|
+
described_class.browser.process.pid
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
expect(pids.uniq.size).to eq(1)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it "does not leak a browser context per render (block form disposes it)" do
|
|
84
|
+
5.times { described_class.with_page { |page| page.go_to("about:blank") } }
|
|
85
|
+
|
|
86
|
+
expect(described_class.browser.contexts.size).to eq(0)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
require "spec_helper"
|
|
2
|
+
|
|
3
|
+
RSpec.describe HtmlToPdf::Configuration do
|
|
4
|
+
describe "defaults" do
|
|
5
|
+
subject(:config) { described_class.new }
|
|
6
|
+
|
|
7
|
+
it "has no browser_options, letting Ferrum auto-detect Chrome" do
|
|
8
|
+
expect(config.browser_options).to eq({})
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
it "defaults to the application layout" do
|
|
12
|
+
expect(config.default_layout).to eq("application")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
it "prints backgrounds by default" do
|
|
16
|
+
expect(config.default_options).to eq(print_background: true)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it "does not honor request-supplied header/footer templates by default" do
|
|
20
|
+
expect(config.allow_request_templates).to eq(false)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it "caps javascript_delay at 10 seconds by default" do
|
|
24
|
+
expect(config.max_javascript_delay_ms).to eq(10_000)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
describe "HtmlToPdf.configure" do
|
|
29
|
+
after { HtmlToPdf.reset_configuration! }
|
|
30
|
+
|
|
31
|
+
it "yields the shared configuration for mutation" do
|
|
32
|
+
HtmlToPdf.configure { |c| c.default_layout = "pdf" }
|
|
33
|
+
|
|
34
|
+
expect(HtmlToPdf.configuration.default_layout).to eq("pdf")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it "memoizes the same configuration instance across calls" do
|
|
38
|
+
expect(HtmlToPdf.configuration).to equal(HtmlToPdf.configuration)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
describe ".reset_configuration!" do
|
|
43
|
+
it "replaces the configuration with fresh defaults" do
|
|
44
|
+
HtmlToPdf.configure { |c| c.default_layout = "pdf" }
|
|
45
|
+
|
|
46
|
+
HtmlToPdf.reset_configuration!
|
|
47
|
+
|
|
48
|
+
expect(HtmlToPdf.configuration.default_layout).to eq("application")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
require "spec_helper"
|
|
2
|
+
|
|
3
|
+
RSpec.describe HtmlToPdf::PrintOptions do
|
|
4
|
+
def build(pdf_options)
|
|
5
|
+
described_class.build(pdf_options)
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
describe "precedence" do
|
|
9
|
+
it "lets a per-request :padding override a gem-wide default margin" do
|
|
10
|
+
HtmlToPdf.configure { |c| c.default_options = { margin_top: "1in" } }
|
|
11
|
+
|
|
12
|
+
options, = build(padding: "20mm")
|
|
13
|
+
|
|
14
|
+
expect(options[:margin_top]).to be_within(0.0001).of(20 / 25.4)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it "lets an explicit print_options margin win over :padding" do
|
|
18
|
+
options, = build(padding: "20mm", print_options: { margin_top: "5mm" })
|
|
19
|
+
|
|
20
|
+
expect(options[:margin_top]).to be_within(0.0001).of(5 / 25.4)
|
|
21
|
+
expect(options[:margin_bottom]).to be_within(0.0001).of(20 / 25.4)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it "lets explicit paper_width/paper_height override a gem-wide default :format" do
|
|
25
|
+
HtmlToPdf.configure { |c| c.default_options = { format: "A4" } }
|
|
26
|
+
|
|
27
|
+
options, = build(print_options: { paper_width: 5, paper_height: 5 })
|
|
28
|
+
|
|
29
|
+
expect(options[:paper_width]).to eq(5.0)
|
|
30
|
+
expect(options[:paper_height]).to eq(5.0)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it "resolves a per-request :format, overriding the default" do
|
|
34
|
+
HtmlToPdf.configure { |c| c.default_options = { format: "A4" } }
|
|
35
|
+
|
|
36
|
+
options, = build(print_options: { format: "Letter" })
|
|
37
|
+
|
|
38
|
+
expect(options[:paper_width]).to eq(8.50)
|
|
39
|
+
expect(options[:paper_height]).to eq(11.00)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it "falls back to the resolved default format when nothing is passed" do
|
|
43
|
+
HtmlToPdf.configure { |c| c.default_options = { format: "A4" } }
|
|
44
|
+
|
|
45
|
+
options, = build({})
|
|
46
|
+
|
|
47
|
+
expect(options[:paper_width]).to eq(8.27)
|
|
48
|
+
expect(options[:paper_height]).to eq(11.70)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
describe "unit conversion" do
|
|
53
|
+
%w[mm cm in px].each do |unit|
|
|
54
|
+
it "accepts #{unit} units on size keys" do
|
|
55
|
+
options, = build(print_options: { margin_top: "10#{unit}" })
|
|
56
|
+
|
|
57
|
+
expect(options[:margin_top]).to be_a(Float)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it "treats a bare number as inches" do
|
|
62
|
+
options, = build(print_options: { margin_top: 2 })
|
|
63
|
+
|
|
64
|
+
expect(options[:margin_top]).to eq(2.0)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it "raises on an unrecognized size value" do
|
|
68
|
+
expect { build(print_options: { margin_top: "lots" }) }.to raise_error(ArgumentError)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
describe "paper format" do
|
|
73
|
+
it "raises on an unknown format name" do
|
|
74
|
+
expect { build(print_options: { format: "Poster" }) }.to raise_error(ArgumentError)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it "is case-insensitive" do
|
|
78
|
+
options, = build(print_options: { format: "a4" })
|
|
79
|
+
|
|
80
|
+
expect(options[:paper_width]).to eq(8.27)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
describe "header_template / footer_template" do
|
|
85
|
+
it "strips them from request-supplied print_options by default" do
|
|
86
|
+
options, = build(print_options: { header_template: "<script>evil()</script>" })
|
|
87
|
+
|
|
88
|
+
expect(options).not_to have_key(:header_template)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
it "still honors them from config.default_options, which is trusted app code" do
|
|
92
|
+
HtmlToPdf.configure { |c| c.default_options = { header_template: "<span>trusted</span>" } }
|
|
93
|
+
|
|
94
|
+
options, = build({})
|
|
95
|
+
|
|
96
|
+
expect(options[:header_template]).to eq("<span>trusted</span>")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it "honors a request-supplied template only when explicitly opted in" do
|
|
100
|
+
HtmlToPdf.configure { |c| c.allow_request_templates = true }
|
|
101
|
+
|
|
102
|
+
options, = build(print_options: { footer_template: "<span>ok</span>" })
|
|
103
|
+
|
|
104
|
+
expect(options[:footer_template]).to eq("<span>ok</span>")
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
describe "javascript_delay" do
|
|
109
|
+
it "is extracted out of the CDP options hash" do
|
|
110
|
+
options, delay = build(print_options: { javascript_delay: 250 })
|
|
111
|
+
|
|
112
|
+
expect(options).not_to have_key(:javascript_delay)
|
|
113
|
+
expect(delay).to eq(250)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
it "is clamped to config.max_javascript_delay_ms" do
|
|
117
|
+
HtmlToPdf.configure { |c| c.max_javascript_delay_ms = 1_000 }
|
|
118
|
+
|
|
119
|
+
_, delay = build(print_options: { javascript_delay: 999_999 })
|
|
120
|
+
|
|
121
|
+
expect(delay).to eq(1_000)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it "never goes negative" do
|
|
125
|
+
_, delay = build(print_options: { javascript_delay: -500 })
|
|
126
|
+
|
|
127
|
+
expect(delay).to eq(0)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
it "is nil when not given" do
|
|
131
|
+
_, delay = build({})
|
|
132
|
+
|
|
133
|
+
expect(delay).to be_nil
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
describe "legacy flag names" do
|
|
138
|
+
it "no longer recognizes wkhtmltopdf-style orientation/page_size" do
|
|
139
|
+
options, = build(print_options: { orientation: "Landscape", page_size: "A4" })
|
|
140
|
+
|
|
141
|
+
expect(options[:landscape]).to be_nil
|
|
142
|
+
expect(options[:orientation]).to eq("Landscape")
|
|
143
|
+
expect(options[:paper_width]).to be_nil
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
describe "key formatting" do
|
|
148
|
+
it "accepts dasherized keys the same as underscored ones" do
|
|
149
|
+
options, = build(print_options: { "margin-top" => "10mm" })
|
|
150
|
+
|
|
151
|
+
expect(options[:margin_top]).to be_within(0.0001).of(10 / 25.4)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
require "spec_helper"
|
|
2
|
+
|
|
3
|
+
# These exercise the controller hook (include HtmlToPdf + around_action)
|
|
4
|
+
# against a real Rails dispatch, not a stub. An earlier version of this gem
|
|
5
|
+
# hooked in as a before_action that quietly rebuilt the controller and
|
|
6
|
+
# re-ran the action on the copy; Rails' own `response=` setter is documented
|
|
7
|
+
# to mark the response as already rendered the moment it's assigned, and
|
|
8
|
+
# copying the throwaway controller's instance variables back onto the real
|
|
9
|
+
# one carried that flag along, so `send_data` raised DoubleRenderError on
|
|
10
|
+
# every single PDF request once run against a real app rather than a stub.
|
|
11
|
+
# It also meant a before_action declared after html_to_pdf never got to run
|
|
12
|
+
# before the view was captured. These specs pin both down so they can't
|
|
13
|
+
# silently come back.
|
|
14
|
+
RSpec.describe "HtmlToPdf controller integration", type: :request do
|
|
15
|
+
around do |example|
|
|
16
|
+
HtmlToPdf.configure { |c| c.default_options = { print_background: true } }
|
|
17
|
+
example.run
|
|
18
|
+
ensure
|
|
19
|
+
HtmlToPdf::BrowserPool.reset!
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it "does not raise DoubleRenderError and returns a real PDF" do
|
|
23
|
+
get "/widgets/42", params: { format: :pdf }
|
|
24
|
+
|
|
25
|
+
expect(response).to have_http_status(:ok)
|
|
26
|
+
expect(response.media_type).to eq("application/pdf")
|
|
27
|
+
expect(response.body.byteslice(0, 4)).to eq("%PDF")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it "runs a before_action declared after the around_action before capturing the view" do
|
|
31
|
+
get "/widgets/42", params: { format: :pdf }
|
|
32
|
+
|
|
33
|
+
pdf_text = extract_text(response.body)
|
|
34
|
+
expect(pdf_text).to include("Widget #42")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it "runs the action body exactly once" do
|
|
38
|
+
WidgetsController.show_invocations = 0
|
|
39
|
+
|
|
40
|
+
get "/widgets/7", params: { format: :pdf }
|
|
41
|
+
|
|
42
|
+
expect(WidgetsController.show_invocations).to eq(1)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it "leaves ordinary HTML requests completely unaffected" do
|
|
46
|
+
get "/widgets/42"
|
|
47
|
+
|
|
48
|
+
expect(response).to have_http_status(:ok)
|
|
49
|
+
expect(response.media_type).to eq("text/html")
|
|
50
|
+
expect(response.body).to include("Widget #42")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it "honors a custom layout passed via pdf_options" do
|
|
54
|
+
get "/widgets/5", params: { format: :pdf, pdf_options: { layout: "pdf" } }
|
|
55
|
+
|
|
56
|
+
expect(extract_text(response.body)).to include("PDF LAYOUT")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it "still runs rescue_from for an exception raised inside the action" do
|
|
60
|
+
get "/booms/1", params: { format: :pdf }
|
|
61
|
+
|
|
62
|
+
expect(response).to have_http_status(:unprocessable_content)
|
|
63
|
+
expect(response.body).to eq("handled")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def extract_text(pdf_bytes)
|
|
67
|
+
require "tempfile"
|
|
68
|
+
file = Tempfile.new(["spec", ".pdf"], binmode: true)
|
|
69
|
+
file.write(pdf_bytes)
|
|
70
|
+
file.flush
|
|
71
|
+
`pdftotext #{file.path} -`
|
|
72
|
+
ensure
|
|
73
|
+
file&.close!
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
class BoomsController < ApplicationController
|
|
2
|
+
class Boom < StandardError; end
|
|
3
|
+
|
|
4
|
+
around_action :html_to_pdf, only: [:show]
|
|
5
|
+
rescue_from Boom, with: :handle_boom
|
|
6
|
+
|
|
7
|
+
def show
|
|
8
|
+
raise Boom, "deliberate failure for spec coverage"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def handle_boom
|
|
14
|
+
render plain: "handled", status: :unprocessable_content
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
class WidgetsController < ApplicationController
|
|
2
|
+
around_action :html_to_pdf, only: [:show]
|
|
3
|
+
|
|
4
|
+
# Declared after the around_action, matching the real-world ordering that
|
|
5
|
+
# exposed the original gem's bug: anything this sets must still be visible
|
|
6
|
+
# to the view when the request is being converted to a PDF.
|
|
7
|
+
before_action :set_widget, only: [:show]
|
|
8
|
+
|
|
9
|
+
cattr_accessor :show_invocations
|
|
10
|
+
self.show_invocations = 0
|
|
11
|
+
|
|
12
|
+
def index
|
|
13
|
+
render plain: "widgets index"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def show
|
|
17
|
+
self.class.show_invocations += 1
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def set_widget
|
|
23
|
+
@widget = "Widget ##{params[:id]}"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
*.log
|
|
File without changes
|
data/spec/spec_helper.rb
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
require "combustion"
|
|
2
|
+
Combustion.initialize! :action_controller, :action_view
|
|
3
|
+
|
|
4
|
+
require "rspec/rails"
|
|
5
|
+
require "htmlToPdf"
|
|
6
|
+
|
|
7
|
+
RSpec.configure do |config|
|
|
8
|
+
config.expect_with :rspec do |c|
|
|
9
|
+
c.syntax = :expect
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
config.before do
|
|
13
|
+
HtmlToPdf.reset_configuration!
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Specs tagged :chrome exercise a real headless Chrome/Chromium process.
|
|
17
|
+
# Skip them with a clear reason rather than a confusing Ferrum crash when
|
|
18
|
+
# no browser is installed, so running the suite stays contributor-friendly
|
|
19
|
+
# on a machine that just doesn't have Chrome.
|
|
20
|
+
chrome_available =
|
|
21
|
+
begin
|
|
22
|
+
browser = Ferrum::Browser.new
|
|
23
|
+
true
|
|
24
|
+
rescue Ferrum::BinaryNotFoundError
|
|
25
|
+
false
|
|
26
|
+
ensure
|
|
27
|
+
browser&.quit
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
config.filter_run_excluding(chrome: true) unless chrome_available
|
|
31
|
+
end
|