pdfkit 0.8.2 → 0.8.3

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of pdfkit might be problematic. Click here for more details.

checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: de8b4012d74460425f75d1374c727da61833b7a7
4
- data.tar.gz: e723e3a6a1820d56bba814abe11531e2b50a8cbe
2
+ SHA256:
3
+ metadata.gz: bed642993a544476d0b9ed06d2b6dcdb6bcac722eee1c3decbc0a6c0d307668d
4
+ data.tar.gz: 4ad6010df3d11509774cee9d74477c463258db0e4a845e77243d42ad1d517353
5
5
  SHA512:
6
- metadata.gz: 1adb6a986fe7f6ac50399478a378228031da51a45584ecdc87e6dcdaa706c85242166f5748e50cf073fde18f4607f53fa6bc96bb64e2ddf2121c9f4b2a4743b9
7
- data.tar.gz: 44b2ce9e91809c74af26484f610d0ae7fa0cc322b6d7f3cf3b613292cf1e075d2fa5cd6e5d354b2443a2003fca6586044ee4ad90baa932380b637aa508211da7
6
+ metadata.gz: 7dd8eee00c8793c11011056b7cc4f4ae83f487972aabddd3a55f14341d75b2263baf09501202ace3921417d50bc902be616fd75268bc0c395fcab0a071b041c4
7
+ data.tar.gz: 4b0ecd0f3ca2ba8ad8b5eb7beca56a1c15ba72902664dfe602daeb8e3fb8bf880496e77566d5bfe0d1159b5690599001fd6c4523a95aa2c3b73aa39abbdc9c50
@@ -1 +1 @@
1
- ruby-2.0.0
1
+ 2.5.1
@@ -4,6 +4,10 @@ rvm:
4
4
  - 2.0
5
5
  - 2.1
6
6
 
7
+ before_install:
8
+ - gem update --system
9
+ - gem update bundler
10
+
7
11
  before_script:
8
12
  - "export DISPLAY=:99.0"
9
13
  - "sh -e /etc/init.d/xvfb start"
data/README.md CHANGED
@@ -43,8 +43,22 @@ PDFKit.new('<html><head><meta name="pdfkit-page_size" content="Letter"')
43
43
  PDFKit.new('<html><head><meta name="pdfkit-cookie cookie_name1" content="cookie_value1"')
44
44
  PDFKit.new('<html><head><meta name="pdfkit-cookie cookie_name2" content="cookie_value2"')
45
45
  ```
46
+
47
+ ### Resolving relative URLs and protocols
48
+
49
+ If the source HTML has relative URLs (`/images/cat.png`) or
50
+ [protocols](https://en.wikipedia.org/wiki/Uniform_Resource_Locator#prurl)
51
+ (`//example.com/site.css`) that need to be resolved, you can pass `:root_url`
52
+ and `:protocol` options to PDFKit:
53
+
54
+ ```ruby
55
+ PDFKit.new(html, root_url: 'http://mysite.com/').to_file
56
+ # or:
57
+ PDFKit.new(html, protocol: 'https').to_file
58
+ ```
59
+
46
60
  ### Using cookies in scraping
47
- If you want to pass a cookie to cookie to pdfkit to scrape a website, you can
61
+ If you want to pass a cookie to cookie to pdfkit to scrape a website, you can
48
62
  pass it in a hash:
49
63
  ```ruby
50
64
  kit = PDFKit.new(url, cookie: {cookie_name: :cookie_value})
@@ -62,6 +76,7 @@ PDFKit.configure do |config|
62
76
  }
63
77
  # Use only if your external hostname is unavailable on the server.
64
78
  config.root_url = "http://localhost"
79
+ config.protocol = 'http'
65
80
  config.verbose = false
66
81
  end
67
82
  ```
@@ -127,8 +142,8 @@ Will cause the .pdf to be saved to `path/to/saved.pdf` in addition to being sent
127
142
  like Passenger or try to embed your resources within your HTML to
128
143
  avoid extra HTTP requests.
129
144
 
130
- Example solution (rails / bundler), add unicorn to the development
131
- group in your Gemfile `gem 'unicorn'` then run `bundle`. Next, add a
145
+ Example solution (rails / bundler), add unicorn to the development
146
+ group in your Gemfile `gem 'unicorn'` then run `bundle`. Next, add a
132
147
  file `config/unicorn.conf` with
133
148
 
134
149
  worker_processes 3
@@ -146,7 +161,7 @@ Will cause the .pdf to be saved to `path/to/saved.pdf` in addition to being sent
146
161
  asset host.
147
162
 
148
163
  * **Mangled output in the browser:** Be sure that your HTTP response
149
- headers specify "Content-Type: application/pdf"
164
+ headers specify "Content-Type: application/pdf"
150
165
 
151
166
  ## Note on Patches/Pull Requests
152
167
 
@@ -1,4 +1,7 @@
1
1
  require 'pdfkit/source'
2
2
  require 'pdfkit/pdfkit'
3
3
  require 'pdfkit/middleware'
4
+ require 'pdfkit/html_preprocessor'
5
+ require 'pdfkit/os'
4
6
  require 'pdfkit/configuration'
7
+ require 'pdfkit/wkhtmltopdf'
@@ -1,7 +1,7 @@
1
1
  class PDFKit
2
2
  class Configuration
3
3
  attr_accessor :meta_tag_prefix, :default_options, :root_url
4
- attr_writer :wkhtmltopdf, :verbose
4
+ attr_writer :verbose
5
5
 
6
6
  def initialize
7
7
  @verbose = false
@@ -19,7 +19,20 @@ class PDFKit
19
19
  end
20
20
 
21
21
  def wkhtmltopdf
22
- @wkhtmltopdf ||= (defined?(Bundler::GemfileError) && File.exists?('Gemfile') ? `bundle exec which wkhtmltopdf` : `which wkhtmltopdf`).chomp
22
+ @wkhtmltopdf ||= default_wkhtmltopdf
23
+ end
24
+
25
+ def default_wkhtmltopdf
26
+ @default_command_path ||= (defined?(Bundler::GemfileError) && File.exists?('Gemfile') ? `bundle exec which wkhtmltopdf` : `which wkhtmltopdf`).chomp
27
+ end
28
+
29
+ def wkhtmltopdf=(path)
30
+ if File.exist?(path)
31
+ @wkhtmltopdf = path
32
+ else
33
+ warn "No executable found at #{path}. Will fall back to #{default_wkhtmltopdf}" unless File.exist?(path)
34
+ @wkhtmltopdf = default_wkhtmltopdf
35
+ end
23
36
  end
24
37
 
25
38
  def quiet?
@@ -0,0 +1,23 @@
1
+ class PDFKit
2
+ module HTMLPreprocessor
3
+
4
+ # Change relative paths to absolute, and relative protocols to absolute protocols
5
+ def self.process(html, root_url, protocol)
6
+ html = translate_relative_paths(html, root_url) if root_url
7
+ html = translate_relative_protocols(html, protocol) if protocol
8
+ html
9
+ end
10
+
11
+ private
12
+
13
+ def self.translate_relative_paths(html, root_url)
14
+ # Try out this regexp using rubular http://rubular.com/r/hiAxBNX7KE
15
+ html.gsub(/(href|src)=(['"])\/([^\/"']([^\"']*|[^"']*))?['"]/, "\\1=\\2#{root_url}\\3\\2")
16
+ end
17
+
18
+ def self.translate_relative_protocols(body, protocol)
19
+ # Try out this regexp using rubular http://rubular.com/r/0Ohk0wFYxV
20
+ body.gsub(/(href|src)=(['"])\/\/([^\"']*|[^"']*)['"]/, "\\1=\\2#{protocol}://\\3\\2")
21
+ end
22
+ end
23
+ end
@@ -18,7 +18,16 @@ class PDFKit
18
18
  if rendering_pdf? && headers['Content-Type'] =~ /text\/html|application\/xhtml\+xml/
19
19
  body = response.respond_to?(:body) ? response.body : response.join
20
20
  body = body.join if body.is_a?(Array)
21
- body = PDFKit.new(translate_paths(body, env), @options).to_pdf
21
+
22
+ root_url = root_url(env)
23
+ protocol = protocol(env)
24
+ options = @options.merge(root_url: root_url, protocol: protocol)
25
+
26
+ if headers['PDFKit-javascript-delay']
27
+ options.merge!(javascript_delay: headers.delete('PDFKit-javascript-delay').to_i)
28
+ end
29
+
30
+ body = PDFKit.new(body, options).to_pdf
22
31
  response = [body]
23
32
 
24
33
  if headers['PDFKit-save-pdf']
@@ -32,8 +41,8 @@ class PDFKit
32
41
  headers.delete('Cache-Control')
33
42
  end
34
43
 
35
- headers['Content-Length'] = (body.respond_to?(:bytesize) ? body.bytesize : body.size).to_s
36
- headers['Content-Type'] = 'application/pdf'
44
+ headers['Content-Length'] = (body.respond_to?(:bytesize) ? body.bytesize : body.size).to_s
45
+ headers['Content-Type'] = 'application/pdf'
37
46
  end
38
47
 
39
48
  [status, headers, response]
@@ -41,22 +50,12 @@ class PDFKit
41
50
 
42
51
  private
43
52
 
44
- # Change relative paths to absolute, and relative protocols to absolute protocols
45
- def translate_paths(body, env)
46
- body = translate_relative_paths(body, env)
47
- translate_relative_protocols(body, env)
53
+ def root_url(env)
54
+ PDFKit.configuration.root_url || "#{env['rack.url_scheme']}://#{env['HTTP_HOST']}/"
48
55
  end
49
56
 
50
- def translate_relative_paths(body, env)
51
- root = PDFKit.configuration.root_url || "#{env['rack.url_scheme']}://#{env['HTTP_HOST']}/"
52
- # Try out this regexp using rubular http://rubular.com/r/hiAxBNX7KE
53
- body.gsub(/(href|src)=(['"])\/([^\/"']([^\"']*|[^"']*))?['"]/, "\\1=\\2#{root}\\3\\2")
54
- end
55
-
56
- def translate_relative_protocols(body, env)
57
- protocol = "#{env['rack.url_scheme']}://"
58
- # Try out this regexp using rubular http://rubular.com/r/0Ohk0wFYxV
59
- body.gsub(/(href|src)=(['"])\/\/([^\"']*|[^"']*)['"]/, "\\1=\\2#{protocol}\\3\\2")
57
+ def protocol(env)
58
+ env['rack.url_scheme']
60
59
  end
61
60
 
62
61
  def rendering_pdf?
@@ -65,20 +64,18 @@ class PDFKit
65
64
 
66
65
  def render_as_pdf?
67
66
  request_path = @request.path
68
- request_path_is_pdf = request_path.match(%r{\.pdf$})
67
+ return false unless request_path.end_with?('.pdf')
69
68
 
70
- if request_path_is_pdf && @conditions[:only]
69
+ if @conditions[:only]
71
70
  conditions_as_regexp(@conditions[:only]).any? do |pattern|
72
- request_path =~ pattern
71
+ pattern === request_path
73
72
  end
74
- elsif request_path_is_pdf && @conditions[:except]
75
- conditions_as_regexp(@conditions[:except]).each do |pattern|
76
- return false if request_path =~ pattern
73
+ elsif @conditions[:except]
74
+ conditions_as_regexp(@conditions[:except]).none? do |pattern|
75
+ pattern === request_path
77
76
  end
78
-
79
- return true
80
77
  else
81
- request_path_is_pdf
78
+ true
82
79
  end
83
80
  end
84
81
 
@@ -99,8 +96,8 @@ class PDFKit
99
96
  end
100
97
 
101
98
  def conditions_as_regexp(conditions)
102
- [conditions].flatten.map do |pattern|
103
- pattern.is_a?(Regexp) ? pattern : Regexp.new('^' + pattern)
99
+ Array(conditions).map do |pattern|
100
+ pattern.is_a?(Regexp) ? pattern : Regexp.new("^#{pattern}")
104
101
  end
105
102
  end
106
103
  end
@@ -0,0 +1,19 @@
1
+ require 'rbconfig'
2
+
3
+ class PDFKit
4
+ module OS
5
+ def self.host_is_windows?
6
+ !(RbConfig::CONFIG['host_os'] =~ /mswin|msys|mingw|cygwin|bccwin|wince/).nil?
7
+ end
8
+
9
+ def self.shell_escape_for_os(args)
10
+ if (host_is_windows?)
11
+ # Windows reserved shell characters are: & | ( ) < > ^
12
+ # See http://technet.microsoft.com/en-us/library/cc723564.aspx#XSLTsection123121120120
13
+ args.map { |arg| arg.gsub(/([&|()<>^])/,'^\1') }.join(" ")
14
+ else
15
+ args.shelljoin
16
+ end
17
+ end
18
+ end
19
+ end
@@ -1,5 +1,4 @@
1
1
  require 'shellwords'
2
- require 'rbconfig'
3
2
 
4
3
  class PDFKit
5
4
  class NoExecutableError < StandardError
@@ -17,24 +16,27 @@ class PDFKit
17
16
  end
18
17
 
19
18
  attr_accessor :source, :stylesheets
20
- attr_reader :options
19
+ attr_reader :renderer
21
20
 
22
21
  def initialize(url_file_or_html, options = {})
23
22
  @source = Source.new(url_file_or_html)
24
23
 
25
24
  @stylesheets = []
26
25
 
27
- @options = PDFKit.configuration.default_options.merge(options)
28
- @options.delete(:quiet) if PDFKit.configuration.verbose?
29
- @options.merge! find_options_in_meta(url_file_or_html) unless source.url?
30
- @options = normalize_options(@options)
26
+ options = PDFKit.configuration.default_options.merge(options)
27
+ options.delete(:quiet) if PDFKit.configuration.verbose?
28
+ options.merge! find_options_in_meta(url_file_or_html) unless source.url?
29
+ @root_url = options.delete(:root_url)
30
+ @protocol = options.delete(:protocol)
31
+ @renderer = WkHTMLtoPDF.new options
32
+ @renderer.normalize_options
31
33
 
32
34
  raise NoExecutableError.new unless File.exists?(PDFKit.configuration.wkhtmltopdf)
33
35
  end
34
36
 
35
37
  def command(path = nil)
36
- args = @options.to_a.flatten.compact
37
- shell_escaped_command = [executable, shell_escape_for_os(args)].join ' '
38
+ args = @renderer.options_for_command
39
+ shell_escaped_command = [executable, OS::shell_escape_for_os(args)].join ' '
38
40
 
39
41
  # In order to allow for URL parameters (e.g. https://www.google.com/search?q=pdfkit) we do
40
42
  # not escape the source. The user is responsible for ensuring that no vulnerabilities exist
@@ -45,17 +47,17 @@ class PDFKit
45
47
  "#{shell_escaped_command} #{input_for_command} #{output_for_command}"
46
48
  end
47
49
 
50
+ def options
51
+ # TODO(cdwort,sigmavirus24): Replace this with an attr_reader for @renderer instead in 1.0.0
52
+ @renderer.options
53
+ end
54
+
48
55
  def executable
49
- default = PDFKit.configuration.wkhtmltopdf
50
- return default if default !~ /^\// # its not a path, so nothing we can do
51
- if File.exist?(default)
52
- default
53
- else
54
- default.split('/').last
55
- end
56
+ PDFKit.configuration.wkhtmltopdf
56
57
  end
57
58
 
58
59
  def to_pdf(path=nil)
60
+ preprocess_html
59
61
  append_stylesheets
60
62
 
61
63
  invoke = command(path)
@@ -79,11 +81,6 @@ class PDFKit
79
81
 
80
82
  protected
81
83
 
82
- # Pulled from:
83
- # https://github.com/wkhtmltopdf/wkhtmltopdf/blob/ebf9b6cfc4c58a31349fb94c568b254fac37b3d3/README_WKHTMLTOIMAGE#L27
84
- REPEATABLE_OPTIONS = %w[--allow --cookie --custom-header --post --post-file --run-script]
85
- SPECIAL_OPTIONS = %w[cover toc]
86
-
87
84
  def find_options_in_meta(content)
88
85
  # Read file if content is a File
89
86
  content = content.read if content.is_a?(File)
@@ -111,6 +108,13 @@ class PDFKit
111
108
  "<style>#{File.read(stylesheet)}</style>"
112
109
  end
113
110
 
111
+ def preprocess_html
112
+ if @source.html?
113
+ processed_html = PDFKit::HTMLPreprocessor.process(@source.to_s, @root_url, @protocol)
114
+ @source = Source.new(processed_html)
115
+ end
116
+ end
117
+
114
118
  def append_stylesheets
115
119
  raise ImproperSourceError.new('Stylesheets may only be added to an HTML source') if stylesheets.any? && !@source.html?
116
120
 
@@ -123,65 +127,12 @@ class PDFKit
123
127
  end
124
128
  end
125
129
 
126
- def normalize_options(options)
127
- normalized_options = {}
128
-
129
- options.each do |key, value|
130
- next if !value
131
-
132
- # The actual option for wkhtmltopdf
133
- normalized_key = normalize_arg key
134
- normalized_key = "--#{normalized_key}" unless SPECIAL_OPTIONS.include?(normalized_key)
135
-
136
- # If the option is repeatable, attempt to normalize all values
137
- if REPEATABLE_OPTIONS.include? normalized_key
138
- normalize_repeatable_value(normalized_key, value) do |normalized_unique_key, normalized_value|
139
- normalized_options[normalized_unique_key] = normalized_value
140
- end
141
- else # Otherwise, just normalize it like usual
142
- normalized_options[normalized_key] = normalize_value(value)
143
- end
144
- end
145
-
146
- normalized_options
147
- end
148
-
149
- def normalize_arg(arg)
150
- arg.to_s.downcase.gsub(/[^a-z0-9]/,'-')
151
- end
152
-
153
- def normalize_value(value)
154
- case value
155
- when nil
156
- nil
157
- when TrueClass, 'true' #ie, ==true, see http://www.ruby-doc.org/core-1.9.3/TrueClass.html
158
- nil
159
- when Hash
160
- value.to_a.flatten.collect{|x| normalize_value(x)}.compact
161
- when Array
162
- value.flatten.collect{|x| x.to_s}
163
- else
164
- (host_is_windows? && value.to_s.index(' ')) ? "'#{ value.to_s }'" : value.to_s
165
- end
166
- end
167
-
168
- def normalize_repeatable_value(option_name, value)
169
- case value
170
- when Hash, Array
171
- value.each do |(key, val)|
172
- yield [[option_name, normalize_value(key)], normalize_value(val)]
173
- end
174
- else
175
- yield [[option_name, normalize_value(value)], nil]
176
- end
177
- end
178
-
179
130
  def successful?(status)
180
131
  return true if status.success?
181
132
 
182
133
  # Some of the codes: https://code.google.com/p/wkhtmltopdf/issues/detail?id=1088
183
134
  # returned when assets are missing (404): https://code.google.com/p/wkhtmltopdf/issues/detail?id=548
184
- return true if status.exitstatus == 2 && error_handling?
135
+ return true if status.exitstatus == 2 && @renderer.error_handling?
185
136
 
186
137
  false
187
138
  end
@@ -189,25 +140,4 @@ class PDFKit
189
140
  def empty_result?(path, result)
190
141
  (path && File.size(path) == 0) || (path.nil? && result.to_s.strip.empty?)
191
142
  end
192
-
193
- def error_handling?
194
- @options.key?('--ignore-load-errors') ||
195
- # wkhtmltopdf v0.10.0 beta4 replaces ignore-load-errors with load-error-handling
196
- # https://code.google.com/p/wkhtmltopdf/issues/detail?id=55
197
- %w(skip ignore).include?(@options['--load-error-handling'])
198
- end
199
-
200
- def host_is_windows?
201
- @host_is_windows ||= !(RbConfig::CONFIG['host_os'] =~ /mswin|msys|mingw|cygwin|bccwin|wince/).nil?
202
- end
203
-
204
- def shell_escape_for_os(args)
205
- if (host_is_windows?)
206
- # Windows reserved shell characters are: & | ( ) < > ^
207
- # See http://technet.microsoft.com/en-us/library/cc723564.aspx#XSLTsection123121120120
208
- args.map { |arg| arg.gsub(/([&|()<>^])/,'^\1') }.join(" ")
209
- else
210
- args.shelljoin
211
- end
212
- end
213
143
  end
@@ -1,3 +1,3 @@
1
1
  class PDFKit
2
- VERSION = "0.8.2"
2
+ VERSION = "0.8.3"
3
3
  end
@@ -0,0 +1,80 @@
1
+ class PDFKit
2
+ class WkHTMLtoPDF
3
+ attr_reader :options
4
+ # Pulled from:
5
+ # https://github.com/wkhtmltopdf/wkhtmltopdf/blob/ebf9b6cfc4c58a31349fb94c568b254fac37b3d3/README_WKHTMLTOIMAGE#L27
6
+ REPEATABLE_OPTIONS = %w[--allow --cookie --custom-header --post --post-file --run-script]
7
+ SPECIAL_OPTIONS = %w[cover toc]
8
+
9
+ def initialize(options)
10
+ @options = options
11
+ end
12
+
13
+ def normalize_options
14
+ # TODO(cdwort,sigmavirus24): Make this method idempotent in a future release so it can be called repeatedly
15
+ normalized_options = {}
16
+
17
+ @options.each do |key, value|
18
+ next if !value
19
+
20
+ # The actual option for wkhtmltopdf
21
+ normalized_key = normalize_arg key
22
+ normalized_key = "--#{normalized_key}" unless SPECIAL_OPTIONS.include?(normalized_key)
23
+
24
+ # If the option is repeatable, attempt to normalize all values
25
+ if REPEATABLE_OPTIONS.include? normalized_key
26
+ normalize_repeatable_value(normalized_key, value) do |normalized_unique_key, normalized_value|
27
+ normalized_options[normalized_unique_key] = normalized_value
28
+ end
29
+ else # Otherwise, just normalize it like usual
30
+ normalized_options[normalized_key] = normalize_value(value)
31
+ end
32
+ end
33
+
34
+ @options = normalized_options
35
+ end
36
+
37
+ def error_handling?
38
+ @options.key?('--ignore-load-errors') ||
39
+ # wkhtmltopdf v0.10.0 beta4 replaces ignore-load-errors with load-error-handling
40
+ # https://code.google.com/p/wkhtmltopdf/issues/detail?id=55
41
+ %w(skip ignore).include?(@options['--load-error-handling'])
42
+ end
43
+
44
+ def options_for_command
45
+ @options.to_a.flatten.compact
46
+ end
47
+
48
+ private
49
+
50
+ def normalize_arg(arg)
51
+ arg.to_s.downcase.gsub(/[^a-z0-9]/,'-')
52
+ end
53
+
54
+ def normalize_value(value)
55
+ case value
56
+ when nil
57
+ nil
58
+ when TrueClass, 'true' #ie, ==true, see http://www.ruby-doc.org/core-1.9.3/TrueClass.html
59
+ nil
60
+ when Hash
61
+ value.to_a.flatten.collect{|x| normalize_value(x)}.compact
62
+ when Array
63
+ value.flatten.collect{|x| x.to_s}
64
+ else
65
+ (OS::host_is_windows? && value.to_s.index(' ')) ? "'#{ value.to_s }'" : value.to_s
66
+ end
67
+ end
68
+
69
+ def normalize_repeatable_value(option_name, value)
70
+ case value
71
+ when Hash, Array
72
+ value.each do |(key, val)|
73
+ yield [[option_name, normalize_value(key)], normalize_value(val)]
74
+ end
75
+ else
76
+ yield [[option_name, normalize_value(value)], nil]
77
+ end
78
+ end
79
+ end
80
+ end
@@ -11,6 +11,7 @@ Gem::Specification.new do |s|
11
11
  s.homepage = "https://github.com/pdfkit/pdfkit"
12
12
  s.summary = "HTML+CSS -> PDF"
13
13
  s.description = "Uses wkhtmltopdf to create PDFs using HTML"
14
+ s.license = "MIT"
14
15
 
15
16
  s.rubyforge_project = "pdfkit"
16
17
 
@@ -19,7 +20,9 @@ Gem::Specification.new do |s|
19
20
  s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
20
21
  s.require_paths = ["lib"]
21
22
 
22
- # Developmnet Dependencies
23
+ s.requirements << "wkhtmltopdf"
24
+
25
+ # Development Dependencies
23
26
  s.add_development_dependency(%q<activesupport>, [">= 3.0.8"])
24
27
  s.add_development_dependency(%q<mocha>, [">= 0.9.10"])
25
28
  s.add_development_dependency(%q<rack-test>, [">= 0.5.6"])
@@ -3,17 +3,6 @@ require 'spec_helper'
3
3
  describe PDFKit::Configuration do
4
4
  subject { PDFKit::Configuration.new }
5
5
  describe "#wkhtmltopdf" do
6
- it "can be configured" do
7
- subject.wkhtmltopdf = '/my/bin/wkhtmltopdf'
8
- expect(subject.wkhtmltopdf).to eql '/my/bin/wkhtmltopdf'
9
- end
10
-
11
- # This test documents existing functionality. Feel free to fix.
12
- it "can be poorly configured" do
13
- subject.wkhtmltopdf = 1234
14
- expect(subject.wkhtmltopdf).to eql 1234
15
- end
16
-
17
6
  context "when not explicitly configured" do
18
7
  it "detects the existance of bundler" do
19
8
  # Test assumes bundler is installed in your test environment
@@ -0,0 +1,69 @@
1
+ require 'spec_helper'
2
+
3
+ describe PDFKit::HTMLPreprocessor do
4
+ describe "#process" do
5
+ let(:preprocessor) { PDFKit::HTMLPreprocessor }
6
+ let(:root_url) { 'http://example.com/' } # This mirrors Middleware#root_url's response
7
+ let(:protocol) { 'http' }
8
+
9
+ it "correctly parses host-relative url with single quotes" do
10
+ original_body = %{<html><head><link href='/stylesheets/application.css' media='screen' rel='stylesheet' type='text/css' /></head><body><img alt='test' src="/test.png" /></body></html>}
11
+ body = preprocessor.process original_body, root_url, protocol
12
+ expect(body).to eq("<html><head><link href='http://example.com/stylesheets/application.css' media='screen' rel='stylesheet' type='text/css' /></head><body><img alt='test' src=\"http://example.com/test.png\" /></body></html>")
13
+ end
14
+
15
+ it "correctly parses host-relative url with double quotes" do
16
+ original_body = %{<link href="/stylesheets/application.css" media="screen" rel="stylesheet" type="text/css" />}
17
+ body = preprocessor.process original_body, root_url, protocol
18
+ expect(body).to eq("<link href=\"http://example.com/stylesheets/application.css\" media=\"screen\" rel=\"stylesheet\" type=\"text/css\" />")
19
+ end
20
+
21
+ it "correctly parses protocol-relative url with single quotes" do
22
+ original_body = %{<link href='//fonts.googleapis.com/css?family=Open+Sans:400,600' rel='stylesheet' type='text/css'>}
23
+ body = preprocessor.process original_body, root_url, protocol
24
+ expect(body).to eq("<link href='http://fonts.googleapis.com/css?family=Open+Sans:400,600' rel='stylesheet' type='text/css'>")
25
+ end
26
+
27
+ it "correctly parses protocol-relative url with double quotes" do
28
+ original_body = %{<link href="//fonts.googleapis.com/css?family=Open+Sans:400,600" rel='stylesheet' type='text/css'>}
29
+ body = preprocessor.process original_body, root_url, protocol
30
+ expect(body).to eq("<link href=\"http://fonts.googleapis.com/css?family=Open+Sans:400,600\" rel='stylesheet' type='text/css'>")
31
+ end
32
+
33
+ it "correctly parses multiple tags where first one is root url" do
34
+ original_body = %{<a href='/'><img src='/logo.jpg' ></a>}
35
+ body = preprocessor.process original_body, root_url, protocol
36
+ expect(body).to eq "<a href='http://example.com/'><img src='http://example.com/logo.jpg' ></a>"
37
+ end
38
+
39
+ it "returns the body even if there are no valid substitutions found" do
40
+ original_body = "NO MATCH"
41
+ body = preprocessor.process original_body, root_url, protocol
42
+ expect(body).to eq("NO MATCH")
43
+ end
44
+
45
+ context 'when root_url is nil' do
46
+ it "returns the body safely, without interpolating" do
47
+ original_body = %{<link href='//fonts.googleapis.com/css?family=Open+Sans:400,600' rel='stylesheet' type='text/css'><a href='/'><img src='/logo.jpg'></a>}
48
+ body = preprocessor.process original_body, nil, protocol
49
+ expect(body).to eq(%{<link href='http://fonts.googleapis.com/css?family=Open+Sans:400,600' rel='stylesheet' type='text/css'><a href='/'><img src='/logo.jpg'></a>})
50
+ end
51
+ end
52
+
53
+ context 'when protocol is nil' do
54
+ it "returns the body safely, without interpolating" do
55
+ original_body = %{<link href='//fonts.googleapis.com/css?family=Open+Sans:400,600' rel='stylesheet' type='text/css'><a href='/'><img src='/logo.jpg'></a>}
56
+ body = preprocessor.process original_body, root_url, nil
57
+ expect(body).to eq(%{<link href='//fonts.googleapis.com/css?family=Open+Sans:400,600' rel='stylesheet' type='text/css'><a href='http://example.com/'><img src='http://example.com/logo.jpg'></a>})
58
+ end
59
+ end
60
+
61
+ context 'when root_url and protocol are both nil' do
62
+ it "returns the body safely, without interpolating" do
63
+ original_body = %{<link href='//fonts.googleapis.com/css?family=Open+Sans:400,600' rel='stylesheet' type='text/css'><a href='/'><img src='/logo.jpg'></a>}
64
+ body = preprocessor.process original_body, nil, nil
65
+ expect(body).to eq original_body
66
+ end
67
+ end
68
+ end
69
+ end
@@ -236,22 +236,22 @@ describe PDFKit::Middleware do
236
236
  end
237
237
 
238
238
  describe "saving generated pdf to disk" do
239
- before do
239
+ before do
240
240
  #make sure tests don't find an old test_save.pdf
241
241
  File.delete('spec/test_save.pdf') if File.exists?('spec/test_save.pdf')
242
242
  expect(File.exists?('spec/test_save.pdf')).to eq(false)
243
- end
243
+ end
244
244
 
245
245
  context "when header PDFKit-save-pdf is present" do
246
246
  it "saves the .pdf to disk" do
247
- headers = { 'PDFKit-save-pdf' => 'spec/test_save.pdf' }
247
+ headers = { 'PDFKit-save-pdf' => 'spec/test_save.pdf' }
248
248
  mock_app({}, {only: '/public'}, headers)
249
- get 'http://www.example.org/public/test_save.pdf'
249
+ get 'http://www.example.org/public/test_save.pdf'
250
250
  expect(File.exists?('spec/test_save.pdf')).to eq(true)
251
- end
251
+ end
252
252
 
253
253
  it "does not raise when target directory does not exist" do
254
- headers = { 'PDFKit-save-pdf' => '/this/dir/does/not/exist/spec/test_save.pdf' }
254
+ headers = { 'PDFKit-save-pdf' => '/this/dir/does/not/exist/spec/test_save.pdf' }
255
255
  mock_app({}, {only: '/public'}, headers)
256
256
  expect {
257
257
  get 'http://www.example.com/public/test_save.pdf'
@@ -262,15 +262,60 @@ describe PDFKit::Middleware do
262
262
  context "when header PDFKit-save-pdf is not present" do
263
263
  it "does not saved the .pdf to disk" do
264
264
  mock_app({}, {only: '/public'}, {} )
265
- get 'http://www.example.org/public/test_save.pdf'
265
+ get 'http://www.example.org/public/test_save.pdf'
266
266
  expect(File.exists?('spec/test_save.pdf')).to eq(false)
267
267
  end
268
268
  end
269
269
  end
270
+
271
+ describe 'javascript delay' do
272
+ context 'when header PDFKit-javascript-delay is present' do
273
+ it 'passes header value through to PDFKit initialiser' do
274
+ expect(PDFKit).to receive(:new).with('Hello world!', {
275
+ root_url: 'http://www.example.com/', protocol: 'http', javascript_delay: 4321
276
+ }).and_call_original
277
+
278
+ headers = { 'PDFKit-javascript-delay' => '4321' }
279
+ mock_app({}, { only: '/public' }, headers)
280
+ get 'http://www.example.com/public/test_save.pdf'
281
+ end
282
+
283
+ it 'handles invalid content in header' do
284
+ expect(PDFKit).to receive(:new).with('Hello world!', {
285
+ root_url: 'http://www.example.com/', protocol: 'http', javascript_delay: 0
286
+ }).and_call_original
287
+
288
+ headers = { 'PDFKit-javascript-delay' => 'invalid' }
289
+ mock_app({}, { only: '/public' }, headers)
290
+ get 'http://www.example.com/public/test_save.pdf'
291
+ end
292
+
293
+ it 'overrides default option' do
294
+ expect(PDFKit).to receive(:new).with('Hello world!', {
295
+ root_url: 'http://www.example.com/', protocol: 'http', javascript_delay: 4321
296
+ }).and_call_original
297
+
298
+ headers = { 'PDFKit-javascript-delay' => '4321' }
299
+ mock_app({ javascript_delay: 1234 }, { only: '/public' }, headers)
300
+ get 'http://www.example.com/public/test_save.pdf'
301
+ end
302
+ end
303
+
304
+ context 'when header PDFKit-javascript-delay is not present' do
305
+ it 'passes through default option' do
306
+ expect(PDFKit).to receive(:new).with('Hello world!', {
307
+ root_url: 'http://www.example.com/', protocol: 'http', javascript_delay: 1234
308
+ }).and_call_original
309
+
310
+ mock_app({ javascript_delay: 1234 }, { only: '/public' }, { })
311
+ get 'http://www.example.com/public/test_save.pdf'
312
+ end
313
+ end
314
+ end
270
315
  end
271
316
 
272
- describe "remove .pdf from PATH_INFO and REQUEST_URI" do
273
- before { mock_app }
317
+ describe "remove .pdf from PATH_INFO and REQUEST_URI" do
318
+ before { mock_app }
274
319
 
275
320
  context "matching" do
276
321
 
@@ -319,61 +364,36 @@ describe PDFKit::Middleware do
319
364
  end
320
365
  end
321
366
 
322
- describe "#translate_paths" do
367
+ describe "#root_url and #protocol" do
323
368
  before do
324
369
  @pdf = PDFKit::Middleware.new({})
325
370
  @env = { 'REQUEST_URI' => 'http://example.com/document.pdf', 'rack.url_scheme' => 'http', 'HTTP_HOST' => 'example.com' }
326
371
  end
327
372
 
328
- it "correctly parses relative url with single quotes" do
329
- @body = %{<html><head><link href='/stylesheets/application.css' media='screen' rel='stylesheet' type='text/css' /></head><body><img alt='test' src="/test.png" /></body></html>}
330
- body = @pdf.send :translate_paths, @body, @env
331
- expect(body).to eq("<html><head><link href='http://example.com/stylesheets/application.css' media='screen' rel='stylesheet' type='text/css' /></head><body><img alt='test' src=\"http://example.com/test.png\" /></body></html>")
332
- end
333
-
334
- it "correctly parses relative url with double quotes" do
335
- @body = %{<link href="/stylesheets/application.css" media="screen" rel="stylesheet" type="text/css" />}
336
- body = @pdf.send :translate_paths, @body, @env
337
- expect(body).to eq("<link href=\"http://example.com/stylesheets/application.css\" media=\"screen\" rel=\"stylesheet\" type=\"text/css\" />")
338
- end
339
-
340
- it "correctly parses relative url with double quotes" do
341
- @body = %{<link href='//fonts.googleapis.com/css?family=Open+Sans:400,600' rel='stylesheet' type='text/css'>}
342
- body = @pdf.send :translate_paths, @body, @env
343
- expect(body).to eq("<link href='http://fonts.googleapis.com/css?family=Open+Sans:400,600' rel='stylesheet' type='text/css'>")
344
- end
345
-
346
- it "correctly parses multiple tags where first one is root url" do
347
- @body = %{<a href='/'><img src='/logo.jpg' ></a>}
348
- body = @pdf.send :translate_paths, @body, @env
349
- expect(body).to eq "<a href='http://example.com/'><img src='http://example.com/logo.jpg' ></a>"
350
- end
373
+ context 'when root_url is not configured' do
374
+ it "infers the root_url and protocol from the environment" do
375
+ root_url = @pdf.send(:root_url, @env)
376
+ protocol = @pdf.send(:protocol, @env)
351
377
 
352
- it "returns the body even if there are no valid substitutions found" do
353
- @body = "NO MATCH"
354
- body = @pdf.send :translate_paths, @body, @env
355
- expect(body).to eq("NO MATCH")
378
+ expect(root_url).to eq('http://example.com/')
379
+ expect(protocol).to eq('http')
380
+ end
356
381
  end
357
- end
358
382
 
359
- describe "#translate_paths with root_url configuration" do
360
- before do
361
- @pdf = PDFKit::Middleware.new({})
362
- @env = { 'REQUEST_URI' => 'http://example.com/document.pdf', 'rack.url_scheme' => 'http', 'HTTP_HOST' => 'example.com' }
363
- PDFKit.configure do |config|
364
- config.root_url = "http://example.net/"
383
+ context 'when root_url is configured' do
384
+ before do
385
+ PDFKit.configuration.root_url = 'http://example.net/'
386
+ end
387
+ after do
388
+ PDFKit.configuration.root_url = nil
365
389
  end
366
- end
367
390
 
368
- it "adds the root_url" do
369
- @body = %{<html><head><link href='/stylesheets/application.css' media='screen' rel='stylesheet' type='text/css' /></head><body><img alt='test' src="/test.png" /></body></html>}
370
- body = @pdf.send :translate_paths, @body, @env
371
- expect(body).to eq("<html><head><link href='http://example.net/stylesheets/application.css' media='screen' rel='stylesheet' type='text/css' /></head><body><img alt='test' src=\"http://example.net/test.png\" /></body></html>")
372
- end
391
+ it "takes the root_url from the configuration, and infers the protocol from the environment" do
392
+ root_url = @pdf.send(:root_url, @env)
393
+ protocol = @pdf.send(:protocol, @env)
373
394
 
374
- after do
375
- PDFKit.configure do |config|
376
- config.root_url = nil
395
+ expect(root_url).to eq('http://example.net/')
396
+ expect(protocol).to eq('http')
377
397
  end
378
398
  end
379
399
  end
@@ -0,0 +1,65 @@
1
+ #encoding: UTF-8
2
+ require 'spec_helper'
3
+ require 'rbconfig'
4
+
5
+ describe 'OS' do
6
+ subject { PDFKit::OS }
7
+
8
+ describe 'host_is_windows?' do
9
+ it 'is callable' do
10
+ expect(subject).to respond_to(:host_is_windows?)
11
+ end
12
+
13
+ def test_is_windows(bool, host_os)
14
+ allow(RbConfig::CONFIG).to receive(:[]).with('host_os').and_return(host_os)
15
+
16
+ expect(subject.host_is_windows?).to be bool
17
+ end
18
+
19
+ it 'returns true if the host_os is set to "mswin"' do
20
+ test_is_windows(true, 'mswin')
21
+ end
22
+
23
+ it 'returns true if the host_os is set to "msys"' do
24
+ test_is_windows(true, 'msys')
25
+ end
26
+
27
+ it 'returns false if the host_os is set to "linux-gnu"' do
28
+ test_is_windows(false, 'linux-gnu')
29
+ end
30
+
31
+ it 'returns false if the host_os is set to "darwin14.1.0"' do
32
+ test_is_windows(false, 'darwin14.1.0')
33
+ end
34
+ end
35
+
36
+ describe 'shell_escape_for_os' do
37
+ it 'is callable' do
38
+ expect(subject).to respond_to(:shell_escape_for_os)
39
+ end
40
+
41
+ it 'calls shelljoin on linux' do
42
+ args = double(:shelljoin)
43
+ allow(RbConfig::CONFIG).to receive(:[]).with('host_os').and_return('linux-gnu')
44
+
45
+ expect(args).to receive(:shelljoin)
46
+ PDFKit::OS.shell_escape_for_os(args)
47
+ end
48
+
49
+ it 'calls shelljoin on darwin14.1.10' do
50
+ args = double(:shelljoin)
51
+ allow(RbConfig::CONFIG).to receive(:[]).with('host_os').and_return('darwin14.1.10-gnu')
52
+
53
+ expect(args).to receive(:shelljoin)
54
+ PDFKit::OS.shell_escape_for_os(args)
55
+ end
56
+
57
+ it 'escapes special characters on Windows' do
58
+ args = ['foo|bar', 'biz(baz)', 'foo<baz>bar', 'hello^world&goodbye']
59
+ allow(RbConfig::CONFIG).to receive(:[]).with('host_os').and_return('mswin')
60
+
61
+ escaped_args = PDFKit::OS.shell_escape_for_os(args)
62
+ expect(escaped_args).to eq('foo^|bar biz^(baz^) foo^<baz^>bar hello^^world^&goodbye')
63
+ end
64
+ end
65
+ end
@@ -386,7 +386,7 @@ describe PDFKit do
386
386
 
387
387
  context "on windows" do
388
388
  before do
389
- allow_any_instance_of(PDFKit).to receive(:host_is_windows?).and_return(true)
389
+ allow(PDFKit::OS).to receive(:host_is_windows?).and_return(true)
390
390
  end
391
391
 
392
392
  it "escapes special windows characters" do
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pdfkit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.2
4
+ version: 0.8.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jared Pace
@@ -9,104 +9,104 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2015-08-26 00:00:00.000000000 Z
12
+ date: 2019-02-14 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: activesupport
16
16
  requirement: !ruby/object:Gem::Requirement
17
17
  requirements:
18
- - - '>='
18
+ - - ">="
19
19
  - !ruby/object:Gem::Version
20
20
  version: 3.0.8
21
21
  type: :development
22
22
  prerelease: false
23
23
  version_requirements: !ruby/object:Gem::Requirement
24
24
  requirements:
25
- - - '>='
25
+ - - ">="
26
26
  - !ruby/object:Gem::Version
27
27
  version: 3.0.8
28
28
  - !ruby/object:Gem::Dependency
29
29
  name: mocha
30
30
  requirement: !ruby/object:Gem::Requirement
31
31
  requirements:
32
- - - '>='
32
+ - - ">="
33
33
  - !ruby/object:Gem::Version
34
34
  version: 0.9.10
35
35
  type: :development
36
36
  prerelease: false
37
37
  version_requirements: !ruby/object:Gem::Requirement
38
38
  requirements:
39
- - - '>='
39
+ - - ">="
40
40
  - !ruby/object:Gem::Version
41
41
  version: 0.9.10
42
42
  - !ruby/object:Gem::Dependency
43
43
  name: rack-test
44
44
  requirement: !ruby/object:Gem::Requirement
45
45
  requirements:
46
- - - '>='
46
+ - - ">="
47
47
  - !ruby/object:Gem::Version
48
48
  version: 0.5.6
49
49
  type: :development
50
50
  prerelease: false
51
51
  version_requirements: !ruby/object:Gem::Requirement
52
52
  requirements:
53
- - - '>='
53
+ - - ">="
54
54
  - !ruby/object:Gem::Version
55
55
  version: 0.5.6
56
56
  - !ruby/object:Gem::Dependency
57
57
  name: i18n
58
58
  requirement: !ruby/object:Gem::Requirement
59
59
  requirements:
60
- - - ~>
60
+ - - "~>"
61
61
  - !ruby/object:Gem::Version
62
62
  version: 0.6.11
63
63
  type: :development
64
64
  prerelease: false
65
65
  version_requirements: !ruby/object:Gem::Requirement
66
66
  requirements:
67
- - - ~>
67
+ - - "~>"
68
68
  - !ruby/object:Gem::Version
69
69
  version: 0.6.11
70
70
  - !ruby/object:Gem::Dependency
71
71
  name: rake
72
72
  requirement: !ruby/object:Gem::Requirement
73
73
  requirements:
74
- - - ~>
74
+ - - "~>"
75
75
  - !ruby/object:Gem::Version
76
76
  version: 0.9.2
77
77
  type: :development
78
78
  prerelease: false
79
79
  version_requirements: !ruby/object:Gem::Requirement
80
80
  requirements:
81
- - - ~>
81
+ - - "~>"
82
82
  - !ruby/object:Gem::Version
83
83
  version: 0.9.2
84
84
  - !ruby/object:Gem::Dependency
85
85
  name: rdoc
86
86
  requirement: !ruby/object:Gem::Requirement
87
87
  requirements:
88
- - - ~>
88
+ - - "~>"
89
89
  - !ruby/object:Gem::Version
90
90
  version: 4.0.1
91
91
  type: :development
92
92
  prerelease: false
93
93
  version_requirements: !ruby/object:Gem::Requirement
94
94
  requirements:
95
- - - ~>
95
+ - - "~>"
96
96
  - !ruby/object:Gem::Version
97
97
  version: 4.0.1
98
98
  - !ruby/object:Gem::Dependency
99
99
  name: rspec
100
100
  requirement: !ruby/object:Gem::Requirement
101
101
  requirements:
102
- - - ~>
102
+ - - "~>"
103
103
  - !ruby/object:Gem::Version
104
104
  version: '3.0'
105
105
  type: :development
106
106
  prerelease: false
107
107
  version_requirements: !ruby/object:Gem::Requirement
108
108
  requirements:
109
- - - ~>
109
+ - - "~>"
110
110
  - !ruby/object:Gem::Version
111
111
  version: '3.0'
112
112
  description: Uses wkhtmltopdf to create PDFs using HTML
@@ -116,12 +116,12 @@ executables: []
116
116
  extensions: []
117
117
  extra_rdoc_files: []
118
118
  files:
119
- - .document
120
- - .gitignore
121
- - .rspec
122
- - .ruby-gemset
123
- - .ruby-version
124
- - .travis.yml
119
+ - ".document"
120
+ - ".gitignore"
121
+ - ".rspec"
122
+ - ".ruby-gemset"
123
+ - ".ruby-version"
124
+ - ".travis.yml"
125
125
  - CHANGELOG.md
126
126
  - Gemfile
127
127
  - LICENSE
@@ -130,21 +130,27 @@ files:
130
130
  - Rakefile
131
131
  - lib/pdfkit.rb
132
132
  - lib/pdfkit/configuration.rb
133
+ - lib/pdfkit/html_preprocessor.rb
133
134
  - lib/pdfkit/middleware.rb
135
+ - lib/pdfkit/os.rb
134
136
  - lib/pdfkit/pdfkit.rb
135
137
  - lib/pdfkit/source.rb
136
138
  - lib/pdfkit/version.rb
139
+ - lib/pdfkit/wkhtmltopdf.rb
137
140
  - pdfkit.gemspec
138
141
  - spec/configuration_spec.rb
139
142
  - spec/fixtures/example.css
140
143
  - spec/fixtures/example.html
141
144
  - spec/fixtures/example_with_hex_symbol.css
145
+ - spec/html_preprocessor_spec.rb
142
146
  - spec/middleware_spec.rb
147
+ - spec/os_spec.rb
143
148
  - spec/pdfkit_spec.rb
144
149
  - spec/source_spec.rb
145
150
  - spec/spec_helper.rb
146
151
  homepage: https://github.com/pdfkit/pdfkit
147
- licenses: []
152
+ licenses:
153
+ - MIT
148
154
  metadata: {}
149
155
  post_install_message:
150
156
  rdoc_options: []
@@ -152,17 +158,18 @@ require_paths:
152
158
  - lib
153
159
  required_ruby_version: !ruby/object:Gem::Requirement
154
160
  requirements:
155
- - - '>='
161
+ - - ">="
156
162
  - !ruby/object:Gem::Version
157
163
  version: '0'
158
164
  required_rubygems_version: !ruby/object:Gem::Requirement
159
165
  requirements:
160
- - - '>='
166
+ - - ">="
161
167
  - !ruby/object:Gem::Version
162
168
  version: '0'
163
- requirements: []
169
+ requirements:
170
+ - wkhtmltopdf
164
171
  rubyforge_project: pdfkit
165
- rubygems_version: 2.4.6
172
+ rubygems_version: 2.7.6
166
173
  signing_key:
167
174
  specification_version: 4
168
175
  summary: HTML+CSS -> PDF
@@ -171,7 +178,9 @@ test_files:
171
178
  - spec/fixtures/example.css
172
179
  - spec/fixtures/example.html
173
180
  - spec/fixtures/example_with_hex_symbol.css
181
+ - spec/html_preprocessor_spec.rb
174
182
  - spec/middleware_spec.rb
183
+ - spec/os_spec.rb
175
184
  - spec/pdfkit_spec.rb
176
185
  - spec/source_spec.rb
177
186
  - spec/spec_helper.rb