htmlToPdf 0.1.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.
data/lib/htmlToPdf.rb CHANGED
@@ -1,46 +1,97 @@
1
+ require "securerandom"
2
+ require "active_support/concern"
3
+ require "active_support/core_ext/object/blank"
1
4
  require "htmlToPdf/version"
5
+ require "htmlToPdf/configuration"
6
+ require "htmlToPdf/browser_pool"
7
+ require "htmlToPdf/print_options"
2
8
 
3
9
  module HtmlToPdf
10
+ extend ActiveSupport::Concern
4
11
 
5
- def html_to_pdf()
6
- pdf_options = params[:pdf_options] ? params[:pdf_options] : {}
7
- if request.format == 'application/pdf'
8
- initialize_downloads(pdf_options)
9
- FileUtils.mkdir_p(File.join(Rails.root, 'tmp'))
10
- _source_file = File.join(Rails.root, 'tmp', "#{Time.now.to_i.to_s}.html")
11
- _destination_file = File.join(Rails.root, 'tmp', "#{@name}.pdf")
12
- _controller = ("#{controller_name}Controller").camelize.constantize.new
13
- _controller.method(action_name).call
14
- _assigns = {}
15
- _controller.instance_variables.each do |var|
16
- _assigns[var] = _controller.instance_variable_get(var)
17
- end
18
- _assigns.merge!({url: Rails.application.routes.url_helpers})
19
-
20
- _html = render_to_string(template: "#{controller_name}/#{action_name}", :layout => @layout, locals: _assigns)
21
- file_content = _html.gsub(/\\"/, "")
22
-
23
-
24
- File.open(_source_file, "w+") do |f|
25
- f.write(file_content)
26
- end
27
- system("wkhtmltopdf #{_source_file} #{_destination_file}")
28
- File.delete(_source_file) if File.exist?(_source_file)
29
- send_data File.open(_destination_file, "rb") { |f| f.read }, :disposition => 'attachment',:filename => "#{@name}.pdf"
30
- File.delete(_destination_file)
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
31
23
  end
24
+
25
+ pdf_options = params[:pdf_options] ? params[:pdf_options] : {}
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
41
+
42
+ html = render_to_string(template: "#{controller_name}/#{action_name}", layout: @layout, locals: { url: Rails.application.routes.url_helpers })
43
+ file_content = html.gsub(/\\"/, "")
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)
49
+
50
+ begin
51
+ pdf_bytes = render_pdf(source_file)
52
+ ensure
53
+ File.delete(source_file) if File.exist?(source_file)
54
+ end
55
+
56
+ deliver_pdf(pdf_bytes)
32
57
  end
33
58
 
34
59
  def initialize_downloads(pdf_options = {})
35
- if pdf_options
36
- @name = pdf_options[:title].nil? ? "#{Time.now.to_i.to_s}" : pdf_options[:title]
37
- @layout = pdf_options[:layout].nil? ? 'application' : pdf_options[:layout]
38
- else
39
- @name = "#{Time.now.to_i.to_s}"
40
- @layout = 'application'
60
+ pdf_options ||= {}
61
+ @name = pdf_options[:title].nil? ? "#{Time.now.to_i.to_s}" : pdf_options[:title]
62
+ @layout = pdf_options[:layout].nil? ? HtmlToPdf.configuration.default_layout : pdf_options[:layout]
63
+ @print_options, @javascript_delay = HtmlToPdf::PrintOptions.build(pdf_options)
64
+ end
65
+
66
+ def sanitized_pdf_name
67
+ @name.to_s.gsub(/[^a-zA-Z0-9_\- ]/, '').strip.presence || Time.now.to_i.to_s
68
+ end
69
+
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 || {}))
41
79
  end
42
80
  end
43
81
 
44
- end
82
+ private
45
83
 
46
- ActionController::Base.send(:include, HtmlToPdf)
84
+ def pdf_request?
85
+ request.format == 'application/pdf'
86
+ end
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
97
+ end
@@ -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,3 @@
1
+ class ApplicationController < ActionController::Base
2
+ include HtmlToPdf
3
+ 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,4 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <body><%= yield %></body>
4
+ </html>
@@ -0,0 +1,4 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <body><strong>PDF LAYOUT</strong><%= yield %></body>
4
+ </html>
@@ -0,0 +1,2 @@
1
+ <h1><%= @widget %></h1>
2
+ <p>Rendered at <%= Time.now.to_i %></p>
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ Rails.application.routes.draw do
4
+ resources :widgets, only: [:index, :show]
5
+ resources :booms, only: [:show]
6
+ end
@@ -0,0 +1 @@
1
+ *.log
File without changes
@@ -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