defmodule PdfGenerator do @vsn "0.5.0" @moduledoc """ # PdfGenerator Provides a simple wrapper around [wkhtmltopdf](http://wkhtmltopdf.org) and [pdftk](https://www.pdflabs.com/tools/pdftk-the-pdf-toolkit/) to generate possibly encrypted PDFs from an HTML source. # Configuration (optional) if no or partial configuration is given, PdfGenerator will search for executables on path. This will rais an error when wkhtmltopdf cannot be found. config :pdf_generator, wkhtml_path: "/path/to/wkhtmltopdf", pdftk_path: "/path/to/pdftk", In your config/config.exs. Add :pdf_generator to your mix.exs: Note that this is optional but advised to as it will perform a check on startup whether it can find a suitable wkhtmltopdf executable. It's generally better to have an app fail at startup than at later runtime. def application do [applications: [ .., :pdf_generator, ..], .. ] end If you don't want to autostart, issue PdfGenerator.start wkhtml_path: "/path/to/wkhtml_path" # System requirements - wkhtmltopdf or chrome-headless - pdftk (optional, for encrypted PDFs) Precompiled **wkhtmltopdf** binaries can be obtained here: http://wkhtmltopdf.org/downloads.html **pdftk** should be available as package on your system via - `apt-get install pdftk` on Debian/Ubuntu - `brew pdftk` on OSX (you'll need homebrew, of course) - Install the Exe-Installer on Windows found the project's homepage (link above) """ use Application # See http://elixir-lang.org/docs/stable/elixir/Application.html # for more information on OTP Applications def start(_type, _args) do import Supervisor.Spec, warn: false children = [ # Define workers and child supervisors to be supervised # worker(TestApp.Worker, [arg1, arg2, arg3]) worker( PdfGenerator.PathAgent, [[ wkhtml_path: Application.get_env(:pdf_generator, :wkhtml_path), pdftk_path: Application.get_env(:pdf_generator, :pdftk_path), raise_on_missing_wkhtmltopdf_binary: Application.get_env(:pdf_generator, :raise_on_missing_wkhtmltopdf_binary, true), ]] ) ] opts = [strategy: :one_for_one, name: PdfGenerator.Supervisor] Supervisor.start_link(children, opts) end def defaults(), do: [generator: :wkhtmltopdf, page_size: "A4"] # return file name of generated pdf @doc """ Generates a pdf file from given html string. Returns a string containing a temporary file path for that PDF. ## Options * `:page_size` - output page size, defaults to "A4" * `:open_password` - password required to open PDF. Will apply encryption to PDF * `:edit_password` - password required to edit PDF * `:shell_params` - list of command-line arguments to wkhtmltopdf see http://wkhtmltopdf.org/usage/wkhtmltopdf.txt for all options * `:delete_temporary` - true to remove the temporary html generated in the system tmp dir * `:filename` - filename you want for the output PDF (provide without .pdf extension), defaults to a random string # Examples pdf_path_1 = PdfGenerator.generate "

Boom

" pdf_path_2 = PdfGenerator.generate( "

Boom

", page_size: "A5", open_password: "secret", edit_password: "g3h31m", shell_params: [ "--outline", "--outline-depth3", "3" ], delete_temporary: true, filename: "my_awesome_pdf" ) """ @type url :: binary() @type html :: binary() @type pdf_file_path :: binary() @type content :: html | {:ok, url} @type reason :: atom() | {atom(), any} @type opts :: keyword() @type path :: binary() @type html_path :: path @type pdf_path :: path @type generator :: :wkhtmltopdf | :chrome @spec generate(content, opts) :: {:ok, pdf_file_path} | {:error, reason} def generate(content, opts \\ []) do options = Keyword.merge(defaults(), opts) generator = options[:generator] open_password = options[:open_password] edit_password = options[:edit_password] delete_temp = options[:delete_temporary] with {html_file, pdf_file} <- make_file_paths(options), :ok <- maybe_write_html(content, html_file), {executable, arguments} <- make_command(generator, options, content, {html_file, pdf_file}), {console_stderr, exit_code} <- System.cmd(executable, arguments, stderr_to_stdout: true), # unfortuantely wkhtmltopdf returns 0 on errors as well :-/ {:result_ok, true} <- {:result_ok, result_ok(generator, console_stderr, exit_code)}, # 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} reason -> {:error, reason} end end @spec maybe_write_html(content, path()) :: :ok | {:error, reason} def maybe_write_html({:url, _url}, _html_file_path), do: :ok def maybe_write_html({:html, html}, html_file_path), do: File.write(html_file_path, html) def maybe_write_html(html, html_file_path) when is_binary(html), do: maybe_write_html({:html, html}, html_file_path) @spec make_file_paths(keyword()) :: {html_path, pdf_path} def make_file_paths(options) do filebase = options[:filename] |> generate_filebase() {filebase <> ".html", filebase <> ".pdf"} end def make_dimensions(options) when is_list(options) do options |> Enum.into(%{}) |> dimensions_for() end @doc ~s""" Returns `{width, height}` tuple for page sizes either as given or for A4 and A5. Defaults to A4 sizes. """ def dimensions_for(%{page_width: width, page_height: height}), do: {width, height} def dimensions_for(%{page_size: "A4"}), do: {"8.50", "11.0"} def dimensions_for(%{page_size: "A5"}), do: {"4.25", "5.5"} def dimensions_for(_map), do: dimensions_for(%{page_size: "A4"}) @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") 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, []} else {nodejs_on_path, [js_file]} end {width, height} = make_dimensions(options) more_params = options[:shell_params] || [] source = case content do {:url, url} -> url _html -> "file://" <> html_path end arguments = executable_args ++ [ "--url", source, "--pdf", pdf_path, "--paper-width", width, "--paper-height", height, ] ++ more_params {executable, arguments} # |> IO.inspect() end def make_command(:wkhtmltopdf, options, content, {html_path, pdf_path}) do executable = PdfGenerator.PathAgent.get.wkhtml_path source = case content do {:url, url} -> url _html -> html_path end more_params = options[:shell_params] || [] arguments = [ "--page-size", options[:page_size] || "A4", source, pdf_path ] ++ more_params # for wkhtmltopdf we support prefixes like ["xvfb-run", "-a"] to precede the actual command case get_command_prefix(options) do nil -> {executable, arguments} [prefix | prefix_args] -> {prefix, prefix_args ++ [executable] ++ arguments} prefix -> {prefix, [executable | arguments]} end end defp maybe_delete_temp(true, file), do: File.rm(file) defp maybe_delete_temp(_falsy, _file), do: :ok def maybe_encrypt_pdf(pdf_file, open_password, edit_password) when is_binary(open_password) or is_binary(edit_password) do encrypt_pdf(pdf_file, open_password, edit_password) end def maybe_encrypt_pdf(pdf_file, _open_password, _edit_password) do {:ok, pdf_file} end defp result_ok(:chrome, _string, 0), do: true defp result_ok(:chrome, _string, _exit_code), do: false defp result_ok(:wkhtmltopdf, string, _exit_code), do: String.match?(string, ~r/Done/ms) defp get_command_prefix(options) do options[:command_prefix] || Application.get_env(:pdf_generator, :command_prefix) end defp generate_filebase(nil), do: generate_filebase(PdfGenerator.Random.string()) defp generate_filebase(filename), do: Path.join(System.tmp_dir, filename) def encrypt_pdf(pdf_input_path, user_pw, owner_pw ) do pdftk_path = PdfGenerator.PathAgent.get.pdftk_path pdf_output_file = Path.join System.tmp_dir, PdfGenerator.Random.string() <> ".pdf" pdftk_args = [ pdf_input_path, "output", pdf_output_file, "owner_pw", random_if_undef(owner_pw), "user_pw", random_if_undef(user_pw), "encrypt_128bit", "allow", "Printing", "CopyContents" ] {stderr, exit_code} = System.cmd(pdftk_path, pdftk_args, stderr_to_stdout: true) case exit_code do 0 -> {:ok, pdf_output_file} _ -> {:error, {:pdftk, stderr}} end end defp random_if_undef(nil), do: PdfGenerator.Random.string(16) defp random_if_undef(any), do: any @doc """ Takes same options as `generate` but will return an `{:ok, binary_pdf_content}` tuple. In case option _delete_temporary_ is true, will as well delete the temporary pdf file. """ def generate_binary(html, options \\ []) do result = generate html, options case result do {:ok, filename} -> {:ok, filename |> read_and_maybe_delete(options) } {:error, reason} -> {:error, reason} end end defp read_and_maybe_delete(filename, options) do content = filename |> File.read! if Keyword.get(options, :delete_temporary), do: filename |> File.rm content end @doc """ Same as generate_binary but returns PDF content directly or raises on error. """ def generate_binary!(html, options \\ []) do result = generate_binary html, options case result do {:ok, content} -> content {:error, reason} -> raise "in-place generation failed: " <> reason end end @doc """ Same as generate but returns PDF file name only (raises on error). """ def generate!(html, options \\ []) do result = generate html, options case result do {:ok, filename} -> filename {:error, reason} -> raise "HTML generation failed: " <> reason end end end