htmlToPdf 2.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/Rakefile CHANGED
@@ -1,2 +1,6 @@
1
1
  require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
2
3
 
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task default: :spec
data/config.ru ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubygems"
4
+ require "bundler"
5
+
6
+ Bundler.require :default, :development
7
+
8
+ Combustion.initialize! :all
9
+ run Combustion::Application
@@ -0,0 +1,99 @@
1
+ # HtmlToPdf architecture
2
+
3
+ [← Back to README](../README.md)
4
+
5
+ ## Why headless Chrome instead of wkhtmltopdf
6
+
7
+ wkhtmltopdf renders with a frozen, years-out-of-date WebKit build — no flexbox fixes, no CSS Grid,
8
+ no modern font handling, and the upstream project has been unmaintained for years. Headless Chrome
9
+ is the actual rendering engine your users browse your app with, kept current by Chrome's own
10
+ release cycle, so "what the PDF looks like" and "what the page looks like in a browser" stop being
11
+ two different questions. The tradeoff is the dependency: a real Chrome/Chromium install instead of
12
+ a single static binary. See [Rails integration](rails-integration.md#production-deployment-chrome-in-docker)
13
+ for what that costs you in a container image.
14
+
15
+ ## Request lifecycle
16
+
17
+ For one `GET /widgets/1.pdf` request, with `include HtmlToPdf` + `around_action :html_to_pdf` on
18
+ the controller:
19
+
20
+ ```mermaid
21
+ sequenceDiagram
22
+ participant Rails as Rails callback chain
23
+ participant HTP as html_to_pdf
24
+ participant Action as Controller action
25
+ participant Pool as BrowserPool
26
+ participant Chrome as Ferrum::Browser (Chrome)
27
+
28
+ Rails->>HTP: around_action reaches html_to_pdf
29
+ HTP->>HTP: initialize_downloads(pdf_options)<br/>(resolves @layout, @print_options, @javascript_delay)
30
+ HTP->>Rails: request.format = :html, yield
31
+ Rails->>Action: remaining before_actions, then the action body
32
+ Action-->>Rails: implicit render (discarded — see below)
33
+ Rails-->>HTP: yield returns, request.format restored
34
+ HTP->>HTP: render_to_string(layout: @layout)<br/>— the HTML that actually gets converted
35
+ HTP->>Pool: render_pdf(source_file)
36
+ Pool->>Chrome: create_page(new_context: true) { |page| ... }
37
+ Chrome-->>Pool: page.go_to("file://...") + page.pdf(**print_options)
38
+ Pool-->>HTP: PDF bytes
39
+ HTP->>Rails: send_file_headers! + self.response_body =
40
+ ```
41
+
42
+ The forced-`:html` `yield` is the load-bearing part of this design, and the reason this is an
43
+ `around_action` rather than a `before_action`: it lets the *rest* of the callback chain and the
44
+ action run in their normal place — any other `before_action` (a `set_record`-style one, say) runs
45
+ exactly where it always would, with its instance variables available by the time
46
+ `render_to_string` runs afterward. Format is forced to `:html` for the `yield` only so Rails'
47
+ *own* implicit-render machinery resolves the actual `.html.erb` template instead of raising
48
+ `UnknownFormat` for a `.pdf` template that doesn't exist — that render's output is thrown away;
49
+ `render_to_string`, called after format is restored, is what actually gets written to disk and
50
+ converted. See [The DoubleRenderError trap](https://ramlaxmanyadav.github.io/htmlToPdf/rails-double-render-pdf.html)
51
+ for the bug this design replaced and exactly why the obvious-looking alternative doesn't work.
52
+
53
+ ## BrowserPool
54
+
55
+ ```mermaid
56
+ flowchart LR
57
+ R1["Request A"] --> Pool
58
+ R2["Request B"] --> Pool
59
+ Pool["BrowserPool<br/>(one Ferrum::Browser per process)"]
60
+ Pool --> C1["Context A<br/>(create_page new_context: true)"]
61
+ Pool --> C2["Context B<br/>(create_page new_context: true)"]
62
+ C1 --> Chrome["Chrome OS process"]
63
+ C2 --> Chrome
64
+ ```
65
+
66
+ Launching Chrome costs several hundred milliseconds, so `HtmlToPdf::BrowserPool` keeps one
67
+ `Ferrum::Browser` alive per Ruby process (lazily started on first use, guarded by a `Mutex` for
68
+ the memoization itself — not for every command; Ferrum's own CDP client assigns command IDs under
69
+ its own mutex and matches responses by ID, so concurrent `with_page` calls from multiple Puma
70
+ threads are safe to issue against the one shared browser). Every call still gets `create_page(new_context:
71
+ true) { |page| ... }` — a fresh, isolated browser context (separate cookies/storage/cache) per
72
+ conversion, disposed automatically when the block returns. That block form matters: calling
73
+ `create_page(new_context: true)` *without* a block and closing the page yourself leaks the context
74
+ — Ferrum only disposes it in the `ensure` attached to the block form.
75
+
76
+ If a command raises `Ferrum::DeadBrowserError`, `ProcessTimeoutError`, `TimeoutError`, or
77
+ `NoSuchPageError` (Chrome crashed or stopped responding), `BrowserPool` quits the dead browser,
78
+ drops the memoized reference, and retries the whole `with_page` call once against a freshly
79
+ launched browser before giving up and re-raising.
80
+
81
+ **Scaling note:** this is one Chrome process per Ruby process, not per request. That's the right
82
+ tradeoff for the common case (a web app rendering PDFs on demand), but it does mean every thread in
83
+ that process shares one browser — a page that hangs indefinitely ties up that shared resource for
84
+ everyone until Ferrum's own timeout fires. If you're rendering PDFs at high, sustained concurrency,
85
+ that's a capacity ceiling worth knowing is there rather than discovering under load.
86
+
87
+ ## print_options and security
88
+
89
+ `HtmlToPdf::PrintOptions.build` is the one place `pdf_options` (a request-param-shaped hash) turns
90
+ into the keyword arguments handed to `Ferrum::Page#pdf` / Chrome's `Page.printToPDF`. Precedence,
91
+ highest first: `pdf_options[:print_options]`, then `pdf_options[:padding]`, then
92
+ `config.default_options` — each source is resolved (paper `:format` → `:paper_width`/
93
+ `:paper_height`, unit conversion) independently before merging, so a gem-wide default can't
94
+ silently clobber an explicit per-request value for the same underlying dimension.
95
+
96
+ Chrome's print options are a fixed, narrow set of layout flags — margins, paper size, scale — with
97
+ one deliberate exception: `header_template`/`footer_template` are raw HTML Chrome renders as-is.
98
+ See [Security](security.md) for why those two keys are filtered out of request-supplied
99
+ `print_options` by default, and what `config.allow_request_templates` actually opts you into.
@@ -0,0 +1,60 @@
1
+ # Contributing & Releasing
2
+
3
+ [← Back to README](../README.md)
4
+
5
+ ## Running the test suite
6
+
7
+ ```
8
+ bundle install
9
+ bundle exec rake # alias for `rake spec`
10
+ ```
11
+
12
+ The suite needs a real Chrome or Chromium install for the specs tagged `:chrome` (most of
13
+ `spec/htmlToPdf/browser_pool_spec.rb`, and all of `spec/integration/controller_spec.rb`, which
14
+ drives a real Rails dispatch via a minimal [combustion](https://github.com/pat/combustion) app
15
+ under `spec/internal`) — those specs are automatically excluded with a clear message if
16
+ `Ferrum::Browser.new` can't find a binary, so the rest of the suite (the `PrintOptions` and
17
+ `Configuration` unit specs, and the stubbed-Ferrum half of `BrowserPool`'s specs) still runs
18
+ without one. `spec/integration/controller_spec.rb` also shells out to `pdftotext` (part of
19
+ `poppler-utils`) to assert on actual rendered PDF content — install it locally the same way CI
20
+ does (`apt-get install poppler-utils` on Debian/Ubuntu, `brew install poppler` on macOS) to run
21
+ those checks.
22
+
23
+ `spec/internal` is the combustion-managed dummy Rails app — only the files this gem's own specs
24
+ actually exercise exist there (a couple of controllers, views, and routes), not a full scaffolded
25
+ app.
26
+
27
+ ## Extending it
28
+
29
+ - `lib/htmlToPdf/print_options.rb` is where `pdf_options`/`config.default_options` turn into the
30
+ keyword arguments `Ferrum::Page#pdf` expects — see
31
+ [Architecture](architecture.md#print_options-and-security) before adding a new option, especially
32
+ anything that (like `header_template`) is raw HTML rather than a validated value.
33
+ - `lib/htmlToPdf/browser_pool.rb` owns the one pooled `Ferrum::Browser` per process — see
34
+ [Architecture](architecture.md#browserpool) for the context-leak trap the block form of
35
+ `create_page` exists to avoid.
36
+ - `lib/htmlToPdf.rb` is the controller-facing `around_action` itself — read
37
+ [Rails integration](rails-integration.md) and the
38
+ ["DoubleRenderError" guide](https://ramlaxmanyadav.github.io/htmlToPdf/rails-double-render-pdf.html)
39
+ before changing its control flow; the bug those describe only shows up against a real Rails
40
+ dispatch, not a stub, which is exactly why `spec/integration/controller_spec.rb` exists.
41
+
42
+ ## Releasing
43
+
44
+ Two GitHub Actions workflows handle this repo's own CI/CD:
45
+
46
+ - [`.github/workflows/ci.yml`](../.github/workflows/ci.yml) — runs the spec suite, a syntax sweep,
47
+ and a `gem build` sanity check on every push/PR to `master`, across every Ruby version
48
+ `required_ruby_version` claims to support (3.1 through 3.4).
49
+ - [`.github/workflows/release.yml`](../.github/workflows/release.yml) — pushing a tag matching `v*`
50
+ re-runs the test suite, then publishes to RubyGems via
51
+ [Trusted Publishing](https://guides.rubygems.org/trusted-publishing/) (OIDC — no API key stored
52
+ as a GitHub secret, nothing to leak or rotate).
53
+
54
+ Trusted Publishing needs a one-time setup on rubygems.org before the release workflow can actually
55
+ publish anything (this can't be done from the repo itself — it's a rubygems.org account action):
56
+ sign in, go to the htmlToPdf gem's page → **Trusted Publishers** → add one with owner
57
+ `ramlaxmanyadav`, repository `htmlToPdf`, workflow filename `release.yml`, no environment. After
58
+ that, `git tag vX.Y.Z && git push origin vX.Y.Z` is the entire release process — bump
59
+ `lib/htmlToPdf/version.rb`, update `CHANGELOG.md`, commit, tag, push the tag, and the workflow does
60
+ the rest.
@@ -0,0 +1,111 @@
1
+ # HtmlToPdf in Rails
2
+
3
+ [← Back to README](../README.md)
4
+
5
+ ## Wiring it up
6
+
7
+ ```ruby
8
+ class ApplicationController < ActionController::Base
9
+ include HtmlToPdf
10
+ around_action :html_to_pdf
11
+ end
12
+ ```
13
+
14
+ It has to be `include` + `around_action`, not just a `before_action` — see
15
+ [Architecture](architecture.md#request-lifecycle) for exactly why, and
16
+ [The DoubleRenderError trap](https://ramlaxmanyadav.github.io/htmlToPdf/rails-double-render-pdf.html)
17
+ for the bug this replaced. `HtmlToPdf` is a plain `ActiveSupport::Concern` with no `included do`
18
+ block, so including it only defines the method — nothing runs for any controller until you also
19
+ declare the `around_action` yourself, scoped however you like:
20
+
21
+ ```ruby
22
+ # Only the actions that actually support a PDF export, not every action on the controller.
23
+ around_action :html_to_pdf, only: [:index, :show]
24
+ ```
25
+
26
+ ## Callback ordering
27
+
28
+ Because this is a real `around_action`, every other callback runs in its normal, declared
29
+ position — a `before_action` declared *after* `html_to_pdf` still runs before the view is
30
+ captured, same as it would for an ordinary HTML request:
31
+
32
+ ```ruby
33
+ class InvoicesController < ApplicationController
34
+ around_action :html_to_pdf, only: [:show]
35
+ before_action :set_invoice, only: [:show] # runs normally; @invoice is set before capture
36
+
37
+ def show
38
+ end
39
+
40
+ private
41
+
42
+ def set_invoice
43
+ @invoice = Invoice.find(params[:id])
44
+ end
45
+ end
46
+ ```
47
+
48
+ `rescue_from` works the same way — an exception raised inside the action during the `yield`
49
+ propagates normally and is handled by whatever `rescue_from` the controller has configured, exactly
50
+ as it would outside a PDF request.
51
+
52
+ ## Custom layout and filename per request
53
+
54
+ ```erb
55
+ <%= link_to "Download PDF", invoice_path(@invoice, format: :pdf, pdf_options: {
56
+ title: "invoice-#{@invoice.number}",
57
+ layout: "pdf"
58
+ }) %>
59
+ ```
60
+
61
+ `pdf_options[:layout]` overrides `config.default_layout` for just this render — handy for a
62
+ print-specific layout with no navigation chrome, as opposed to your app's normal layout. See the
63
+ [README](../README.md#usage) for the full `pdf_options`/`print_options` reference.
64
+
65
+ ## Production deployment: Chrome in Docker
66
+
67
+ The most common point of friction isn't the gem, it's "Chrome isn't in my container image."
68
+ Ferrum needs a real Chrome/Chromium binary on the machine actually rendering PDFs. For a
69
+ Debian/Ubuntu-based image:
70
+
71
+ ```dockerfile
72
+ RUN apt-get update -qq && apt-get install -y --no-install-recommends \
73
+ chromium \
74
+ && rm -rf /var/lib/apt/lists/*
75
+ ```
76
+
77
+ If your container runs as root (common for a minimal/CI image, uncommon for a real production
78
+ image — prefer a non-root user there if you can), Chrome refuses to start without its sandbox
79
+ disabled:
80
+
81
+ ```ruby
82
+ # config/initializers/html_to_pdf.rb
83
+ HtmlToPdf.configure do |config|
84
+ config.browser_options = { browser_options: { "no-sandbox" => nil } } if ENV["CHROME_NO_SANDBOX"]
85
+ end
86
+ ```
87
+
88
+ Don't set `no-sandbox` unconditionally on a machine where you don't control what else runs — it's
89
+ specifically a root-in-a-throwaway-container accommodation, not a default.
90
+
91
+ ## The asset_host gotcha
92
+
93
+ `HtmlToPdf` writes the rendered view to a temporary `.html` file and points Chrome at it via a
94
+ `file://` URL (so relative paths still have a real base URI to resolve against — see
95
+ [Architecture](architecture.md#request-lifecycle)). A stylesheet tag that emits a root-relative URL
96
+ (`/assets/application-abcd1234.css`, Rails' normal default) resolves against that `file://` origin
97
+ to `file:///assets/application-abcd1234.css` — which doesn't exist — and the CSS silently fails to
98
+ load. The PDF comes out completely unstyled, with no error anywhere.
99
+
100
+ The fix is `config.asset_host`, set to something that actually resolves over real HTTP. Hardcoding
101
+ a host only works until someone runs the app on a different port, so derive it from the request
102
+ instead:
103
+
104
+ ```ruby
105
+ # config/environments/development.rb
106
+ config.asset_host = Proc.new { |_source, request| request ? "#{request.scheme}://#{request.host_with_port}" : nil }
107
+ ```
108
+
109
+ Production usually already has a real `asset_host` pointed at a CDN for unrelated reasons — if
110
+ yours doesn't yet, this is exactly the same fix, just pointed at your own domain instead of
111
+ `localhost`.
data/docs/security.md ADDED
@@ -0,0 +1,81 @@
1
+ # Security model
2
+
3
+ [← Back to README](../README.md)
4
+
5
+ `pdf_options` is typically read straight from request params (`params[:pdf_options]`) — the README
6
+ example passes it through a `link_to` query string. That means anything accepted under
7
+ `pdf_options[:print_options]` is, by default, something an unauthenticated caller can set on any
8
+ action that exposes the `pdf` format. The design goal is that the worst a caller can do with that
9
+ is make an ugly PDF, never run arbitrary code or reach another host.
10
+
11
+ ## What's safe by construction
12
+
13
+ Every `print_options` key except two (below) is a validated number, boolean, or enum:
14
+
15
+ - `format` is checked against a fixed table of named paper sizes (`PrintOptions::PAPER_SIZES`) and
16
+ raises `ArgumentError` on anything else — it can't be used to inject arbitrary strings into
17
+ Chrome's protocol layer.
18
+ - `margin_*`/`paper_width`/`paper_height` go through `PrintOptions.to_inches`, which only accepts a
19
+ number or a `number + unit` string (`mm`/`cm`/`in`/`px`) — again, `ArgumentError` on anything else.
20
+ - `landscape`, `print_background`, `display_header_footer`, `prefer_css_page_size` are passed
21
+ straight through to Chrome's `Page.printToPDF`, which itself only accepts booleans for these.
22
+
23
+ This is a meaningfully narrower surface than the gem's wkhtmltopdf-based predecessor, which
24
+ forwarded `wkhtmltopdf_options` as literal CLI flags to a subprocess — any key a caller supplied
25
+ became an actual argv entry to a binary. `print_options` is a fixed hash of typed values sent over
26
+ the Chrome DevTools Protocol; there's no argv for a caller to inject into.
27
+
28
+ ## The one exception: header_template / footer_template
29
+
30
+ These two are raw HTML — Chrome renders them as-is inside the printed header/footer. Unlike every
31
+ other key, there's no validation that could make an arbitrary string safe here: a caller who
32
+ controls `header_template` can embed anything, including
33
+ `<img src="http://169.254.169.254/latest/meta-data/...">` or any other internal-network URL,
34
+ and the Chrome process (which has real network access) will fetch it — a server-side request
35
+ forgery vector, not merely an HTML-injection one.
36
+
37
+ Because of that, `header_template`/`footer_template` are **stripped from request-supplied
38
+ `print_options` by default**. They're only honored from `config.default_options` — Ruby code you
39
+ wrote, not request input:
40
+
41
+ ```ruby
42
+ # config/initializers/html_to_pdf.rb — this is fine, it's your own code
43
+ HtmlToPdf.configure do |config|
44
+ config.default_options = {
45
+ header_template: "<span></span>",
46
+ footer_template: "<div style='font-size:10px;text-align:center;'><span class=\"pageNumber\"></span></div>"
47
+ }
48
+ end
49
+ ```
50
+
51
+ If you genuinely need a caller to control the header/footer HTML — and have your own separate
52
+ sanitization in place before `pdf_options` ever reaches `html_to_pdf` — that's what
53
+ `config.allow_request_templates` is for:
54
+
55
+ ```ruby
56
+ # Only do this if pdf_options never flows from unfiltered request params in your app.
57
+ HtmlToPdf.configure { |config| config.allow_request_templates = true }
58
+ ```
59
+
60
+ Default is `false`. Leave it there unless you've specifically reviewed what feeds `pdf_options` in
61
+ your app.
62
+
63
+ ## javascript_delay has a ceiling
64
+
65
+ `render_pdf` sleeps for `javascript_delay` milliseconds between page load and capture, while
66
+ holding the pooled browser's page/context open (see [Architecture](architecture.md#browserpool)).
67
+ Without a cap, one caller passing an enormous value would stall every other PDF render sharing that
68
+ browser for however long they chose — a single-request denial of service against a resource every
69
+ thread in the process shares. `config.max_javascript_delay_ms` (default `10_000`, i.e. 10 seconds)
70
+ clamps it; raise it only if you trust whatever can set `javascript_delay` in your app.
71
+
72
+ ## The baseline that doesn't change
73
+
74
+ None of the above touches the actual page content itself: `html_to_pdf` renders your view's own
75
+ HTML, exactly as your app would render it for a normal request. If that view embeds
76
+ caller-controlled content unescaped, or makes requests to caller-controlled URLs, that's the same
77
+ risk any server-rendered HTML page has — PDF conversion doesn't add to it, but it doesn't take it
78
+ away either. The guidance already in the README stands: don't expose the `pdf` format on an action
79
+ where you wouldn't be comfortable letting a caller influence layout, and don't pass
80
+ request-controlled values into `pdf_options` on a publicly reachable action without thinking about
81
+ what in there is actually safe to expose.
data/htmlToPdf.gemspec CHANGED
@@ -8,8 +8,8 @@ Gem::Specification.new do |spec|
8
8
  spec.version = HtmlToPdf::VERSION
9
9
  spec.authors = ["Ram Laxman Yadav"]
10
10
  spec.email = ["yadavramlaxman@gmail.com"]
11
- spec.summary = %q{Render Rails controller actions to PDF using wkhtmltopdf}
12
- spec.description = %q{HtmlToPdf lets any Rails controller action be downloaded as a PDF. Request the action with a PDF format and the gem renders the view exactly as the browser would, then converts it to PDF via wkhtmltopdf. Supports Rails 7.x and 8.x.}
11
+ spec.summary = %q{Render Rails controller actions to PDF using headless Chrome}
12
+ spec.description = %q{HtmlToPdf lets any Rails controller action be downloaded as a PDF. Request the action with a PDF format and the gem renders the view exactly as the browser would, then converts it to PDF via a pooled headless Chrome instance (through the ferrum gem). Supports Rails 7.x and 8.x.}
13
13
  spec.homepage = "https://github.com/ramlaxmanyadav/htmlToPdf"
14
14
  spec.license = "MIT"
15
15
  spec.required_ruby_version = ">= 3.1"
@@ -17,6 +17,20 @@ Gem::Specification.new do |spec|
17
17
  spec.files = `git ls-files -z`.split("\x0")
18
18
  spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
19
 
20
- spec.add_development_dependency "bundler", ">= 2.4"
21
- spec.add_development_dependency "rake", ">= 13.0"
20
+ spec.add_dependency "ferrum", ">= 0.13", "< 1.0"
21
+ spec.add_dependency "activesupport", ">= 7.0", "< 9"
22
+
23
+ spec.metadata["source_code_uri"] = spec.homepage
24
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/master/CHANGELOG.md"
25
+ spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
26
+ spec.metadata["documentation_uri"] = "https://ramlaxmanyadav.github.io/htmlToPdf/"
27
+ spec.metadata["rubygems_mfa_required"] = "true"
28
+
29
+ spec.add_development_dependency "bundler", ">= 2.4", "< 5"
30
+ spec.add_development_dependency "rake", ">= 13.0", "< 14"
31
+ spec.add_development_dependency "rspec", "~> 3.13"
32
+ spec.add_development_dependency "rspec-rails", "~> 7.0"
33
+ spec.add_development_dependency "combustion", "~> 1.5"
34
+ spec.add_development_dependency "rails", ">= 7.0", "< 9"
35
+ spec.add_development_dependency "puma", ">= 5.0", "< 7"
22
36
  end
@@ -0,0 +1,50 @@
1
+ require "ferrum"
2
+
3
+ module HtmlToPdf
4
+ # Keeps a single headless Chrome process alive per Ruby process (starting
5
+ # Chrome per-request costs several hundred ms) and hands out a fresh,
6
+ # isolated browser context per conversion so pages never share cookies or
7
+ # state. Automatically restarts the browser once if Chrome has crashed or
8
+ # stopped responding.
9
+ module BrowserPool
10
+ MUTEX = Mutex.new
11
+
12
+ RECOVERABLE_ERRORS = [
13
+ Ferrum::DeadBrowserError,
14
+ Ferrum::ProcessTimeoutError,
15
+ Ferrum::TimeoutError,
16
+ Ferrum::NoSuchPageError
17
+ ].freeze
18
+
19
+ module_function
20
+
21
+ def with_page
22
+ attempts = 0
23
+ begin
24
+ attempts += 1
25
+ # The block form is required: it's the only way Ferrum disposes the
26
+ # browser context afterward. Closing just the page (as a bare
27
+ # `create_page(new_context: true)` + `page.close` would) leaks a
28
+ # context per call, which adds up fast in a long-lived pooled browser.
29
+ browser.create_page(new_context: true) { |page| yield page }
30
+ rescue *RECOVERABLE_ERRORS
31
+ reset!
32
+ retry if attempts < 2
33
+ raise
34
+ end
35
+ end
36
+
37
+ def browser
38
+ MUTEX.synchronize do
39
+ @browser ||= Ferrum::Browser.new(**HtmlToPdf.configuration.browser_options)
40
+ end
41
+ end
42
+
43
+ def reset!
44
+ MUTEX.synchronize do
45
+ @browser&.quit
46
+ @browser = nil
47
+ end
48
+ end
49
+ end
50
+ end
@@ -1,12 +1,37 @@
1
1
  module HtmlToPdf
2
2
  class Configuration
3
- attr_accessor :wkhtmltopdf_path, :default_layout, :default_options, :tmp_dir
3
+ # Hash of keyword options passed straight to Ferrum::Browser.new, e.g.
4
+ # { browser_path: '/usr/bin/chromium', timeout: 20, process_timeout: 30,
5
+ # browser_options: { 'no-sandbox' => nil } } (the last is for running as
6
+ # root in Docker). Leave empty to let Ferrum auto-detect a local Chrome/
7
+ # Chromium install.
8
+ attr_accessor :browser_options
9
+
10
+ attr_accessor :default_layout, :default_options, :tmp_dir
11
+
12
+ # header_template/footer_template are raw HTML that Chrome renders
13
+ # as-is — unlike every other print option, they're not a validated
14
+ # number/enum/boolean, so letting a request control them lets a caller
15
+ # embed arbitrary markup (e.g. <img src="http://internal-host/...">,
16
+ # an SSRF vector against your network from the Chrome process). They're
17
+ # only honored from config.default_options (trusted application code)
18
+ # unless this is explicitly enabled. Only set this if pdf_options never
19
+ # flows from unfiltered request params in your app.
20
+ attr_accessor :allow_request_templates
21
+
22
+ # Upper bound (ms) on pdf_options[:print_options][:javascript_delay].
23
+ # render_pdf sleeps for this long while holding the pooled browser's
24
+ # page/context open, so an uncapped caller-supplied value would let one
25
+ # request stall every other PDF render sharing that browser.
26
+ attr_accessor :max_javascript_delay_ms
4
27
 
5
28
  def initialize
6
- @wkhtmltopdf_path = 'wkhtmltopdf'
7
- @default_layout = 'application'
8
- @default_options = {}
9
- @tmp_dir = nil
29
+ @browser_options = {}
30
+ @default_layout = 'application'
31
+ @default_options = { print_background: true }
32
+ @tmp_dir = nil
33
+ @allow_request_templates = false
34
+ @max_javascript_delay_ms = 10_000
10
35
  end
11
36
  end
12
37
 
@@ -0,0 +1,122 @@
1
+ require "active_support/core_ext/object/blank"
2
+
3
+ module HtmlToPdf
4
+ # Normalizes the Hash passed as pdf_options[:print_options] (plus the
5
+ # default_options configured globally and the :padding shorthand) into the
6
+ # keyword arguments Ferrum::Page#pdf / Chrome's Page.printToPDF expect.
7
+ #
8
+ # Chrome's print options are a fixed, narrow set of layout flags (margins,
9
+ # paper size, scale, etc.) rather than arbitrary CLI arguments, so this
10
+ # can't be used to inject arbitrary binary flags even when pdf_options
11
+ # comes from params. header_template/footer_template are the one exception
12
+ # — see TEMPLATE_KEYS below — and are filtered out of request-supplied
13
+ # options unless explicitly allowed.
14
+ module PrintOptions
15
+ SIZE_KEYS = %i[paper_width paper_height margin_top margin_bottom margin_left margin_right].freeze
16
+
17
+ # Matches Chrome's own predefined paper sizes (inches).
18
+ PAPER_SIZES = {
19
+ 'letter' => [8.50, 11.00],
20
+ 'legal' => [8.50, 14.00],
21
+ 'tabloid' => [11.00, 17.00],
22
+ 'ledger' => [17.00, 11.00],
23
+ 'a0' => [33.10, 46.80],
24
+ 'a1' => [23.40, 33.10],
25
+ 'a2' => [16.54, 23.40],
26
+ 'a3' => [11.70, 16.54],
27
+ 'a4' => [8.27, 11.70],
28
+ 'a5' => [5.83, 8.27],
29
+ 'a6' => [4.13, 5.83]
30
+ }.freeze
31
+
32
+ UNIT_TO_INCHES = { 'in' => 1.0, 'cm' => 1 / 2.54, 'mm' => 1 / 25.4, 'px' => 1 / 96.0 }.freeze
33
+
34
+ # Raw HTML Chrome renders as-is, unlike every other print option. Only
35
+ # trusted (config.default_options) by default — see
36
+ # Configuration#allow_request_templates.
37
+ TEMPLATE_KEYS = %i[header_template footer_template].freeze
38
+
39
+ module_function
40
+
41
+ # Returns [cdp_options, javascript_delay_in_ms]. javascript_delay isn't a
42
+ # Chrome print flag (Page.printToPDF has no such parameter and rejects
43
+ # unknown ones) — it's our own convenience, applied as a pause between
44
+ # page load and capture, for pages that render content asynchronously.
45
+ #
46
+ # Precedence, highest first: pdf_options[:print_options], then
47
+ # pdf_options[:padding], then config.default_options. default_options and
48
+ # print_options are each resolved (legacy aliases/format) independently
49
+ # before merging, so e.g. a gem-wide default :format can't clobber an
50
+ # explicit per-request :paper_width/:paper_height, and a gem-wide default
51
+ # margin doesn't block a per-request :padding from overriding it.
52
+ def build(pdf_options)
53
+ defaults = resolve_source(HtmlToPdf.configuration.default_options)
54
+ request_options = resolve_source(pdf_options[:print_options] || {})
55
+ strip_request_templates!(request_options)
56
+ apply_padding!(request_options, pdf_options[:padding])
57
+
58
+ options = defaults.merge(request_options)
59
+ convert_sizes!(options)
60
+ javascript_delay = clamp_javascript_delay(options.delete(:javascript_delay))
61
+ [options, javascript_delay]
62
+ end
63
+
64
+ def resolve_source(hash)
65
+ options = underscore_keys(hash)
66
+ apply_format!(options)
67
+ options
68
+ end
69
+
70
+ def underscore_keys(hash)
71
+ hash.each_with_object({}) { |(key, value), acc| acc[key.to_s.tr('-', '_').to_sym] = value }
72
+ end
73
+
74
+ def strip_request_templates!(request_options)
75
+ return if HtmlToPdf.configuration.allow_request_templates
76
+
77
+ TEMPLATE_KEYS.each { |key| request_options.delete(key) }
78
+ end
79
+
80
+ def clamp_javascript_delay(value)
81
+ return nil unless value
82
+
83
+ [[value.to_i, 0].max, HtmlToPdf.configuration.max_javascript_delay_ms].min
84
+ end
85
+
86
+ def apply_padding!(options, padding)
87
+ return if padding.blank?
88
+
89
+ %i[margin_top margin_bottom margin_left margin_right].each do |key|
90
+ options[key] = padding unless options.key?(key)
91
+ end
92
+ end
93
+
94
+ def apply_format!(options)
95
+ format = options.delete(:format)
96
+ return unless format
97
+
98
+ dimensions = PAPER_SIZES[format.to_s.downcase]
99
+ raise ArgumentError, "Unknown paper format: #{format.inspect}" unless dimensions
100
+
101
+ options[:paper_width], options[:paper_height] = dimensions
102
+ end
103
+
104
+ def convert_sizes!(options)
105
+ SIZE_KEYS.each do |key|
106
+ next unless options.key?(key)
107
+
108
+ options[key] = to_inches(options[key])
109
+ end
110
+ end
111
+
112
+ def to_inches(value)
113
+ return value.to_f if value.is_a?(Numeric)
114
+
115
+ match = value.to_s.strip.match(/\A(-?[\d.]+)\s*(in|cm|mm|px)?\z/i)
116
+ raise ArgumentError, "Unrecognized size value: #{value.inspect}" unless match
117
+
118
+ number, unit = match.captures
119
+ number.to_f * UNIT_TO_INCHES.fetch(unit&.downcase || 'in')
120
+ end
121
+ end
122
+ end
@@ -1,3 +1,3 @@
1
1
  module HtmlToPdf
2
- VERSION = "2.0.0"
2
+ VERSION = "3.0.0"
3
3
  end