diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml new file mode 100644 index 00000000..9d39e165 --- /dev/null +++ b/.github/workflows/release-drafter.yml @@ -0,0 +1,19 @@ +name: Release Drafter + +on: + push: + # branches to consider in the event; optional, defaults to all + branches: + - master + +jobs: + update_release_draft: + runs-on: ubuntu-latest + steps: + # Drafts your next Release notes as Pull Requests are merged into "master" + - uses: release-drafter/release-drafter@v5 + # with: + # (Optional) specify config name to use, relative to .github/. Default: release-drafter.yml + # config-name: my-config.yml + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 00000000..7edec9eb --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,19 @@ +name: Mark stale issues and pull requests + +on: + schedule: + - cron: "0 0 * * *" + +jobs: + stale: + + runs-on: ubuntu-latest + + steps: + - uses: actions/stale@v1 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + stale-issue-message: 'This issue has been marked as stale and will be automatically closed.' + stale-pr-message: 'This pull request has been marked as stale and will be automatically closed.' + stale-issue-label: 'no-issue-activity' + stale-pr-label: 'no-pr-activity' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..b7b46a72 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,71 @@ +name: Test workflow + +on: [push, pull_request] + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + ruby: + - '2.5' + - '2.6' + - '2.7' + - '3.0' + - '3.1' + rails: + - '~> 4.1' + - '~> 5.2' + - '~> 6.0.0' + - '~> 6.1' + - '~> 7.0.0' + exclude: + - ruby: '2.5' + rails: '~> 7.0.0' + - ruby: '2.6' + rails: '~> 7.0.0' + - ruby: '2.7' + rails: '~> 4.1' + - ruby: '3.0' + rails: '~> 4.1' + - ruby: '3.0' + rails: '~> 5.2' + - ruby: '3.1' + rails: '~> 4.1' + - ruby: '3.1' + rails: '~> 5.2' + - ruby: '3.1' + rails: '~> 6.0.0' + runs-on: ${{ matrix.os }} + env: + RAILS_VERSION: ${{ matrix.rails }} + steps: + - uses: actions/checkout@v2 + - uses: ruby/setup-ruby@v1 + # rubygems-update's latest is no longer compatible with ruby 2.5, so conditionally run ruby-setup setting the + # rubygem version the most recent valid version for 2.5: + # https://rubygems.org/gems/rubygems-update/versions/3.3.26 + if: ${{ matrix.ruby == '2.5' }} + with: + ruby-version: ${{ matrix.ruby }} + rubygems: 3.3.26 + bundler: latest + bundler-cache: true + - uses: ruby/setup-ruby@v1 + # otherwise, we can use rubygems latest + if: ${{ matrix.ruby != '2.5' }} + with: + ruby-version: ${{ matrix.ruby }} + rubygems: latest + bundler: latest + bundler-cache: true + + - name: Setup wkhtmltopdf + run: | + sudo apt-get install -y xfonts-base xfonts-75dpi + wget https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6-1/wkhtmltox_0.12.6-1.bionic_amd64.deb + sudo dpkg -i wkhtmltox_0.12.6-1.bionic_amd64.deb + + - name: Run tests + run: bundle exec rake diff --git a/.ruby-version b/.ruby-version index f33eb12f..d48d3702 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -ruby-2.0.0 +2.6.9 diff --git a/.travis.yml b/.travis.yml index 16529a0f..a859ba9d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,16 +1,46 @@ +language: ruby + rvm: -- 1.9.2 -- 1.9.3 -- 2.0 -- 2.1 +- 2.5 +- 2.6 +- 2.7 +- 3.0 +- 3.1 + +env: + matrix: + - RAILS_VERSION="~> 4.1" + - RAILS_VERSION="~> 5.2" + - RAILS_VERSION="~> 6.0.0" + - RAILS_VERSION="~> 6.1" + - RAILS_VERSION="~> 7.0.0" + +jobs: + exclude: + - rvm: 2.5 + env: RAILS_VERSION="~> 7.0.0" + - rvm: 2.6 + env: RAILS_VERSION="~> 7.0.0" + - rvm: 2.7 + env: RAILS_VERSION="~> 4.1" + - rvm: 3.0 + env: RAILS_VERSION="~> 4.1" + - rvm: 3.0 + env: RAILS_VERSION="~> 5.2" + - rvm: 3.1 + env: RAILS_VERSION="~> 4.1" + - rvm: 3.1 + env: RAILS_VERSION="~> 5.2" + - rvm: 3.1 + env: RAILS_VERSION="~> 6.0.0" + +cache: bundler before_install: - gem update --system - gem update bundler before_script: - - "export DISPLAY=:99.0" - - "sh -e /etc/init.d/xvfb start" - "sudo apt-get -qq -y install fontconfig libxrender1" - - "wget http://download.gna.org/wkhtmltopdf/0.12/0.12.1/wkhtmltox-0.12.1_linux-precise-amd64.deb" - - "sudo dpkg -i wkhtmltox-0.12.1_linux-precise-amd64.deb" + - "wget https://github.com/wkhtmltopdf/wkhtmltopdf/releases/download/0.12.5/wkhtmltox_0.12.5-1.xenial_amd64.deb" + - "sudo apt-get install ./wkhtmltox_0.12.5-1.xenial_amd64.deb" diff --git a/CHANGELOG.md b/CHANGELOG.md index 919b961c..a46c04b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,72 @@ +2023-02-27 +================= + * Bump to 0.8.7.3 + * Allow passing a `Pathname` object to the `path` argument by @yujideveloper in https://github.com/pdfkit/pdfkit/pull/522 + * Update repeatable options by @mguidetti in https://github.com/pdfkit/pdfkit/pull/524 + +2022-10-18 +================= + * Bump to 0.8.7.2 + * Call IO.popen with an Array of command arguments (#519) + +2022-10-17 +================= + * Bump to 0.8.7.1 + * Support non-lower-case Content-Type header provided by app (#516) + +2022-10-02 +================= + * Bump to 0.8.7 + * Lowercase the header names for rack 3 changes (#511) + * Partially escaped URLs should be escaped (#509) + +2022-04-11 +================= + * Bump to 0.8.6 + * Update ruby and rails versions + +2021-01-23 +================= + * Bump to 0.8.5 + * Make `PDFKit::VERSION` public (#484) + * Fix to render stylesheets as html safe string on Rails 6 (#483) + * Adds support for Rails 6 + +2020-08-16 +================= + * Bump to 0.8.4.3.2 + * Reduce scope of middleware exception handling (#476) + +2020-07-05 +================= + * Bump to 0.8.4.3.1 + * Don't override request level Content-Disposition header if it exists (#466) + * Update rake (#471) + * Add missing require statements for tempfile (#467) + * Only grab last line of bundle exec which output (#464) + * Return 500 status when an exception is caught in middleware (#469) + * Update Travis CI URL for wkhtmltopf (#473) + +2020-04-01 +================= + * Bump to 0.8.4.2 + * Improve path detection feedback (#460) + * Fix typos (#444) + * Update readme (#439) + +2019-02-22 +================= + * Bump to 0.8.4.1 + * Make PDFkit threadsafe (#377) + * Update activesupport (#434) + +2019-02-21 +================= + * Bump to 0.8.4 + * Removed support for Ruby < 2.2 + * Xvfb support (#277) + * Remove 'config.protocol' from the README (#389) + 2015-08-26 ================= * Bump to 0.8.2 diff --git a/Gemfile b/Gemfile index 12a4e43d..5b623bd2 100644 --- a/Gemfile +++ b/Gemfile @@ -1,7 +1,7 @@ source 'https://rubygems.org' group :test do - gem 'activesupport', '~> 3.0' + gem 'activesupport', ENV['RAILS_VERSION'] || '~> 4.1' gem 'simplecov', require: false end diff --git a/README.md b/README.md index 0ebaf683..bfa3799a 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,11 @@ Create PDFs using plain old HTML+CSS. Uses [wkhtmltopdf](http://github.com/antialize/wkhtmltopdf) on the back-end which renders HTML using Webkit. +## Supported versions + +- Ruby 2.5, 2.6, 2.7, 3.0, 3.1 +- Rails 4.2, 5.2, 6.0, 6.1, 7.0 + ## Install ### PDFKit @@ -14,7 +19,7 @@ gem install pdfkit -2. Try using the `wkhtmltopdf-binary` gem (mac + linux i386) +2. Try using the `wkhtmltopdf-binary-edge` gem (mac + linux i386) ``` gem install wkhtmltopdf-binary ``` @@ -34,7 +39,7 @@ pdf = kit.to_pdf file = kit.to_file('/path/to/save/pdf') # PDFKit.new can optionally accept a URL or a File. -# Stylesheets can not be added when source is provided as a URL of File. +# Stylesheets can not be added when source is provided as a URL or File. kit = PDFKit.new('http://google.com') kit = PDFKit.new(File.new('/path/to/html')) @@ -58,7 +63,7 @@ PDFKit.new(html, protocol: 'https').to_file ``` ### Using cookies in scraping -If you want to pass a cookie to cookie to pdfkit to scrape a website, you can +If you want to pass a cookie to pdfkit to scrape a website, you can pass it in a hash: ```ruby kit = PDFKit.new(url, cookie: {cookie_name: :cookie_value}) @@ -76,7 +81,6 @@ PDFKit.configure do |config| } # Use only if your external hostname is unavailable on the server. config.root_url = "http://localhost" - config.protocol = 'http' config.verbose = false end ``` @@ -117,6 +121,13 @@ config.middleware.use PDFKit::Middleware, {}, :except => [%r[^/prawn], %r[^/secr # conditions can be strings (either one or an array) config.middleware.use PDFKit::Middleware, {}, :except => ['/secret'] ``` +**With conditions to force download** +```ruby +# force download with attachment disposition +config.middleware.use PDFKit::Middleware, {}, :disposition => 'attachment' +# conditions can force a filename +config.middleware.use PDFKit::Middleware, {}, :disposition => 'attachment; filename=report.pdf' +``` **Saving the generated .pdf to disk** Setting the `PDFKit-save-pdf` header will cause PDFKit to write the generated .pdf to the file indicated by the value of the header. @@ -126,7 +137,7 @@ For example: headers['PDFKit-save-pdf'] = 'path/to/saved.pdf' ``` -Will cause the .pdf to be saved to `path/to/saved.pdf` in addition to being sent back to the client. If the path is not writable/non-existant the write will fail silently. The `PDFKit-save-pdf` header is never sent back to the client. +Will cause the .pdf to be saved to `path/to/saved.pdf` in addition to being sent back to the client. If the path is not writable/non-existent the write will fail silently. The `PDFKit-save-pdf` header is never sent back to the client. ## Troubleshooting @@ -141,13 +152,13 @@ Will cause the .pdf to be saved to `path/to/saved.pdf` in addition to being sent around this issue you may want to run a server with multiple workers like Passenger or try to embed your resources within your HTML to avoid extra HTTP requests. - + Example solution (rails / bundler), add unicorn to the development group in your Gemfile `gem 'unicorn'` then run `bundle`. Next, add a file `config/unicorn.conf` with - + worker_processes 3 - + Then to run the app `unicorn_rails -c config/unicorn.conf` (from rails_root) * **Resources aren't included in the PDF:** Images, CSS, or JavaScript @@ -161,7 +172,7 @@ Will cause the .pdf to be saved to `path/to/saved.pdf` in addition to being sent asset host. * **Mangled output in the browser:** Be sure that your HTTP response - headers specify "Content-Type: application/pdf" + headers specify "content-type: application/pdf" ## Note on Patches/Pull Requests diff --git a/lib/pdfkit.rb b/lib/pdfkit.rb index 908e266f..1ea91906 100644 --- a/lib/pdfkit.rb +++ b/lib/pdfkit.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'pdfkit/source' require 'pdfkit/pdfkit' require 'pdfkit/middleware' @@ -5,3 +7,4 @@ require 'pdfkit/os' require 'pdfkit/configuration' require 'pdfkit/wkhtmltopdf' +require 'pdfkit/version' diff --git a/lib/pdfkit/configuration.rb b/lib/pdfkit/configuration.rb index 28172147..6eaa7e0e 100644 --- a/lib/pdfkit/configuration.rb +++ b/lib/pdfkit/configuration.rb @@ -1,10 +1,14 @@ +# frozen_string_literal: true + class PDFKit class Configuration - attr_accessor :meta_tag_prefix, :default_options, :root_url - attr_writer :verbose + attr_accessor :meta_tag_prefix, :root_url + attr_writer :use_xvfb, :verbose + attr_reader :default_options def initialize @verbose = false + @use_xvfb = false @meta_tag_prefix = 'pdfkit-' @default_options = { :disable_smart_shrinking => false, @@ -23,18 +27,31 @@ def wkhtmltopdf end def default_wkhtmltopdf - @default_command_path ||= (defined?(Bundler::GemfileError) && File.exists?('Gemfile') ? `bundle exec which wkhtmltopdf` : `which wkhtmltopdf`).chomp + return @default_command_path if @default_command_path + if defined?(Bundler::GemfileError) && File.exist?('Gemfile') + @default_command_path = `bundle exec which wkhtmltopdf`.chomp.lines.last + end + @default_command_path = `which wkhtmltopdf`.chomp if @default_command_path.nil? || @default_command_path.empty? + @default_command_path end def wkhtmltopdf=(path) if File.exist?(path) @wkhtmltopdf = path else - warn "No executable found at #{path}. Will fall back to #{default_wkhtmltopdf}" unless File.exist?(path) + warn "No executable found at #{path}. Will fall back to #{default_wkhtmltopdf}" @wkhtmltopdf = default_wkhtmltopdf end end + def executable + using_xvfb? ? ['xvfb-run', wkhtmltopdf] : wkhtmltopdf + end + + def using_xvfb? + @use_xvfb + end + def quiet? !@verbose end @@ -42,6 +59,10 @@ def quiet? def verbose? @verbose end + + def default_options=(options) + @default_options.merge!(options) + end end class << self @@ -54,6 +75,7 @@ class << self # @example # PDFKit.configure do |config| # config.wkhtmltopdf = '/usr/bin/wkhtmltopdf' + # config.use_xvfb = true # config.verbose = true # end diff --git a/lib/pdfkit/html_preprocessor.rb b/lib/pdfkit/html_preprocessor.rb index c402b1ab..611613bc 100644 --- a/lib/pdfkit/html_preprocessor.rb +++ b/lib/pdfkit/html_preprocessor.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class PDFKit module HTMLPreprocessor diff --git a/lib/pdfkit/middleware.rb b/lib/pdfkit/middleware.rb index 0d794382..c397e10a 100644 --- a/lib/pdfkit/middleware.rb +++ b/lib/pdfkit/middleware.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class PDFKit class Middleware def initialize(app, options = {}, conditions = {}) @@ -9,35 +11,51 @@ def initialize(app, options = {}, conditions = {}) end def call(env) + dup._call(env) + end + + def _call(env) @request = Rack::Request.new(env) @render_pdf = false set_request_to_render_as_pdf(env) if render_as_pdf? status, headers, response = @app.call(env) - if rendering_pdf? && headers['Content-Type'] =~ /text\/html|application\/xhtml\+xml/ - body = response.respond_to?(:body) ? response.body : response.join - body = body.join if body.is_a?(Array) - - root_url = root_url(env) - protocol = protocol(env) - options = @options.merge(root_url: root_url, protocol: protocol) - body = PDFKit.new(body, options).to_pdf - response = [body] - - if headers['PDFKit-save-pdf'] - File.open(headers['PDFKit-save-pdf'], 'wb') { |file| file.write(body) } rescue nil - headers.delete('PDFKit-save-pdf') - end - - unless @caching - # Do not cache PDFs - headers.delete('ETag') - headers.delete('Cache-Control') + begin + content_type_header = headers.has_key?('Content-Type') ? 'Content-Type' : 'content-type' + if rendering_pdf? && headers[content_type_header] =~ /text\/html|application\/xhtml\+xml/ + body = response.respond_to?(:body) ? response.body : response.join + body = body.join if body.is_a?(Array) + + root_url = root_url(env) + protocol = protocol(env) + options = @options.merge(root_url: root_url, protocol: protocol) + + if headers['PDFKit-javascript-delay'] + options.merge!(javascript_delay: headers.delete('PDFKit-javascript-delay').to_i) + end + + body = PDFKit.new(body, options).to_pdf + response = [body] + + if headers['PDFKit-save-pdf'] + File.open(headers['PDFKit-save-pdf'], 'wb') { |file| file.write(body) } rescue nil + headers.delete('PDFKit-save-pdf') + end + + unless @caching + # Do not cache PDFs + headers.delete('etag') + headers.delete('cache-control') + end + + headers['content-length'] = (body.respond_to?(:bytesize) ? body.bytesize : body.size).to_s + headers[content_type_header] = 'application/pdf' + headers['content-disposition'] ||= @conditions[:disposition] || 'inline' end - - headers['Content-Length'] = (body.respond_to?(:bytesize) ? body.bytesize : body.size).to_s - headers['Content-Type'] = 'application/pdf' + rescue StandardError => e + status = 500 + response = [e.message] end [status, headers, response] diff --git a/lib/pdfkit/os.rb b/lib/pdfkit/os.rb index 12721c6c..e05b1dd1 100644 --- a/lib/pdfkit/os.rb +++ b/lib/pdfkit/os.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rbconfig' class PDFKit diff --git a/lib/pdfkit/pdfkit.rb b/lib/pdfkit/pdfkit.rb index 3ebf027f..9ef7ad4b 100644 --- a/lib/pdfkit/pdfkit.rb +++ b/lib/pdfkit/pdfkit.rb @@ -1,20 +1,31 @@ +# frozen_string_literal: true + require 'shellwords' +require 'tempfile' class PDFKit - class NoExecutableError < StandardError + class Error < StandardError; end + + class NoExecutableError < Error def initialize - msg = "No wkhtmltopdf executable found at #{PDFKit.configuration.wkhtmltopdf}\n" - msg << ">> Please install wkhtmltopdf - https://github.com/pdfkit/PDFKit/wiki/Installing-WKHTMLTOPDF" + msg = "No wkhtmltopdf executable found at #{PDFKit.configuration.wkhtmltopdf}\n" \ + ">> Please install wkhtmltopdf - https://github.com/pdfkit/PDFKit/wiki/Installing-WKHTMLTOPDF" super(msg) end end - class ImproperSourceError < StandardError + class ImproperSourceError < Error def initialize(msg) super("Improper Source: #{msg}") end end + class ImproperWkhtmltopdfExitStatus < Error + def initialize(invoke) + super("Command failed (exitstatus=#{$?.exitstatus}): #{invoke}") + end + end + attr_accessor :source, :stylesheets attr_reader :renderer @@ -31,20 +42,15 @@ def initialize(url_file_or_html, options = {}) @renderer = WkHTMLtoPDF.new options @renderer.normalize_options - raise NoExecutableError.new unless File.exists?(PDFKit.configuration.wkhtmltopdf) + raise NoExecutableError unless File.exist?(PDFKit.configuration.wkhtmltopdf) end def command(path = nil) - args = @renderer.options_for_command - shell_escaped_command = [executable, OS::shell_escape_for_os(args)].join ' ' - - # In order to allow for URL parameters (e.g. https://www.google.com/search?q=pdfkit) we do - # not escape the source. The user is responsible for ensuring that no vulnerabilities exist - # in the source. Please see https://github.com/pdfkit/pdfkit/issues/164. - input_for_command = @source.to_input_for_command - output_for_command = path ? Shellwords.shellescape(path) : '-' - - "#{shell_escaped_command} #{input_for_command} #{output_for_command}" + args = [*executable] + args.concat(@renderer.options_for_command) + args << @source.to_input_for_command + args << (path ? path.to_s : '-') + args end def options @@ -53,7 +59,7 @@ def options end def executable - PDFKit.configuration.wkhtmltopdf + PDFKit.configuration.executable end def to_pdf(path=nil) @@ -70,7 +76,7 @@ def to_pdf(path=nil) # $? is thread safe per # http://stackoverflow.com/questions/2164887/thread-safe-external-process-in-ruby-plus-checking-exitstatus - raise "command failed (exitstatus=#{$?.exitstatus}): #{invoke}" if empty_result?(path, result) or !successful?($?) + raise ImproperWkhtmltopdfExitStatus, invoke if empty_result?(path, result) || !successful?($?) return result end @@ -83,7 +89,7 @@ def to_file(path) def find_options_in_meta(content) # Read file if content is a File - content = content.read if content.is_a?(File) + content = content.read if content.is_a?(File) || content.is_a?(Tempfile) found = {} content.scan(/]*>/) do |meta| @@ -105,7 +111,9 @@ def find_options_in_meta(content) end def style_tag_for(stylesheet) - "" + style = "" + style = style.html_safe if style.respond_to?(:html_safe) + style end def preprocess_html @@ -116,11 +124,13 @@ def preprocess_html end def append_stylesheets - raise ImproperSourceError.new('Stylesheets may only be added to an HTML source') if stylesheets.any? && !@source.html? + raise ImproperSourceError, 'Stylesheets may only be added to an HTML source' if stylesheets.any? && !@source.html? stylesheets.each do |stylesheet| if @source.to_s.match(/<\/head>/) - @source = Source.new(@source.to_s.gsub(/(<\/head>)/) {|s| style_tag_for(stylesheet) + s }) + @source = Source.new(@source.to_s.gsub(/(<\/head>)/) {|s| + style_tag_for(stylesheet) + (s.respond_to?(:html_safe) ? s.html_safe : s) + }) else @source.to_s.insert(0, style_tag_for(stylesheet)) end diff --git a/lib/pdfkit/source.rb b/lib/pdfkit/source.rb index de1d2314..5238acca 100644 --- a/lib/pdfkit/source.rb +++ b/lib/pdfkit/source.rb @@ -1,3 +1,6 @@ +# frozen_string_literal: true + +require 'tempfile' require 'uri' class PDFKit @@ -6,6 +9,8 @@ class Source def initialize(url_file_or_html) @source = url_file_or_html + # @source is assumed to be modifiable, so make sure it is. + @source = @source.dup if @source.is_a?(String) && @source.frozen? end def url? @@ -13,7 +18,7 @@ def url? end def file? - @is_file ||= @source.kind_of?(File) + @is_file ||= @source.kind_of?(File) || @source.kind_of?(Tempfile) end def html? @@ -24,7 +29,7 @@ def to_input_for_command if file? @source.path elsif url? - %{"#{shell_safe_url}"} + escaped_url else SOURCE_FROM_STDIN end @@ -36,12 +41,12 @@ def to_s private - def shell_safe_url - url_needs_escaping? ? URI::escape(@source) : @source + def escaped_url + url_needs_escaping? ? URI::DEFAULT_PARSER.escape(@source) : @source end def url_needs_escaping? - URI::decode(@source) == @source + URI::DEFAULT_PARSER.escape(URI::DEFAULT_PARSER.unescape(@source)) != @source end end end diff --git a/lib/pdfkit/version.rb b/lib/pdfkit/version.rb index 9d8bdd39..f82a00e2 100644 --- a/lib/pdfkit/version.rb +++ b/lib/pdfkit/version.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class PDFKit - VERSION = "0.8.2" + VERSION = '0.8.7.3' end diff --git a/lib/pdfkit/wkhtmltopdf.rb b/lib/pdfkit/wkhtmltopdf.rb index 86220ba2..e72f9238 100644 --- a/lib/pdfkit/wkhtmltopdf.rb +++ b/lib/pdfkit/wkhtmltopdf.rb @@ -1,10 +1,12 @@ +# frozen_string_literal: true + class PDFKit class WkHTMLtoPDF attr_reader :options - # Pulled from: - # https://github.com/wkhtmltopdf/wkhtmltopdf/blob/ebf9b6cfc4c58a31349fb94c568b254fac37b3d3/README_WKHTMLTOIMAGE#L27 - REPEATABLE_OPTIONS = %w[--allow --cookie --custom-header --post --post-file --run-script] - SPECIAL_OPTIONS = %w[cover toc] + # Pulled from: + # https://github.com/wkhtmltopdf/wkhtmltopdf/blob/6a57c1449797d6cb915921fb747f3ac36199241f/docs/usage/wkhtmltopdf.txt#L104 + REPEATABLE_OPTIONS = %w[--allow --bypass-proxy-for --cookie --custom-header --post --post-file --run-script --replace].freeze + SPECIAL_OPTIONS = %w[cover toc].freeze def initialize(options) @options = options @@ -62,7 +64,7 @@ def normalize_value(value) when Array value.flatten.collect{|x| x.to_s} else - (OS::host_is_windows? && value.to_s.index(' ')) ? "'#{ value.to_s }'" : value.to_s + value.to_s end end diff --git a/pdfkit.gemspec b/pdfkit.gemspec index 9ca668d2..d68270d3 100644 --- a/pdfkit.gemspec +++ b/pdfkit.gemspec @@ -13,21 +13,20 @@ Gem::Specification.new do |s| s.description = "Uses wkhtmltopdf to create PDFs using HTML" s.license = "MIT" - s.rubyforge_project = "pdfkit" - s.files = `git ls-files`.split("\n") s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } s.require_paths = ["lib"] + s.required_ruby_version = ">= 2.5" + s.requirements << "wkhtmltopdf" # Development Dependencies - s.add_development_dependency(%q, [">= 3.0.8"]) + s.add_development_dependency(%q, [">= 4.1.11"]) s.add_development_dependency(%q, [">= 0.9.10"]) s.add_development_dependency(%q, [">= 0.5.6"]) - s.add_development_dependency(%q, ["~>0.6.11"]) # Ruby 1.9.2 compatibility - s.add_development_dependency(%q, ["~>0.9.2"]) - s.add_development_dependency(%q, ["~> 4.0.1"]) + s.add_development_dependency(%q, [">= 12.3.3"]) + s.add_development_dependency(%q, [">= 4.0.1"]) s.add_development_dependency(%q, ["~> 3.0"]) end diff --git a/spec/configuration_spec.rb b/spec/configuration_spec.rb index 355085b6..a3c2b2b5 100644 --- a/spec/configuration_spec.rb +++ b/spec/configuration_spec.rb @@ -1,14 +1,75 @@ +# frozen_string_literal: true + require 'spec_helper' describe PDFKit::Configuration do subject { PDFKit::Configuration.new } describe "#wkhtmltopdf" do + context "when explicitly configured" do + it "uses configured value and don't detect" do + expect(subject).not_to receive(:default_wkhtmltopdf) + subject.wkhtmltopdf = "./Gemfile" # Need a file which exists + expect(subject.wkhtmltopdf).to eq("./Gemfile") + end + + it "falls back to detected binary if configured path doesn't exists" do + expect(subject).to receive(:default_wkhtmltopdf).twice.and_return("/bin/fallback") + expect(subject).to receive(:warn).with(/No executable found/) + subject.wkhtmltopdf = "./missing-file" # Need a file which doesn't exist + expect(subject.wkhtmltopdf).to eq("/bin/fallback") + end + end + context "when not explicitly configured" do - it "detects the existance of bundler" do - # Test assumes bundler is installed in your test environment - expect(subject).to receive(:`).with('bundle exec which wkhtmltopdf').and_return('c:\windows\path.exe') - subject.wkhtmltopdf + context "when running inside bundler" do + # Simulate the presence of bundler even if it's not here + before { stub_const("Bundler::GemfileError", Class) } + + it "detects the existance of bundler" do + expect(subject).to receive(:`).with('bundle exec which wkhtmltopdf').and_return("c:\\windows\\path.exe\n") + expect(subject.wkhtmltopdf).to eq('c:\windows\path.exe') + end + + it "falls back if bundler path fails" do + # This happens when there is a wrong (buggy) version of bundler for example + expect(subject).to receive(:`).with('bundle exec which wkhtmltopdf').and_return("") + expect(subject).to receive(:`).with('which wkhtmltopdf').and_return("c:\\windows\\path.exe\n") + expect(subject.wkhtmltopdf).to eq('c:\windows\path.exe') + end + + it "returns last line of 'bundle exec which' output" do + # Happens when the user does not have a HOME directory on their system and runs bundler < 2 + expect(subject).to receive(:`).with('bundle exec which wkhtmltopdf').and_return(<<~EOT + `/home/myuser` is not a directory. + Bundler will use `/tmp/bundler/home/myuser' as your home directory temporarily. + /usr/bin/wkhtmltopdf + EOT + ) + expect(subject.wkhtmltopdf).to eq('/usr/bin/wkhtmltopdf') + end end + + context "when running without bundler" do + # Simulate the absence of bundler even if it's there + before { hide_const("Bundler::GemfileError") } + + it "detects the existance of bundler" do + expect(subject).not_to receive(:`).with('bundle exec which wkhtmltopdf') + expect(subject).to receive(:`).with('which wkhtmltopdf').and_return('c:\windows\path.exe') + expect(subject.wkhtmltopdf).to eq('c:\windows\path.exe') + end + end + end + end + + describe "#executable" do + it "returns wkhtmltopdf by default" do + expect(subject.executable).to eql subject.wkhtmltopdf + end + + it "uses xvfb-run wrapper when option of using xvfb is configured" do + expect(subject).to receive(:using_xvfb?).and_return(true) + expect(subject.executable).to include 'xvfb-run' end end @@ -29,6 +90,13 @@ expect(subject.default_options[:quiet]).to eql false expect(subject.default_options[:is_awesome]).to eql true end + + it "merges additional options with existing defaults" do + subject.default_options = { quiet: false, is_awesome: true } + expect(subject.default_options[:quiet]).to eql false + expect(subject.default_options[:is_awesome]).to eql true + expect(subject.default_options[:disable_smart_shrinking]).to eql false + end end describe "#root_url" do @@ -53,6 +121,22 @@ end end + describe "#using_xvfb?" do + it "can be configured to true" do + subject.use_xvfb = true + expect(subject.using_xvfb?).to eql true + end + + it "defaults to false" do + expect(subject.using_xvfb?).to eql false + end + + it "can be configured to false" do + subject.use_xvfb = false + expect(subject.using_xvfb?).to eql false + end + end + describe "#verbose?" do it "can be configured to true" do subject.verbose = true diff --git a/spec/html_preprocessor_spec.rb b/spec/html_preprocessor_spec.rb index f57b393e..bfa0b794 100644 --- a/spec/html_preprocessor_spec.rb +++ b/spec/html_preprocessor_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe PDFKit::HTMLPreprocessor do diff --git a/spec/middleware_spec.rb b/spec/middleware_spec.rb index fec6942c..72ced7e8 100644 --- a/spec/middleware_spec.rb +++ b/spec/middleware_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' def app; Rack::Lint.new(@app); end @@ -17,43 +19,72 @@ def mock_app(options = {}, conditions = {}, custom_headers = {}) describe PDFKit::Middleware do let(:headers) do - {'Content-Type' => "text/html"} + {'content-type' => "text/html"} end describe "#call" do + + describe 'threadsafety' do + before { mock_app } + it 'is threadsafe' do + n = 30 + extensions = Array.new(n) { rand > 0.5 ? 'html' : 'pdf' } + actual_content_types = Hash.new + + threads = (0...n).map { |i| + Thread.new do + resp = get("http://www.example.org/public/test.#{extensions[i]}") + actual_content_types[i] = resp.content_type + end + } + + threads.each(&:join) + + extensions.each_with_index do |extension, index| + result = actual_content_types[index] + case extension + when 'html', 'txt', 'csv' + expect(result).to eq("text/#{extension}") + when 'pdf' + expect(result).to eq('application/pdf') + end + end + end + end + describe "caching" do let(:headers) do { - 'Content-Type' => "text/html", - 'ETag' => 'foo', - 'Cache-Control' => 'max-age=2592000, public' + 'content-type' => "text/html", + 'etag' => 'foo', + 'cache-control' => 'max-age=2592000, public' } end context "by default" do before { mock_app } - it "deletes ETag" do + it "deletes etag" do get 'http://www.example.org/public/test.pdf' - expect(last_response.headers["ETag"]).to be_nil + expect(last_response.headers["etag"]).to be_nil end - it "deletes Cache-Control" do + it "deletes cache-control" do get 'http://www.example.org/public/test.pdf' - expect(last_response.headers["Cache-Control"]).to be_nil + expect(last_response.headers["cache-control"]).to be_nil end end context "when on" do before { mock_app({}, :caching => true) } - it "preserves ETag" do + it "preserves etag" do get 'http://www.example.org/public/test.pdf' - expect(last_response.headers["ETag"]).not_to be_nil + expect(last_response.headers["etag"]).not_to be_nil end - it "preserves Cache-Control" do + it "preserves cache-control" do get 'http://www.example.org/public/test.pdf' - expect(last_response.headers["Cache-Control"]).not_to be_nil + expect(last_response.headers["cache-control"]).not_to be_nil end end end @@ -68,7 +99,7 @@ def mock_app(options = {}, conditions = {}, custom_headers = {}) context "matching" do specify do get 'http://www.example.org/public/test.pdf' - expect(last_response.headers["Content-Type"]).to eq("application/pdf") + expect(last_response.headers["content-type"]).to eq("application/pdf") expect(last_response.body.bytesize).to eq(PDFKit.new("Hello world!").to_pdf.bytesize) end end @@ -76,7 +107,7 @@ def mock_app(options = {}, conditions = {}, custom_headers = {}) context "not matching" do specify do get 'http://www.example.org/secret/test.pdf' - expect(last_response.headers["Content-Type"]).to eq("text/html") + expect(last_response.headers["content-type"]).to eq("text/html") expect(last_response.body).to eq("Hello world!") end end @@ -88,7 +119,7 @@ def mock_app(options = {}, conditions = {}, custom_headers = {}) context "matching" do specify do get 'http://www.example.org/public/test.pdf' - expect(last_response.headers["Content-Type"]).to eq("application/pdf") + expect(last_response.headers["content-type"]).to eq("application/pdf") expect(last_response.body.bytesize).to eq(PDFKit.new("Hello world!").to_pdf.bytesize) end end @@ -96,7 +127,7 @@ def mock_app(options = {}, conditions = {}, custom_headers = {}) context "not matching" do specify do get 'http://www.example.org/secret/test.pdf' - expect(last_response.headers["Content-Type"]).to eq("text/html") + expect(last_response.headers["content-type"]).to eq("text/html") expect(last_response.body).to eq("Hello world!") end end @@ -110,7 +141,7 @@ def mock_app(options = {}, conditions = {}, custom_headers = {}) context "matching" do specify do get 'http://www.example.org/public/test.pdf' - expect(last_response.headers["Content-Type"]).to eq("application/pdf") + expect(last_response.headers["content-type"]).to eq("application/pdf") expect(last_response.body.bytesize).to eq(PDFKit.new("Hello world!").to_pdf.bytesize) end end @@ -118,7 +149,7 @@ def mock_app(options = {}, conditions = {}, custom_headers = {}) context "not matching" do specify do get 'http://www.example.org/secret/test.pdf' - expect(last_response.headers["Content-Type"]).to eq("text/html") + expect(last_response.headers["content-type"]).to eq("text/html") expect(last_response.body).to eq("Hello world!") end end @@ -130,7 +161,7 @@ def mock_app(options = {}, conditions = {}, custom_headers = {}) context "matching" do specify do get 'http://www.example.org/public/test.pdf' - expect(last_response.headers["Content-Type"]).to eq("application/pdf") + expect(last_response.headers["content-type"]).to eq("application/pdf") expect(last_response.body.bytesize).to eq(PDFKit.new("Hello world!").to_pdf.bytesize) end end @@ -138,7 +169,7 @@ def mock_app(options = {}, conditions = {}, custom_headers = {}) context "not matching" do specify do get 'http://www.example.org/secret/test.pdf' - expect(last_response.headers["Content-Type"]).to eq("text/html") + expect(last_response.headers["content-type"]).to eq("text/html") expect(last_response.body).to eq("Hello world!") end end @@ -156,7 +187,7 @@ def mock_app(options = {}, conditions = {}, custom_headers = {}) context "matching" do specify do get 'http://www.example.org/public/test.pdf' - expect(last_response.headers["Content-Type"]).to eq("application/pdf") + expect(last_response.headers["content-type"]).to eq("application/pdf") expect(last_response.body.bytesize).to eq(PDFKit.new("Hello world!").to_pdf.bytesize) end end @@ -164,7 +195,7 @@ def mock_app(options = {}, conditions = {}, custom_headers = {}) context "not matching" do specify do get 'http://www.example.org/secret/test.pdf' - expect(last_response.headers["Content-Type"]).to eq("text/html") + expect(last_response.headers["content-type"]).to eq("text/html") expect(last_response.body).to eq("Hello world!") end end @@ -176,7 +207,7 @@ def mock_app(options = {}, conditions = {}, custom_headers = {}) context "matching" do specify do get 'http://www.example.org/public/test.pdf' - expect(last_response.headers["Content-Type"]).to eq("application/pdf") + expect(last_response.headers["content-type"]).to eq("application/pdf") expect(last_response.body.bytesize).to eq(PDFKit.new("Hello world!").to_pdf.bytesize) end end @@ -184,7 +215,7 @@ def mock_app(options = {}, conditions = {}, custom_headers = {}) context "not matching" do specify do get 'http://www.example.org/secret/test.pdf' - expect(last_response.headers["Content-Type"]).to eq("text/html") + expect(last_response.headers["content-type"]).to eq("text/html") expect(last_response.body).to eq("Hello world!") end end @@ -198,7 +229,7 @@ def mock_app(options = {}, conditions = {}, custom_headers = {}) context "matching" do specify do get 'http://www.example.org/public/test.pdf' - expect(last_response.headers["Content-Type"]).to eq("application/pdf") + expect(last_response.headers["content-type"]).to eq("application/pdf") expect(last_response.body.bytesize).to eq(PDFKit.new("Hello world!").to_pdf.bytesize) end end @@ -206,7 +237,7 @@ def mock_app(options = {}, conditions = {}, custom_headers = {}) context "not matching" do specify do get 'http://www.example.org/secret/test.pdf' - expect(last_response.headers["Content-Type"]).to eq("text/html") + expect(last_response.headers["content-type"]).to eq("text/html") expect(last_response.body).to eq("Hello world!") end end @@ -218,7 +249,7 @@ def mock_app(options = {}, conditions = {}, custom_headers = {}) context "matching" do specify do get 'http://www.example.org/public/test.pdf' - expect(last_response.headers["Content-Type"]).to eq("application/pdf") + expect(last_response.headers["content-type"]).to eq("application/pdf") expect(last_response.body.bytesize).to eq(PDFKit.new("Hello world!").to_pdf.bytesize) end end @@ -226,7 +257,7 @@ def mock_app(options = {}, conditions = {}, custom_headers = {}) context "not matching" do specify do get 'http://www.example.org/secret/test.pdf' - expect(last_response.headers["Content-Type"]).to eq("text/html") + expect(last_response.headers["content-type"]).to eq("text/html") expect(last_response.body).to eq("Hello world!") end end @@ -238,8 +269,8 @@ def mock_app(options = {}, conditions = {}, custom_headers = {}) describe "saving generated pdf to disk" do before do #make sure tests don't find an old test_save.pdf - File.delete('spec/test_save.pdf') if File.exists?('spec/test_save.pdf') - expect(File.exists?('spec/test_save.pdf')).to eq(false) + File.delete('spec/test_save.pdf') if File.exist?('spec/test_save.pdf') + expect(File.exist?('spec/test_save.pdf')).to eq(false) end context "when header PDFKit-save-pdf is present" do @@ -247,7 +278,7 @@ def mock_app(options = {}, conditions = {}, custom_headers = {}) headers = { 'PDFKit-save-pdf' => 'spec/test_save.pdf' } mock_app({}, {only: '/public'}, headers) get 'http://www.example.org/public/test_save.pdf' - expect(File.exists?('spec/test_save.pdf')).to eq(true) + expect(File.exist?('spec/test_save.pdf')).to eq(true) end it "does not raise when target directory does not exist" do @@ -263,12 +294,157 @@ def mock_app(options = {}, conditions = {}, custom_headers = {}) it "does not saved the .pdf to disk" do mock_app({}, {only: '/public'}, {} ) get 'http://www.example.org/public/test_save.pdf' - expect(File.exists?('spec/test_save.pdf')).to eq(false) + expect(File.exist?('spec/test_save.pdf')).to eq(false) + end + end + end + + describe 'javascript delay' do + context 'when header PDFKit-javascript-delay is present' do + it 'passes header value through to PDFKit initialiser' do + expect(PDFKit).to receive(:new).with('Hello world!', { + root_url: 'http://www.example.com/', protocol: 'http', javascript_delay: 4321 + }).and_call_original + + headers = { 'PDFKit-javascript-delay' => '4321' } + mock_app({}, { only: '/public' }, headers) + get 'http://www.example.com/public/test_save.pdf' + end + + it 'handles invalid content in header' do + expect(PDFKit).to receive(:new).with('Hello world!', { + root_url: 'http://www.example.com/', protocol: 'http', javascript_delay: 0 + }).and_call_original + + headers = { 'PDFKit-javascript-delay' => 'invalid' } + mock_app({}, { only: '/public' }, headers) + get 'http://www.example.com/public/test_save.pdf' + end + + it 'overrides default option' do + expect(PDFKit).to receive(:new).with('Hello world!', { + root_url: 'http://www.example.com/', protocol: 'http', javascript_delay: 4321 + }).and_call_original + + headers = { 'PDFKit-javascript-delay' => '4321' } + mock_app({ javascript_delay: 1234 }, { only: '/public' }, headers) + get 'http://www.example.com/public/test_save.pdf' + end + end + + context 'when header PDFKit-javascript-delay is not present' do + it 'passes through default option' do + expect(PDFKit).to receive(:new).with('Hello world!', { + root_url: 'http://www.example.com/', protocol: 'http', javascript_delay: 1234 + }).and_call_original + + mock_app({ javascript_delay: 1234 }, { only: '/public' }, { }) + get 'http://www.example.com/public/test_save.pdf' + end + end + end + + describe ":disposition" do + describe "doesn't overwrite existing value" do + let(:headers) do + super().merge({ + 'content-disposition' => 'attachment; filename=report-20200101.pdf' + }) + end + + specify do + mock_app({}, { :disposition => 'inline' }) + get 'http://www.example.org/public/test.pdf' + expect(last_response.headers["content-disposition"]).to eq('attachment; filename=report-20200101.pdf') + end + end + + describe "inline or blank" do + context "default" do + specify do + mock_app + get 'http://www.example.org/public/test.pdf' + expect(last_response.headers["content-disposition"]).to eq("inline") + end + end + + context "inline" do + specify do + mock_app({}, { :disposition => 'inline' }) + get 'http://www.example.org/public/test.pdf' + expect(last_response.headers["content-disposition"]).to eq("inline") + end + end + end + + describe "attachment" do + context "attachment" do + specify do + mock_app({}, { :disposition => 'attachment' }) + get 'http://www.example.org/public/test.pdf' + expect(last_response.headers["content-disposition"]).to eq("attachment") + end + end + + context "attachment with filename" do + specify do + mock_app({}, { :disposition => 'attachment; filename=report.pdf' }) + get 'http://www.example.org/public/test.pdf' + expect(last_response.headers["content-disposition"]).to eq("attachment; filename=report.pdf") + end + end + end + end + + describe "error handling" do + let(:error) { StandardError.new("Something went wrong") } + + context "errors raised by PDF generation" do + specify do + mock_app + allow(PDFKit).to receive(:new).and_raise(error) + get 'http://www.example.org/public/test.pdf' + expect(last_response.status).to eq(500) + expect(last_response.body).to eq(error.message) + end + end + + context "errors raised upstream" do + specify do + mock_app + allow(@app).to receive(:call).and_raise(error) + + expect { + get 'http://www.example.org/public/test.pdf' + }.to raise_error(error) end end end end + describe "content type header" do + before { mock_app } + + context "lower case" do + specify "header gets correctly updated" do + get 'http://www.example.org/public/test.pdf' + expect(last_response.headers["content-type"]).to eq("application/pdf") + end + end + + context "mixed case" do + let(:headers) do + {'Content-Type' => "text/html"} + end + + specify "header gets correctly updated" do + pending("this test only applies to rack 2.x and is rejected by rack 3.x") if Rack.release >= "3.0.0" + get 'http://www.example.org/public/test.pdf' + expect(last_response.headers["Content-Type"]).to eq("application/pdf") + end + end + end + describe "remove .pdf from PATH_INFO and REQUEST_URI" do before { mock_app } @@ -293,7 +469,7 @@ def mock_app(options = {}, conditions = {}, custom_headers = {}) main_app = lambda { |env| @env = env @env['SCRIPT_NAME'] = '/example.org' - headers = {'Content-Type' => "text/html"} + headers = {'content-type' => "text/html"} [200, headers, @body || ['Hello world!']] } @@ -352,22 +528,4 @@ def mock_app(options = {}, conditions = {}, custom_headers = {}) end end end - - it "does not get stuck rendering each request as pdf" do - mock_app - # false by default. No requests. - expect(@app.send(:rendering_pdf?)).to eq(false) - - # Remain false on a normal request - get 'http://www.example.org/public/file' - expect(@app.send(:rendering_pdf?)).to eq(false) - - # Return true on a pdf request. - get 'http://www.example.org/public/file.pdf' - expect(@app.send(:rendering_pdf?)).to eq(true) - - # Restore to false on any non-pdf request. - get 'http://www.example.org/public/file' - expect(@app.send(:rendering_pdf?)).to eq(false) - end end diff --git a/spec/os_spec.rb b/spec/os_spec.rb index a3ad16ff..d8c5abfa 100644 --- a/spec/os_spec.rb +++ b/spec/os_spec.rb @@ -1,4 +1,6 @@ #encoding: UTF-8 +# frozen_string_literal: true + require 'spec_helper' require 'rbconfig' diff --git a/spec/pdfkit_spec.rb b/spec/pdfkit_spec.rb index 3ff46003..a6c23142 100644 --- a/spec/pdfkit_spec.rb +++ b/spec/pdfkit_spec.rb @@ -1,4 +1,6 @@ #encoding: UTF-8 +# frozen_string_literal: true + require 'spec_helper' describe PDFKit do @@ -23,6 +25,13 @@ expect(pdfkit.source.to_s).to eq(file_path) end + it "accepts a Tempfile as the source" do + file_path = File.join(SPEC_ROOT,'fixtures','example.html') + pdfkit = PDFKit.new(Tempfile.new(file_path)) + expect(pdfkit.source).to be_file + expect(pdfkit.source.to_s).to match(/^#{Dir.tmpdir}/) + end + # Options ## options keys it "drops options without values" do @@ -36,8 +45,8 @@ end it "transforms complex keys into command-line arguments" do - pdfkit = PDFKit.new('html', :replace => {'value' => 'something else'} ) - expect(pdfkit.options).to have_key('--replace') + pdfkit = PDFKit.new('html', :header_left => {'value' => 'something else'} ) + expect(pdfkit.options).to have_key('--header-left') end it "drops options with false or falsey values" do @@ -63,8 +72,8 @@ end it "parses hash option values into an array" do - pdfkit = PDFKit.new('html', :replace => {'value' => 'something else'} ) - expect(pdfkit.options['--replace']).to eql ['value', 'something else'] + pdfkit = PDFKit.new('html', :header_left => {'value' => 'something else'} ) + expect(pdfkit.options['--header-left']).to eql ['value', 'something else'] end it "flattens hash options into the key" do @@ -75,8 +84,8 @@ end it "parses array option values into a string" do - pdfkit = PDFKit.new('html', :replace => ['value', 'something else'] ) - expect(pdfkit.options['--replace']).to eql ['value', 'something else'] + pdfkit = PDFKit.new('html', :header_left => ['value', 'something else'] ) + expect(pdfkit.options['--header-left']).to eql ['value', 'something else'] end it "flattens array options" do @@ -166,22 +175,36 @@ it "constructs the correct command" do pdfkit = PDFKit.new('html', :page_size => 'Letter', :toc_l1_font_size => 12, :replace => {'foo' => 'bar'}) command = pdfkit.command - expect(command).to include "wkhtmltopdf" - expect(command).to include "--page-size Letter" - expect(command).to include "--toc-l1-font-size 12" - expect(command).to include "--replace foo bar" + expect(command.first).to match(/wkhtmltopdf/) + expect(command).to contain %w[--page-size Letter] + expect(command).to contain %w[--toc-l1-font-size 12] + expect(command).to contain %w[--replace foo bar] + end + + it "contains a specified by path argument" do + pdfkit = PDFKit.new('html') + command = pdfkit.command("/foo/bar") + expect(command.first).to match(/wkhtmltopdf/) + expect(command.last).to eq("/foo/bar") + end + + it "contains a specified by path argument of Pathname" do + pdfkit = PDFKit.new('html') + command = pdfkit.command(Pathname.new("/foo/bar")) + expect(command.first).to match(/wkhtmltopdf/) + expect(command.last).to eq("/foo/bar") end it "sets up one cookie when hash has only one cookie" do pdfkit = PDFKit.new('html', cookie: {cookie_name: :cookie_value}) command = pdfkit.command - expect(command).to include "--cookie cookie_name cookie_value" + expect(command).to contain %w[--cookie cookie_name cookie_value] end - it "does not break Windows paths" do + it "does not split Windows paths that contain spaces" do pdfkit = PDFKit.new('html') allow(PDFKit.configuration).to receive(:wkhtmltopdf).and_return 'c:/Program Files/wkhtmltopdf/wkhtmltopdf.exe' - expect(pdfkit.command).not_to include('Program\ Files') + expect(pdfkit.command).not_to contain(%w[c:/Program Files/wkhtmltopdf/wkhtmltopdf.exe]) end it "does not shell escape source URLs" do @@ -198,15 +221,15 @@ it "sets up multiple cookies when passed multiple cookies" do pdfkit = PDFKit.new('html', :cookie => {:cookie_name1 => :cookie_val1, :cookie_name2 => :cookie_val2}) command = pdfkit.command - expect(command).to include "--cookie cookie_name1 cookie_val1" - expect(command).to include "--cookie cookie_name2 cookie_val2" + expect(command).to contain %w[--cookie cookie_name1 cookie_val1] + expect(command).to contain %w[--cookie cookie_name2 cookie_val2] end it "sets up multiple cookies when passed an array of tuples" do pdfkit = PDFKit.new('html', :cookie => [[:cookie_name1, :cookie_val1], [:cookie_name2, :cookie_val2]]) command = pdfkit.command - expect(command).to include "--cookie cookie_name1 cookie_val1" - expect(command).to include "--cookie cookie_name2 cookie_val2" + expect(command).to contain %w[--cookie cookie_name1 cookie_val1] + expect(command).to contain %w[--cookie cookie_name2 cookie_val2] end it "will not include default options it is told to omit" do @@ -220,42 +243,57 @@ expect(pdfkit.command).not_to include('--disable-smart-shrinking') end - it "encapsulates string arguments in quotes" do + it "must not split string arguments containing spaces" do pdfkit = PDFKit.new('html', :header_center => "foo [page]") - expect(pdfkit.command).to include "--header-center foo\\ \\[page\\]" + expect(pdfkit.command).to contain ['--header-center', 'foo [page]'] end - it "sanitizes string arguments" do + it "paramatarizes string arguments" do pdfkit = PDFKit.new('html', :header_center => "$(ls)") - expect(pdfkit.command).to include "--header-center \\$\\(ls\\)" + expect(pdfkit.command).to contain %w[--header-center $(ls)] end it "read the source from stdin if it is html" do pdfkit = PDFKit.new('html') - expect(pdfkit.command).to match /- -$/ + command = pdfkit.command + expect(command[-2]).to eq('-') + expect(command[-1]).to eq('-') end it "specifies the URL to the source if it is a url" do pdfkit = PDFKit.new('http://google.com') - expect(pdfkit.command).to match /"http:\/\/google.com" -$/ + command = pdfkit.command + expect(command[-2]).to eq("http://google.com") + expect(command[-1]).to eq("-") end it "does not break Windows paths" do pdfkit = PDFKit.new('html') allow(PDFKit.configuration).to receive(:wkhtmltopdf).and_return 'c:/Program Files/wkhtmltopdf/wkhtmltopdf.exe' - expect(pdfkit.command).not_to include('Program\ Files') + expect(pdfkit.command).not_to contain ['Program', 'Files'] end it "specifies the path to the source if it is a file" do file_path = File.join(SPEC_ROOT,'fixtures','example.html') pdfkit = PDFKit.new(File.new(file_path)) - expect(pdfkit.command).to match /#{file_path} -$/ + command = pdfkit.command + expect(command[-2]).to eq(file_path) + expect(command[-1]).to eq('-') + end + + it "specifies the path to the source if it is a tempfile" do + file_path = File.join(SPEC_ROOT,'fixtures','example.html') + pdfkit = PDFKit.new(Tempfile.new(file_path)) + command = pdfkit.command + expect(command[-2]).to start_with(Dir.tmpdir) + expect(command[-1]).to eq('-') end it "specifies the path for the ouput if a path is given" do file_path = "/path/to/output.pdf" pdfkit = PDFKit.new("html") - expect(pdfkit.command(file_path)).to match /#{file_path}$/ + command = pdfkit.command(file_path) + expect(command.last).to eq(file_path) end it "detects special pdfkit meta tags" do @@ -269,8 +307,8 @@ } pdfkit = PDFKit.new(body) command = pdfkit.command - expect(command).to include "--page-size Legal" - expect(command).to include "--orientation Landscape" + expect(command).to contain %w[--page-size Legal] + expect(command).to contain %w[--orientation Landscape] end it "detects cookies meta tag" do @@ -284,7 +322,7 @@ } pdfkit = PDFKit.new(body) command = pdfkit.command - expect(command).to include "--cookie rails_session rails_session_value --cookie cookie_variable cookie_variable_value" + expect(command).to contain %w[--cookie rails_session rails_session_value --cookie cookie_variable cookie_variable_value] end it "detects disable_smart_shrinking meta tag" do @@ -298,7 +336,7 @@ pdfkit = PDFKit.new(body) command = pdfkit.command expect(command).to include "--disable-smart-shrinking" - expect(command).not_to include "--disable-smart-shrinking true" + expect(command).not_to contain %w[--disable-smart-shrinking true] end it "detects names with hyphens instead of underscores" do @@ -327,8 +365,8 @@ } pdfkit = PDFKit.new(body) command = pdfkit.command - expect(command).to include "--page-size Legal" - expect(command).to include "--orientation Landscape" + expect(command).to contain %w[--page-size Legal] + expect(command).to contain %w[--orientation Landscape] end it "skips non-pdfkit meta tags" do @@ -343,8 +381,8 @@ } pdfkit = PDFKit.new(body) command = pdfkit.command - expect(command).not_to include "--page-size Legal" - expect(command).to include "--orientation Landscape" + expect(command).not_to contain %w[--page-size Legal] + expect(command).to contain %w[--orientation Landscape] end it "does not use quiet when told to" do @@ -384,19 +422,32 @@ end end + it "does not use xvfb-run wrapper by default" do + pdfkit = PDFKit.new('html') + expect(pdfkit.command).not_to include 'xvfb-run' + end + + it "uses xvfb-run wrapper when option of using xvfb is configured" do + PDFKit.configure do |config| + config.use_xvfb = true + end + + pdfkit = PDFKit.new('html') + expect(pdfkit.command).to include 'xvfb-run' + + PDFKit.configure do |config| + config.use_xvfb = false + end + end + context "on windows" do before do allow(PDFKit::OS).to receive(:host_is_windows?).and_return(true) end - it "escapes special windows characters" do - pdf = PDFKit.new('html', :title => 'hello(world)') - expect(pdf.command).to include 'hello^(world^)' - end - it "quotes spaces in options" do pdf = PDFKit.new('html', :title => 'hello world') - expect(pdf.command).to include "--title 'hello world'" + expect(pdf.command).to contain ['--title', "hello world"] end end end @@ -470,6 +521,14 @@ expect(pdfkit.source.to_s).to include("") end + it "can deal with ActiveSupport::SafeBuffer if the HTML doesn't have a head tag" do + pdfkit = PDFKit.new(ActiveSupport::SafeBuffer.new "Hai!") + css = File.join(SPEC_ROOT,'fixtures','example.css') + pdfkit.stylesheets << css + pdfkit.to_pdf + expect(pdfkit.source.to_s).to include("") + end + it "escapes \\X in stylesheets" do pdfkit = PDFKit.new("Hai!") css = File.join(SPEC_ROOT,'fixtures','example_with_hex_symbol.css') @@ -481,7 +540,7 @@ #NOTICE: This test is failed if use wkhtmltopdf-binary (0.9.9.1) it "throws an error if it is unable to connect" do pdfkit = PDFKit.new("http://google.com/this-should-not-be-found/404.html") - expect { pdfkit.to_pdf }.to raise_error /exitstatus=1/ + expect { pdfkit.to_pdf }.to raise_error PDFKit::ImproperWkhtmltopdfExitStatus, /exitstatus=1/ end it "does not throw an error if it is unable to connect", pending: 'this test works for wkhtmltopdf-binary (0.9.9.1)' do diff --git a/spec/source_spec.rb b/spec/source_spec.rb index 70f84f9b..b0188197 100644 --- a/spec/source_spec.rb +++ b/spec/source_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe PDFKit::Source do @@ -12,6 +14,11 @@ expect(source).not_to be_url end + it "returns false if passed a tempfile" do + source = PDFKit::Source.new(::Tempfile.new(__FILE__)) + expect(source).not_to be_url + end + it "returns false if passed HTML" do source = PDFKit::Source.new('Oh Hai!') expect(source).not_to be_url @@ -29,6 +36,11 @@ expect(source).to be_file end + it "returns true if passed a tempfile" do + source = PDFKit::Source.new(::Tempfile.new(__FILE__)) + expect(source).to be_file + end + it "returns false if passed a url like string" do source = PDFKit::Source.new('http://google.com') expect(source).not_to be_file @@ -51,6 +63,11 @@ expect(source).not_to be_html end + it "returns false if passed a tempfile" do + source = PDFKit::Source.new(::Tempfile.new(__FILE__)) + expect(source).not_to be_html + end + it "returns false if passed a url like string" do source = PDFKit::Source.new('http://google.com') expect(source).not_to be_html @@ -58,14 +75,14 @@ end describe "#to_input_for_command" do - it "URI escapes source URLs and encloses them in quotes to accomodate ampersands" do - source = PDFKit::Source.new("https://www.google.com/search?q='cat/dev/null'") - expect(source.to_input_for_command).to eq "\"https://www.google.com/search?q='cat%3Cdev/zero%3E/dev/null'\"" + it "URI escapes source URI" do + source = PDFKit::Source.new("https://www.google.com/search?q=foo bar") + expect(source.to_input_for_command).to eq "https://www.google.com/search?q=foo%20bar" end it "does not URI escape previously escaped source URLs" do - source = PDFKit::Source.new("https://www.google.com/search?q='cat%3Cdev/zero%3E/dev/null'") - expect(source.to_input_for_command).to eq "\"https://www.google.com/search?q='cat%3Cdev/zero%3E/dev/null'\"" + source = PDFKit::Source.new("https://www.google.com/search?q=foo%20bar") + expect(source.to_input_for_command).to eq "https://www.google.com/search?q=foo%20bar" end it "returns a '-' for HTML strings to indicate that we send that content through STDIN" do @@ -77,6 +94,11 @@ source = PDFKit::Source.new(::File.new(__FILE__)) expect(source.to_input_for_command).to match 'spec/source_spec.rb' end + + it "returns the file path for tempfile sources" do + source = PDFKit::Source.new(file = ::Tempfile.new(__FILE__)) + expect(source.to_input_for_command).to match file.path + end end describe "#to_s" do @@ -90,6 +112,11 @@ expect(source.to_s).to eq(__FILE__) end + it "returns a path if passed a tempfile" do + source = PDFKit::Source.new(file = ::Tempfile.new(__FILE__)) + expect(source.to_s).to eq(file.path) + end + it "returns the url if passed a url like string" do source = PDFKit::Source.new('http://google.com') expect(source.to_s).to eq('http://google.com') diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index d2a94bf1..cbd488c9 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + SPEC_ROOT = File.dirname(__FILE__) $LOAD_PATH.unshift(SPEC_ROOT) $LOAD_PATH.unshift(File.join(SPEC_ROOT, '..', 'lib')) @@ -6,14 +8,26 @@ add_filter 'spec/' end +Warning[:deprecated] = true if defined?(Warning.[]=) + require 'pdfkit' require 'rspec' require 'mocha' require 'rack' require 'rack/test' require 'active_support' -require 'custom_wkhtmltopdf_path' if File.exists?(File.join(SPEC_ROOT, 'custom_wkhtmltopdf_path.rb')) +require 'custom_wkhtmltopdf_path' if File.exist?(File.join(SPEC_ROOT, 'custom_wkhtmltopdf_path.rb')) RSpec.configure do |config| include Rack::Test::Methods end + +RSpec::Matchers.define :contain do |expected| + match do |actual| + (0..(actual.length - expected.length)).any? do |base_index| + expected.each_with_index.all? do |expected_element,index| + actual[base_index+index] == expected_element + end + end + end +end