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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +39 -0
- data/.github/workflows/release.yml +39 -0
- data/.gitignore +9 -0
- data/.rspec +3 -0
- data/CHANGELOG.md +48 -0
- data/Gemfile.lock +313 -5
- data/README.md +100 -19
- data/Rakefile +4 -0
- data/config.ru +9 -0
- data/docs/architecture.md +99 -0
- data/docs/contributing.md +60 -0
- data/docs/rails-integration.md +111 -0
- data/docs/security.md +81 -0
- data/htmlToPdf.gemspec +18 -4
- data/lib/htmlToPdf/browser_pool.rb +50 -0
- data/lib/htmlToPdf/configuration.rb +30 -5
- data/lib/htmlToPdf/print_options.rb +122 -0
- data/lib/htmlToPdf/version.rb +1 -1
- data/lib/htmlToPdf.rb +67 -60
- data/spec/htmlToPdf/browser_pool_spec.rb +89 -0
- data/spec/htmlToPdf/configuration_spec.rb +51 -0
- data/spec/htmlToPdf/print_options_spec.rb +154 -0
- data/spec/integration/controller_spec.rb +75 -0
- data/spec/internal/app/controllers/application_controller.rb +3 -0
- data/spec/internal/app/controllers/booms_controller.rb +16 -0
- data/spec/internal/app/controllers/widgets_controller.rb +25 -0
- data/spec/internal/app/views/layouts/application.html.erb +4 -0
- data/spec/internal/app/views/layouts/pdf.html.erb +4 -0
- data/spec/internal/app/views/widgets/show.html.erb +2 -0
- data/spec/internal/config/routes.rb +6 -0
- data/spec/internal/log/.gitignore +1 -0
- data/spec/internal/public/favicon.ico +0 -0
- data/spec/spec_helper.rb +31 -0
- metadata +187 -10
data/Rakefile
CHANGED
data/config.ru
ADDED
|
@@ -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
|
|
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
|
|
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.
|
|
21
|
-
spec.
|
|
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
|
-
|
|
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
|
-
@
|
|
7
|
-
@default_layout
|
|
8
|
-
@default_options
|
|
9
|
-
@tmp_dir
|
|
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
|
data/lib/htmlToPdf/version.rb
CHANGED