Skip to content

Commit

Permalink
- 0.5.3
Browse files Browse the repository at this point in the history
    - **BUGFIX** introduced in 0.5.0 when certain shells don't accept
      `[foo=bar, …]` parameters which should correctly be `[foo, bar]`
      Thanks to [@egze](https://github.com/egze) for submitting a patch.
    - Refactored `PathAgent` that holds configuration state for readability and
      more fashionable and extensible error messages. Extensible towards new
      generators.
    - Updated README to be more elaborative on how to install `wkhtmltopdf` and
      `chrome-headless-render-pdf`
  • Loading branch information
gutschilla committed Apr 30, 2019
2 parents d6f4453 + 7d586ed commit 645d165
Show file tree
Hide file tree
Showing 6 changed files with 118 additions and 43 deletions.
19 changes: 19 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,23 @@
# Changes

- 0.5.3
- **BUGFIX** introduced in 0.5.0 when certain shells don't accept
`["foo=bar", …]` parameters which should correctly be `["foo", "bar"]`
Thanks to [@egze](https://github.com/egze) for submitting a patch.
- Refactored `PathAgent` that holds configuration state for readability and
more fashionable and extensible error messages. Extensible towards new
generators.
- Updated README to be more elaborative on how to install `wkhtmltopdf` and
`chrome-headless-render-pdf`
- 0.5.2
- **BUGFIX** introduced in 0.5.0 when global options to wkhtmltopdf weren't
accepted any more due to wrong shell parameter order. Thanks to
[manukall](https://github.com/manukall) for reporting.
- 0.5.1
- allow chrome to be executed as root via default config option
`disable_chrome_sandbox` – this is required for an easy usage within a
docker container as in
[elixir-pdf-server](https://github.com/gutschilla/elixir-pdf-server)
- 0.5.0
- **Got rid of Porcelain** dependency as it interferes with many builds using
plain `System.cmd/3`. Please note, that as of the documentation
Expand Down
89 changes: 61 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# elixir-pdf-generator

A wrapper for both wkhtmltopdf and chrome-headless plus PDFTK (adds in
encryption) for use in Elixir projects. If available, it will use xvfb-run (x
encryption) for use in Elixir projects.

If available, it will use xvfb-run (x
virtual frame buffer) to use wkhtmltopdf on systems that have no X installed,
e.g. a server.

Expand Down Expand Up @@ -33,53 +35,84 @@ e.g. a server.
- Refactored `PathAgent` that holds configuration state for readability and
more fashionable and extensible error messages. Extensible towards new
generators.
- Updated README to be more elaborative on how to install `wkhtmltopdf` and
`chrome-headless-render-pdf`

For a proper changelog, see [CHANGES](CHANGES.md)

# System prerequisites (either wkhtmltopdf or nodejs and maybe pdftk)
# System prerequisites

It's either

* wkhtmltopdf or

* nodejs and possibly chrome/chromium

## chrome-headless

1. Run `npm install`. This requires [nodejs](https://nodejs.org), of course.
This will install a recent chromium and chromedriver to run Chrome in
headless mode and use this browser and its API to print PDFs.
This will allow you to make more use of Javascript and advanced CSS as it's just
your Chrome/Chromium browser rendering your web page as HTML and printing it as
PDF. Rendering _tend_ to be a bit faster than with wkhtmltopdf. The price tag is
that PDFs printed with chrome/chromium are usually considerably bigger than
those generated with wkhtmltopdf.

1. Run `npm -g install chrome-headless-render-pdf puppeteer`.

This requires [nodejs](https://nodejs.org), of course. This will install a
recent chromium and chromedriver to run Chrome in headless mode and use this
browser and its API to print PDFs globally on your machine.

If you prefer a project-local install, just use `npm install` This will
install dependencies under `./node_modules`. Be aware that those won't be
packaged in your distribution (I will add support for this later).

On some machines, this doesn't install Chromium and fails. Here's how to get
this running on Ubtunu 18:

```
DEBIAN_FRONTEND=noninteractive PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=TRUE \
apt-get install -y chromium-chromedriver \
&& npm -g install chrome-headless-render-pdf puppeteer
```

## wkhtmltopdf

2. Download wkhtmltopdf and place it in your $PATH. Current binaries can be
found here: http://wkhtmltopdf.org/downloads.html

D
For the impatient:

* _(optional)_ To use wkhtmltopdf on systems without an X window server
installed, please install `xvfb-run` from your repository (on
Debian/Ubuntu: `sudo apt-get install xvfb`).
```
apt-get -y install xfonts-base xfonts-75dpi \
&& wget https://downloads.wkhtmltopdf.org/0.12/0.12.5/wkhtmltox_0.12.5-1.bionic_amd64.deb \
&& dpkg -i wkhtmltox_0.12.5-1.bionic_amd64.deb
```

* On current (2018) Macintosh computers `/usr/X11/bin/xvfb` should be
available and is reported to do the same thing. _warning:** This is untested.
PLS report to me if you ran this successfully on a Mac.
3. _optional:_ Install `xvfb` (shouldn't be required with the binary mentioned above):

**AND MAYBE**
To use other wkhtmltopdf executables comiled with an unpatched Qt on systems
without an X window server installed, please install `xvfb-run` from your
repository (on Debian/Ubuntu: `sudo apt-get install xvfb`).

I haven't heard any feedback of people using this feature since a while since
the wkhtmltopdf projects ships ready-made binaries. I will deprecate this
starting in `0.6.0` since, well, YAGNI.

3. _(optional)_ Install pdftk via your package manager or homebrew. The project
page also contains a Windows installer
4. _optional:_ Install `pdftk` via your package manager or homebrew. The project
page also contains a Windows installer. On Debian/Ubuntu just type:
`apt-get -y install pdftk`

# Usage

Add this to your dependencies in your mix.exs:

def application do
[applications: [
:logger,
:logger,
:pdf_generator # <-- add this
]]
end

defp deps do
[
# ... whatever else
Expand All @@ -95,7 +128,7 @@ $ iex -S mix
html = "<html><body><p>Hi there!</p></body></html>"
# be aware, this may take a while...
{:ok, filename} = PdfGenerator.generate(html, page_size: "A5")
{:ok, pdf_content} = File.read(filename)
{:ok, pdf_content} = File.read(filename)
# or, if you prefer methods that raise on error:
filename = PdfGenerator.generate!(html, generator: :chrome)
Expand Down Expand Up @@ -125,19 +158,19 @@ pdf_binary = PdfGenerator.generate_binary! "<html>..."

This module will automatically try to finde both `wkhtmltopdf` and `pdftk` in
your path. But you may override or explicitly set their paths in your
`config/config.exs`.
`config/config.exs`.

```
config :pdf_generator,
wkhtml_path: "/usr/bin/wkhtmltopdf", # <-- this program actually does the heavy lifting
pdftk_path: "/usr/bin/pdftk" # <-- only needed for PDF encryption
```

or, if you prefer shrome-headless

```
config :pdf_generator,
use_chrome: true # <-- will be default by 0.6.0
use_chrome: true # <-- will be default by 0.6.0
pdftk_path: "/usr/bin/pdftk" # <-- only needed for PDF encryption
```

Expand All @@ -150,7 +183,7 @@ an X Window server, but your server (or Mac) does not have one installed,
you may find the `command_prefix` handy:

```
PdfGenerator.generate "<html..", command_prefix: "xvfb-run"
PdfGenerator.generate "<html..", command_prefix: "xvfb-run"
```

This can also be configured globally in your `config/config.exs`:
Expand All @@ -174,20 +207,20 @@ config :pdf_generator,

- `filename` - filename for the output pdf file (without .pdf extension, defaults to a random string)

- `page_size`:
* defaults to `A4`, see `wkhtmltopdf` for more options
- `page_size`:
* defaults to `A4`, see `wkhtmltopdf` for more options
* A4 will be translated to `page-height 11` and `page-width 8.5` when
chrome-headless is used

- `open_password`: requires `pdftk`, set password to encrypt PDFs with

- `edit_password`: requires `pdftk`, set password for edit permissions on PDF

- `shell_params`: pass custom parameters to `wkhtmltopdf`. **CAUTION: BEWARE OF SHELL INJECTIONS!**
- `shell_params`: pass custom parameters to `wkhtmltopdf`. **CAUTION: BEWARE OF SHELL INJECTIONS!**

- `command_prefix`: prefix `wkhtmltopdf` with some command or a command with options
(e.g. `xvfb-run -a`, `sudo` ..)

- `delete_temporary`: immediately remove temp files after generation

## Heroku Setup
Expand All @@ -202,7 +235,7 @@ https://github.com/gjaldon/phoenix-static-buildpack
```

__note:__ The list also includes Elixir and Phoenix buildpacks to show you that they
must be placed after `pdftk` and `wkhtmltopdf`. It won't work if you load the
must be placed after `pdftk` and `wkhtmltopdf`. It won't work if you load the
Elixir and Phoenix buildpacks first.

# Documentation
Expand Down
19 changes: 10 additions & 9 deletions lib/pdf_generator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -137,14 +137,14 @@ defmodule PdfGenerator do
:ok <- maybe_write_html(content, html_file),
{executable, arguments} <- make_command(generator, options, content, {html_file, pdf_file}),
{:cmd, {stderr, exit_code}} <- {:cmd, System.cmd(executable, arguments, stderr_to_stdout: true)}, # unfortuantely wkhtmltopdf returns 0 on errors as well :-/
{:result_ok, true, stderr} <- {:result_ok, result_ok(generator, stderr, exit_code), stderr}, # so we inspect stderr instead
{:result_ok, true, _err} <- {:result_ok, result_ok(generator, stderr, exit_code), stderr}, # so we inspect stderr instead
{:rm, :ok} <- {:rm, maybe_delete_temp(delete_temp, html_file)},
{:ok, encrypted_pdf} <- maybe_encrypt_pdf(pdf_file, open_password, edit_password) do
{:ok, encrypted_pdf}
else
{:error, reason} -> {:error, reason}
{:result_ok, _, stderr} -> {:error, {:generator_failed, stderr}}
reason -> {:error, reason}
{:error, reason} -> {:error, reason}
{:result_ok, _, err} -> {:error, {:generator_failed, err}}
reason -> {:error, reason}
end
end

Expand Down Expand Up @@ -174,16 +174,17 @@ defmodule PdfGenerator do

@spec make_command(generator, opts, content, {html_path, pdf_path}) :: {path, list()}
def make_command(:chrome, options, content, {html_path, pdf_path}) do
executable_on_path = System.find_executable("chrome-headless-render-pdf")
nodejs_on_path = System.find_executable("nodejs")
chrome_executable = PdfGenerator.PathAgent.get.chrome_path
node_executable = PdfGenerator.PathAgent.get.node_path
disable_sandbox = Application.get_env(:pdf_generator, :disable_chrome_sandbox) || options[:no_sandbox]
# FIXME: this won't work in releases
js_file = Application.app_dir(:pdf_generator) <> "/../../../../node_modules/chrome-headless-render-pdf/dist/cli/chrome-headless-render-pdf.js"

{executable, executable_args} =
if options[:prefer_system_executable] && is_binary(executable_on_path) do
{executable_on_path, []}
if options[:prefer_system_executable] && is_binary(chrome_executable) do
{chrome_executable, []}
else
{nodejs_on_path, [js_file]}
{node_executable, [js_file]}
end

{width, height} = make_dimensions(options)
Expand Down
27 changes: 22 additions & 5 deletions lib/pdf_generator_path_agent.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,18 @@ defmodule PdfGenerator.PathAgent do
require Logger
defstruct [
wkhtml_path: nil,
pdftk_path: nil,
chrome: nil,
pdftk_path: nil,
chrome_path: nil,
node_path: nil,
]

@moduledoc """
Will check for system executables at startup time and store paths. If
configured as such, will raise an error when no usable executable was found.
The idea is to perform this kind of auto-configuration at startup and then
store the values in this agent. The same could probably be achieved with
Application.put_env/3. Comments on this welcome. Until then, this just owrks.
"""

@name __MODULE__
Expand All @@ -24,6 +29,7 @@ defmodule PdfGenerator.PathAgent do
wkhtml_path: System.find_executable("wkhtmltopdf"),
pdftk_path: System.find_executable("pdftk"),
chrome_path: System.find_executable("chrome-headless-render-pdf"),
node_path: System.find_executable("nodejs") || System.find_executable("node"),
]
++ paths_from_options
|> Enum.dedup()
Expand All @@ -43,6 +49,15 @@ defmodule PdfGenerator.PathAgent do
Agent.get( @name, fn( data ) -> data end )
end

@doc """
Checks options for generator binaries (chrome-headless-render-pdf and
wkhtmltopdf) exists on path. Raises an error if corresponding options are set
to true:
- :raise_on_missing_wkhtmltopdf_binary
- :raise_on_missing_chrome_binary
- :raise_on_missing_binaries -> raises on eiter binary missing
"""
def raise_or_continue(options) do
wkhtml_exists = File.exists?(options[:wkhtml_path])
chrome_exists = File.exists?(options[:chrome_path])
Expand All @@ -54,11 +69,13 @@ defmodule PdfGenerator.PathAgent do
maybe_raise(:wkhtml, raise_on_wkhtml_missing, wkhtml_exists)
maybe_raise(:chrome, raise_on_chrome_missing, chrome_exists)
maybe_raise(:any, raise_on_any_missing, wkhtml_exists or chrome_exists)

options
end

defp maybe_raise(generator, config_says_raise = true, wkhtml_exists = false), do: generator |> missing_message() |> raise()
defp maybe_raise(generator, config_says_raise = false, wkhtml_exists = false), do: generator |> missing_message() |> Logger.warn()
defp maybe_raise(generator, config_says_raise = _, wkhtml_exists = _ ), do: :noop
defp maybe_raise(generator, _config_says_raise = true, _executable_exists = false), do: generator |> missing_message() |> raise()
defp maybe_raise(generator, _config_says_raise = false, _executable_exists = false), do: generator |> missing_message() |> Logger.warn()
defp maybe_raise(_generator, _config_says_raise = _, _executable_exists = _ ), do: :noop

defp missing_message(:wkhtml), do: "wkhtmltopdf executable was not found on your system"
defp missing_message(:chrome), do: "chrome-headless-render-pdf executable was not found on your system"
Expand Down
2 changes: 1 addition & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ defmodule PdfGenerator.Mixfile do
[
app: :pdf_generator,
name: "PDF Generator",
version: "0.5.2",
version: "0.5.3",
elixir: ">= 1.1.0",
deps: deps(),
description: description(),
Expand Down
5 changes: 5 additions & 0 deletions test/pdf_generator_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ defmodule PdfGeneratorTest do
assert result |> File.read! |> String.slice(0, 6) == "%PDF-1"
end

test "chrome's no-sandbox option doesn't crash" do
{_status, result} = PdfGenerator.generate({:url, "http://google.com"}, generator: :chrome, disable_chrome_sandbox: true)
assert result |> File.read! |> String.slice(0, 6) == "%PDF-1"
end

test "generate! returns a filename" do
@html
|> PdfGenerator.generate!
Expand Down

0 comments on commit 645d165

Please sign in to comment.