wicked_pdf 2.0.2 → 2.8.2

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,19 +1,99 @@
1
1
  require 'net/http'
2
- # If webpacker is used, need to check for version
3
- require 'webpacker/version' if defined?(Webpacker)
2
+ require 'delegate'
3
+ require 'stringio'
4
4
 
5
5
  class WickedPdf
6
6
  module WickedPdfHelper
7
7
  module Assets
8
8
  ASSET_URL_REGEX = /url\(['"]?([^'"]+?)['"]?\)/
9
9
 
10
+ class MissingAsset < StandardError; end
11
+
12
+ class MissingLocalAsset < MissingAsset
13
+ attr_reader :path
14
+
15
+ def initialize(path)
16
+ @path = path
17
+ super("Could not find asset '#{path}'")
18
+ end
19
+ end
20
+
21
+ class MissingRemoteAsset < MissingAsset
22
+ attr_reader :url, :response
23
+
24
+ def initialize(url, response)
25
+ @url = url
26
+ @response = response
27
+ super("Could not fetch asset '#{url}': server responded with #{response.code} #{response.message}")
28
+ end
29
+ end
30
+
31
+ class PropshaftAsset < SimpleDelegator
32
+ def content_type
33
+ super.to_s
34
+ end
35
+
36
+ def to_s
37
+ content
38
+ end
39
+
40
+ def filename
41
+ path.to_s
42
+ end
43
+ end
44
+
45
+ class SprocketsEnvironment
46
+ def self.instance
47
+ @instance ||= Sprockets::Railtie.build_environment(Rails.application)
48
+ end
49
+
50
+ def self.find_asset(*args)
51
+ instance.find_asset(*args)
52
+ end
53
+ end
54
+
55
+ class LocalAsset
56
+ attr_reader :path
57
+
58
+ def initialize(path)
59
+ @path = path
60
+ end
61
+
62
+ def content_type
63
+ Mime::Type.lookup_by_extension(File.extname(path).delete('.'))
64
+ end
65
+
66
+ def to_s
67
+ IO.read(path)
68
+ end
69
+
70
+ def filename
71
+ path.to_s
72
+ end
73
+ end
74
+
10
75
  def wicked_pdf_asset_base64(path)
11
76
  asset = find_asset(path)
12
- raise "Could not find asset '#{path}'" if asset.nil?
77
+ raise MissingLocalAsset, path if asset.nil?
78
+
13
79
  base64 = Base64.encode64(asset.to_s).gsub(/\s+/, '')
14
80
  "data:#{asset.content_type};base64,#{Rack::Utils.escape(base64)}"
15
81
  end
16
82
 
83
+ # Using `image_tag` with URLs when generating PDFs (specifically large PDFs with lots of pages) can cause buffer/stack overflows.
84
+ #
85
+ def wicked_pdf_url_base64(url)
86
+ response = Net::HTTP.get_response(URI(url))
87
+
88
+ if response.is_a?(Net::HTTPSuccess)
89
+ base64 = Base64.encode64(response.body).gsub(/\s+/, '')
90
+ "data:#{response.content_type};base64,#{Rack::Utils.escape(base64)}"
91
+ else
92
+ Rails.logger.warn("[wicked_pdf] #{response.code} #{response.message}: #{url}")
93
+ nil
94
+ end
95
+ end
96
+
17
97
  def wicked_pdf_stylesheet_link_tag(*sources)
18
98
  stylesheet_contents = sources.collect do |source|
19
99
  source = WickedPdfHelper.add_extension(source, 'css')
@@ -31,6 +111,7 @@ class WickedPdf
31
111
 
32
112
  def wicked_pdf_stylesheet_pack_tag(*sources)
33
113
  return unless defined?(Webpacker)
114
+
34
115
  if running_in_development?
35
116
  stylesheet_pack_tag(*sources)
36
117
  else
@@ -79,6 +160,16 @@ class WickedPdf
79
160
  end
80
161
  end
81
162
 
163
+ def wicked_pdf_asset_pack_path(asset)
164
+ return unless defined?(Webpacker)
165
+
166
+ if running_in_development?
167
+ asset_pack_path(asset)
168
+ else
169
+ wicked_pdf_asset_path webpacker_source_url(asset)
170
+ end
171
+ end
172
+
82
173
  private
83
174
 
84
175
  # borrowed from actionpack/lib/action_view/helpers/asset_url_helper.rb
@@ -108,8 +199,32 @@ class WickedPdf
108
199
  def find_asset(path)
109
200
  if Rails.application.assets.respond_to?(:find_asset)
110
201
  Rails.application.assets.find_asset(path, :base_path => Rails.application.root.to_s)
202
+ elsif defined?(Propshaft::Assembly) && Rails.application.assets.is_a?(Propshaft::Assembly)
203
+ PropshaftAsset.new(Rails.application.assets.load_path.find(path))
204
+ elsif Rails.application.respond_to?(:assets_manifest)
205
+ relative_asset_path = get_asset_path_from_manifest(path)
206
+ return unless relative_asset_path
207
+
208
+ asset_path = File.join(Rails.application.assets_manifest.dir, relative_asset_path)
209
+ LocalAsset.new(asset_path) if File.file?(asset_path)
111
210
  else
112
- Sprockets::Railtie.build_environment(Rails.application).find_asset(path, :base_path => Rails.application.root.to_s)
211
+ SprocketsEnvironment.find_asset(path, :base_path => Rails.application.root.to_s)
212
+ end
213
+ end
214
+
215
+ def get_asset_path_from_manifest(path)
216
+ assets = Rails.application.assets_manifest.assets
217
+
218
+ if File.extname(path).empty?
219
+ assets.find do |asset, _v|
220
+ directory = File.dirname(asset)
221
+ asset_path = File.basename(asset, File.extname(asset))
222
+ asset_path = File.join(directory, asset_path) if directory != '.'
223
+
224
+ asset_path == path
225
+ end&.last
226
+ else
227
+ assets[path]
113
228
  end
114
229
  end
115
230
 
@@ -133,20 +248,35 @@ class WickedPdf
133
248
  end
134
249
 
135
250
  def read_asset(source)
136
- if precompiled_or_absolute_asset?(source)
137
- pathname = asset_pathname(source)
138
- if pathname =~ URI_REGEXP
139
- read_from_uri(pathname)
140
- elsif File.file?(pathname)
141
- IO.read(pathname)
142
- end
143
- else
144
- find_asset(source).to_s
251
+ asset = find_asset(source)
252
+ return asset.to_s.force_encoding('UTF-8') if asset
253
+
254
+ unless precompiled_or_absolute_asset?(source)
255
+ raise MissingLocalAsset, source if WickedPdf.config[:raise_on_missing_assets]
256
+
257
+ return
258
+ end
259
+
260
+ pathname = asset_pathname(source)
261
+ if pathname =~ URI_REGEXP
262
+ read_from_uri(pathname)
263
+ elsif File.file?(pathname)
264
+ IO.read(pathname)
265
+ elsif WickedPdf.config[:raise_on_missing_assets]
266
+ raise MissingLocalAsset, pathname if WickedPdf.config[:raise_on_missing_assets]
145
267
  end
146
268
  end
147
269
 
148
270
  def read_from_uri(uri)
149
- asset = Net::HTTP.get(URI(uri))
271
+ response = Net::HTTP.get_response(URI(uri))
272
+
273
+ unless response.is_a?(Net::HTTPSuccess)
274
+ raise MissingRemoteAsset.new(uri, response) if WickedPdf.config[:raise_on_missing_assets]
275
+
276
+ return
277
+ end
278
+
279
+ asset = response.body
150
280
  asset.force_encoding('UTF-8') if asset
151
281
  asset = gzip(asset) if WickedPdf.config[:expect_gzipped_remote_assets]
152
282
  asset
@@ -161,10 +291,15 @@ class WickedPdf
161
291
  end
162
292
 
163
293
  def webpacker_source_url(source)
164
- return unless defined?(Webpacker) && defined?(Webpacker::VERSION)
294
+ return unless webpacker_version
295
+
165
296
  # In Webpacker 3.2.0 asset_pack_url is introduced
166
- if Webpacker::VERSION >= '3.2.0'
167
- asset_pack_url(source)
297
+ if webpacker_version >= '3.2.0'
298
+ if (host = Rails.application.config.asset_host)
299
+ asset_pack_path(source, :host => host)
300
+ else
301
+ asset_pack_url(source)
302
+ end
168
303
  else
169
304
  source_path = asset_pack_path(source)
170
305
  # Remove last slash from root path
@@ -173,7 +308,8 @@ class WickedPdf
173
308
  end
174
309
 
175
310
  def running_in_development?
176
- return unless defined?(Webpacker)
311
+ return unless webpacker_version
312
+
177
313
  # :dev_server method was added in webpacker 3.0.0
178
314
  if Webpacker.respond_to?(:dev_server)
179
315
  Webpacker.dev_server.running?
@@ -181,6 +317,16 @@ class WickedPdf
181
317
  Rails.env.development? || Rails.env.test?
182
318
  end
183
319
  end
320
+
321
+ def webpacker_version
322
+ if defined?(Shakapacker)
323
+ require 'shakapacker/version'
324
+ Shakapacker::VERSION
325
+ elsif defined?(Webpacker)
326
+ require 'webpacker/version'
327
+ Webpacker::VERSION
328
+ end
329
+ end
184
330
  end
185
331
  end
186
332
  end
data/lib/wicked_pdf.rb CHANGED
@@ -5,33 +5,49 @@ require 'logger'
5
5
  require 'digest/md5'
6
6
  require 'rbconfig'
7
7
  require 'open3'
8
+ require 'ostruct'
8
9
 
9
10
  require 'active_support/core_ext/module/attribute_accessors'
10
11
  require 'active_support/core_ext/object/blank'
11
12
 
12
13
  require 'wicked_pdf/version'
13
14
  require 'wicked_pdf/railtie'
15
+ require 'wicked_pdf/option_parser'
14
16
  require 'wicked_pdf/tempfile'
17
+ require 'wicked_pdf/binary'
15
18
  require 'wicked_pdf/middleware'
16
19
  require 'wicked_pdf/progress'
17
20
 
18
21
  class WickedPdf
19
22
  DEFAULT_BINARY_VERSION = Gem::Version.new('0.9.9')
20
- BINARY_VERSION_WITHOUT_DASHES = Gem::Version.new('0.12.0')
21
- EXE_NAME = 'wkhtmltopdf'.freeze
22
23
  @@config = {}
23
- cattr_accessor :config
24
- attr_accessor :binary_version
24
+ cattr_accessor :config, :silence_deprecations
25
25
 
26
26
  include Progress
27
27
 
28
+ def self.config=(config)
29
+ ::Kernel.warn 'WickedPdf.config= is deprecated and will be removed in future versions. Use WickedPdf.configure instead.' unless @@silence_deprecations
30
+
31
+ @@config = config
32
+ end
33
+
34
+ def self.configure
35
+ config = OpenStruct.new(@@config)
36
+ yield config
37
+
38
+ @@config.merge! config.to_h
39
+ end
40
+
41
+ def self.clear_config
42
+ @@config = {}
43
+ end
44
+
28
45
  def initialize(wkhtmltopdf_binary_path = nil)
29
- @exe_path = wkhtmltopdf_binary_path || find_wkhtmltopdf_binary_path
30
- raise "Location of #{EXE_NAME} unknown" if @exe_path.empty?
31
- raise "Bad #{EXE_NAME}'s path: #{@exe_path}" unless File.exist?(@exe_path)
32
- raise "#{EXE_NAME} is not executable" unless File.executable?(@exe_path)
46
+ @binary = Binary.new(wkhtmltopdf_binary_path, DEFAULT_BINARY_VERSION)
47
+ end
33
48
 
34
- retrieve_binary_version
49
+ def binary_version
50
+ @binary.version
35
51
  end
36
52
 
37
53
  def pdf_from_html_file(filepath, options = {})
@@ -41,23 +57,24 @@ class WickedPdf
41
57
  def pdf_from_string(string, options = {})
42
58
  options = options.dup
43
59
  options.merge!(WickedPdf.config) { |_key, option, _config| option }
44
- string_file = WickedPdfTempfile.new('wicked_pdf.html', options[:temp_path])
45
- string_file.binmode
46
- string_file.write(string)
47
- string_file.close
48
-
49
- pdf = pdf_from_html_file(string_file.path, options)
50
- pdf
60
+ string_file = WickedPdf::Tempfile.new('wicked_pdf.html', options[:temp_path])
61
+ string_file.write_in_chunks(string)
62
+ pdf_from_html_file(string_file.path, options)
51
63
  ensure
52
- string_file.close! if string_file
64
+ if options[:delete_temporary_files] && string_file
65
+ string_file.close!
66
+ elsif string_file
67
+ string_file.close
68
+ end
53
69
  end
54
70
 
55
- def pdf_from_url(url, options = {})
71
+ def pdf_from_url(url, options = {}) # rubocop:disable Metrics/CyclomaticComplexity
56
72
  # merge in global config options
57
73
  options.merge!(WickedPdf.config) { |_key, option, _config| option }
58
- generated_pdf_file = WickedPdfTempfile.new('wicked_pdf_generated_file.pdf', options[:temp_path])
59
- command = [@exe_path]
60
- command += parse_options(options)
74
+ generated_pdf_file = WickedPdf::Tempfile.new('wicked_pdf_generated_file.pdf', options[:temp_path])
75
+ command = [@binary.path]
76
+ command.unshift(@binary.xvfb_run_path) if options[:use_xvfb]
77
+ command += option_parser.parse(options)
61
78
  command << url
62
79
  command << generated_pdf_file.path.to_s
63
80
 
@@ -66,23 +83,24 @@ class WickedPdf
66
83
  if track_progress?(options)
67
84
  invoke_with_progress(command, options)
68
85
  else
69
- err = Open3.popen3(*command) do |_stdin, _stdout, stderr|
70
- stderr.read
71
- end
86
+ _out, err, status = Open3.capture3(*command)
87
+ err = [status.to_s, err].join("\n") if !err.empty? || !status.success?
72
88
  end
73
89
  if options[:return_file]
74
90
  return_file = options.delete(:return_file)
75
91
  return generated_pdf_file
76
92
  end
77
- generated_pdf_file.rewind
78
- generated_pdf_file.binmode
79
- pdf = generated_pdf_file.read
93
+
94
+ pdf = generated_pdf_file.read_in_chunks
95
+
80
96
  raise "Error generating PDF\n Command Error: #{err}" if options[:raise_on_all_errors] && !err.empty?
81
97
  raise "PDF could not be generated!\n Command Error: #{err}" if pdf && pdf.rstrip.empty?
98
+
82
99
  pdf
83
100
  rescue StandardError => e
84
101
  raise "Failed to execute:\n#{command}\nError: #{e}"
85
102
  ensure
103
+ clean_temp_files
86
104
  generated_pdf_file.close! if generated_pdf_file && !return_file
87
105
  end
88
106
 
@@ -90,6 +108,7 @@ class WickedPdf
90
108
 
91
109
  def in_development_mode?
92
110
  return Rails.env == 'development' if defined?(Rails.env)
111
+
93
112
  RAILS_ENV == 'development' if defined?(RAILS_ENV)
94
113
  end
95
114
 
@@ -101,242 +120,13 @@ class WickedPdf
101
120
  Rails.logger.debug '[wicked_pdf]: ' + cmd
102
121
  end
103
122
 
104
- def retrieve_binary_version
105
- _stdin, stdout, _stderr = Open3.popen3(@exe_path + ' -V')
106
- @binary_version = parse_version(stdout.gets(nil))
107
- rescue StandardError
108
- DEFAULT_BINARY_VERSION
109
- end
110
-
111
- def parse_version(version_info)
112
- match_data = /wkhtmltopdf\s*(\d*\.\d*\.\d*\w*)/.match(version_info)
113
- if match_data && (match_data.length == 2)
114
- Gem::Version.new(match_data[1])
115
- else
116
- DEFAULT_BINARY_VERSION
117
- end
118
- end
119
-
120
- def parse_options(options)
121
- [
122
- parse_extra(options),
123
- parse_others(options),
124
- parse_global(options),
125
- parse_outline(options.delete(:outline)),
126
- parse_header_footer(:header => options.delete(:header),
127
- :footer => options.delete(:footer),
128
- :layout => options[:layout]),
129
- parse_cover(options.delete(:cover)),
130
- parse_toc(options.delete(:toc)),
131
- parse_basic_auth(options)
132
- ].flatten
133
- end
134
-
135
- def parse_extra(options)
136
- return [] if options[:extra].nil?
137
- return options[:extra].split if options[:extra].respond_to?(:split)
138
- options[:extra]
139
- end
140
-
141
- def parse_basic_auth(options)
142
- if options[:basic_auth]
143
- user, passwd = Base64.decode64(options[:basic_auth]).split(':')
144
- ['--username', user, '--password', passwd]
145
- else
146
- []
147
- end
148
- end
149
-
150
- def make_option(name, value, type = :string)
151
- if value.is_a?(Array)
152
- return value.collect { |v| make_option(name, v, type) }
153
- end
154
- if type == :name_value
155
- parts = value.to_s.split(' ')
156
- ["--#{name.tr('_', '-')}", *parts]
157
- elsif type == :boolean
158
- if value
159
- ["--#{name.tr('_', '-')}"]
160
- else
161
- []
162
- end
163
- else
164
- ["--#{name.tr('_', '-')}", value.to_s]
165
- end
166
- end
167
-
168
- def valid_option(name)
169
- if binary_version < BINARY_VERSION_WITHOUT_DASHES
170
- "--#{name}"
171
- else
172
- name
173
- end
174
- end
175
-
176
- def make_options(options, names, prefix = '', type = :string)
177
- return [] if options.nil?
178
- names.collect do |o|
179
- if options[o].blank?
180
- []
181
- else
182
- make_option("#{prefix.blank? ? '' : prefix + '-'}#{o}",
183
- options[o],
184
- type)
185
- end
186
- end
187
- end
188
-
189
- def parse_header_footer(options)
190
- r = []
191
- unless options.blank?
192
- [:header, :footer].collect do |hf|
193
- next if options[hf].blank?
194
- opt_hf = options[hf]
195
- r += make_options(opt_hf, [:center, :font_name, :left, :right], hf.to_s)
196
- r += make_options(opt_hf, [:font_size, :spacing], hf.to_s, :numeric)
197
- r += make_options(opt_hf, [:line], hf.to_s, :boolean)
198
- if options[hf] && options[hf][:content]
199
- @hf_tempfiles = [] unless defined?(@hf_tempfiles)
200
- @hf_tempfiles.push(tf = WickedPdfTempfile.new("wicked_#{hf}_pdf.html"))
201
- tf.write options[hf][:content]
202
- tf.flush
203
- options[hf][:html] = {}
204
- options[hf][:html][:url] = "file:///#{tf.path}"
205
- end
206
- unless opt_hf[:html].blank?
207
- r += make_option("#{hf}-html", opt_hf[:html][:url]) unless opt_hf[:html][:url].blank?
208
- end
209
- end
210
- end
211
- r
212
- end
213
-
214
- def parse_cover(argument)
215
- arg = argument.to_s
216
- return [] if arg.blank?
217
- # Filesystem path or URL - hand off to wkhtmltopdf
218
- if argument.is_a?(Pathname) || (arg[0, 4] == 'http')
219
- [valid_option('cover'), arg]
220
- else # HTML content
221
- @hf_tempfiles ||= []
222
- @hf_tempfiles << tf = WickedPdfTempfile.new('wicked_cover_pdf.html')
223
- tf.write arg
224
- tf.flush
225
- [valid_option('cover'), tf.path]
226
- end
227
- end
228
-
229
- def parse_toc(options)
230
- return [] if options.nil?
231
- r = [valid_option('toc')]
232
- unless options.blank?
233
- r += make_options(options, [:font_name, :header_text], 'toc')
234
- r += make_options(options, [:xsl_style_sheet])
235
- r += make_options(options, [:depth,
236
- :header_fs,
237
- :text_size_shrink,
238
- :l1_font_size,
239
- :l2_font_size,
240
- :l3_font_size,
241
- :l4_font_size,
242
- :l5_font_size,
243
- :l6_font_size,
244
- :l7_font_size,
245
- :level_indentation,
246
- :l1_indentation,
247
- :l2_indentation,
248
- :l3_indentation,
249
- :l4_indentation,
250
- :l5_indentation,
251
- :l6_indentation,
252
- :l7_indentation], 'toc', :numeric)
253
- r += make_options(options, [:no_dots,
254
- :disable_links,
255
- :disable_back_links], 'toc', :boolean)
256
- r += make_options(options, [:disable_dotted_lines,
257
- :disable_toc_links], nil, :boolean)
258
- end
259
- r
123
+ def option_parser
124
+ @option_parser ||= OptionParser.new(binary_version)
260
125
  end
261
126
 
262
- def parse_outline(options)
263
- r = []
264
- unless options.blank?
265
- r = make_options(options, [:outline], '', :boolean)
266
- r += make_options(options, [:outline_depth], '', :numeric)
267
- end
268
- r
269
- end
270
-
271
- def parse_margins(options)
272
- make_options(options, [:top, :bottom, :left, :right], 'margin', :numeric)
273
- end
127
+ def clean_temp_files
128
+ return unless option_parser.hf_tempfiles.present?
274
129
 
275
- def parse_global(options)
276
- r = []
277
- unless options.blank?
278
- r += make_options(options, [:orientation,
279
- :dpi,
280
- :page_size,
281
- :page_width,
282
- :title,
283
- :log_level])
284
- r += make_options(options, [:lowquality,
285
- :grayscale,
286
- :no_pdf_compression,
287
- :quiet], '', :boolean)
288
- r += make_options(options, [:image_dpi,
289
- :image_quality,
290
- :page_height], '', :numeric)
291
- r += parse_margins(options.delete(:margin))
292
- end
293
- r
294
- end
295
-
296
- def parse_others(options)
297
- r = []
298
- unless options.blank?
299
- r += make_options(options, [:proxy,
300
- :username,
301
- :password,
302
- :encoding,
303
- :user_style_sheet,
304
- :viewport_size,
305
- :window_status])
306
- r += make_options(options, [:cookie,
307
- :post], '', :name_value)
308
- r += make_options(options, [:redirect_delay,
309
- :zoom,
310
- :page_offset,
311
- :javascript_delay], '', :numeric)
312
- r += make_options(options, [:book,
313
- :default_header,
314
- :disable_javascript,
315
- :enable_plugins,
316
- :disable_internal_links,
317
- :disable_external_links,
318
- :print_media_type,
319
- :disable_smart_shrinking,
320
- :use_xserver,
321
- :no_background,
322
- :images,
323
- :no_images,
324
- :no_stop_slow_scripts], '', :boolean)
325
- end
326
- r
327
- end
328
-
329
- def find_wkhtmltopdf_binary_path
330
- possible_locations = (ENV['PATH'].split(':') + %w[/usr/bin /usr/local/bin]).uniq
331
- possible_locations += %w[~/bin] if ENV.key?('HOME')
332
- exe_path ||= WickedPdf.config[:exe_path] unless WickedPdf.config.empty?
333
- exe_path ||= begin
334
- detected_path = (defined?(Bundler) ? Bundler.which('wkhtmltopdf') : `which wkhtmltopdf`).chomp
335
- detected_path.present? && detected_path
336
- rescue StandardError
337
- nil
338
- end
339
- exe_path ||= possible_locations.map { |l| File.expand_path("#{l}/#{EXE_NAME}") }.find { |location| File.exist?(location) }
340
- exe_path || ''
130
+ option_parser.hf_tempfiles.each { |file| File.delete(file) }
341
131
  end
342
132
  end
@@ -0,0 +1 @@
1
+ // Nested js
@@ -76,7 +76,7 @@ class PdfHelperTest < ActionController::TestCase
76
76
 
77
77
  # test that calling render does not trigger infinite loop
78
78
  ac = ActionController::Base.new
79
- assert_equal [:base, :patched], ac.render(:cats)
79
+ assert_equal %i[base patched], ac.render(:cats)
80
80
  rescue SystemStackError
81
81
  assert_equal true, false # force spec failure
82
82
  ensure