lib/chromic_pdf/template.ex

# SPDX-License-Identifier: Apache-2.0

defmodule ChromicPDF.Template do
  @moduledoc """
  Helper functions for page styling.

  For a start, see `source_and_options/1`.

  ## Motivation

  This module contains helper functions that make it easier to to build HTML templates (body,
  header, and footer) that fully cover a given page. Like an adapter, it tries to harmonize
  Chrome's `printToPDF` options and related CSS layout styles (`@page` and friends) with a custom
  set of page sizing options. Using this module is entirely optional, but perhaps can help to
  avoid some common pitfalls arising from the slightly unintuitive and sometimes conflicting
  behaviour of `printToPDF` options and `@page` CSS styles in Chrome.


  ## Page dimensions

  One particularly cumbersome detail is that Chrome in headless mode does not correctly interpret
  the `@page` CSS rule to configure the page dimensions. Resulting PDF files will always be in
  US-letter format unless configured differently with the `paperWidth` and `paperHeight` options.
  Experience has shown, that results will be best if the `@page` rule aligns with the values
  passed to `printToPDF/2`, which is why this module exists to make basic page sizing easier.

  To rotate a page into landscape, use the `landscape` option.
  """

  require EEx

  @type blob :: binary()

  @type content_option ::
          {:content, blob()}
          | {:header, blob()}
          | {:footer, blob()}

  @type paper_size ::
          {float(), float()}
          | :a0
          | :a1
          | :a2
          | :a3
          | :a4
          | :a5
          | :a6
          | :a7
          | :a8
          | :a9
          | :a10
          | :us_letter
          | :legal
          | :tabloid

  @type style_option ::
          {:size, paper_size()}
          | {:header_height, binary()}
          | {:header_font_size, binary()}
          | {:header_zoom, binary()}
          | {:footer_height, binary()}
          | {:footer_font_size, binary()}
          | {:footer_zoom, binary()}
          | {:webkit_print_color_adjust, binary()}
          | {:landscape, boolean()}

  @paper_sizes_in_inch %{
    a0: {33.1, 46.8},
    a1: {23.4, 33.1},
    a2: {16.5, 23.4},
    a3: {11.7, 16.5},
    a4: {8.3, 11.7},
    a5: {5.8, 8.3},
    a6: {4.1, 5.8},
    a7: {2.9, 4.1},
    a8: {2.0, 2.9},
    a9: {1.5, 2.0},
    a10: {1.0, 1.5},
    us_letter: {8.5, 11.0},
    legal: {8.5, 14.0},
    tabloid: {11.0, 17.0}
  }

  @default_paper_name :us_letter
  @default_paper_size Map.fetch!(@paper_sizes_in_inch, @default_paper_name)

  @default_content """
  <style>
    body {
      margin: 1em;
      font-family: sans-serif;
    }

    h1 {
      margin: 1em 0;
      font-size: 22pt;
    }

    h2 {
      margin: 1em 0;
      font-size: 14pt;
    }

    p { font-size: 12pt; }

    pre {
      padding: 1em;
      border: 1px solid grey;
      border-radius: 2px;
      background-color: #faffa3;
      white-space: pre-wrap;
    }
  </style>

  <h1>ChromicPDF</h1>
  <p>Please see documentation at <a href="https://hexdocs.pm/chromic_pdf/ChromicPDF.html">hexdocs.pm</a></p>

  <h2>User Agent</h2>
  <pre id="user-agent"></pre>

  <script type="text/javascript">
  window.onload = function() {
    var browser, userAgent = navigator.userAgent;
    document.getElementById('user-agent').innerHTML = userAgent;
  };
  </script>
  """

  @doc """
  Returns source and options for a PDF to be printed, a given set of template options. The return
  value can be passed to `ChromicPDF.print_to_pdf/2`.

  ## Options

  * `header`
  * `footer`
  * all options from `styles/1`

  ## Example

  This example has the dimension of a ISO A4 page.

      ChromicPDF.Template.source_and_options(
        content: "<p>Hello</p>",
        header: "<p>header</p>",
        footer: "<p>footer</p>",
        size: :a4,
        header_height: "45mm",
        header_font_size: "20pt",
        footer_height: "40mm"
      )

  Content, header, and footer templates should be unwrapped HTML markup (i.e. no `<html>` around
  the content), prefixed with any `<style>` tags that your page needs.

        <style>
          h1 { font-size: 22pt; }
        </style>
        <h1>Hello</h1>

  ## ⚠ Markup is injected into the DOM ⚠

  Please be aware that the options returned by this function cause ChromicPDF to inject the
  markup directly into the DOM using the remote debugging API. This comes with some pitfalls
  which are explained in `ChromicPDF.print_to_pdf/2`. Most notably, **no relative URLs** may be
  used within the given HTML.
  """
  @spec source_and_options([content_option() | style_option()]) ::
          ChromicPDF.source_and_options()
  def source_and_options(opts) do
    content = Keyword.get(opts, :content, @default_content)
    header = Keyword.get(opts, :header, "")
    footer = Keyword.get(opts, :footer, "")
    styles = do_styles(opts)

    {width, height} = get_paper_size(opts)

    %{
      source: {:html, html_concat(styles, content)},
      opts: [
        print_to_pdf: %{
          displayHeaderFooter: true,
          headerTemplate: html_concat(styles, header),
          footerTemplate: html_concat(styles, footer),
          paperWidth: width,
          paperHeight: height
        }
      ]
    }
  end

  @doc """
  Concatenes two HTML strings or iolists into one.

  From `{:safe, iolist}` tuples, the `:safe` is dropped. This is useful to prepare data coming
  from a Phoenix-compiled `.eex` template.

      content = html_concat(@styles, render("content.html"))
  """
  @spec html_concat({:safe, iolist()} | iodata(), {:safe, iolist()} | iodata()) :: iolist()
  def html_concat({:safe, styles}, content), do: html_concat(styles, content)
  def html_concat(styles, {:safe, content}), do: html_concat(styles, content)
  def html_concat(styles, content), do: [styles, content]

  @styles """
  <style>
    * {
      -webkit-print-color-adjust: <%= @webkit_print_color_adjust %>;
    }

    @page {
      width: <%= @width %>;
      height: <%= @height %>;
      margin: <%= @header_height %> 0 <%= @footer_height %>;
    }

    #header {
      padding: 0 !important;
      height: <%= @header_height %>;
      font-size: <%= @header_font_size %>;
      zoom: <%= @header_zoom %>;
    }

    #footer {
      padding: 0 !important;
      height: <%= @footer_height %>;
      font-size: <%= @footer_font_size %>;
      zoom: <%= @footer_zoom %>;
    }

    html, body {
      margin: 0;
      padding: 0;
    }
  </style>
  """

  @doc """
  Renders page styles for given options.

  These base styles will configure page dimensions and header and footer heights. They also
  remove any browser padding and margins from these elements, and set the font-size.

  Additionally, they set the zoom level of header and footer templates to 0.75 which seems to
  make them align with the content viewport scaling better.

  ## Options

  * `size` page size, either a standard name (`:a4`, `:us_letter`) or a
     `{<width>, <height>}` tuple in inches, default: `:us_letter`
  * `header_height` default: zero
  * `header_font_size` default: 10pt
  * `header_zoom` default: 0.75
  * `footer_height` default: zero
  * `footer_font_size` default: 10pt
  * `footer_zoom` default: 0.75
  * `webkit_color_print_adjust` default: "exact"
  * `landscape` default: false

  ## Landscape

  As it turns out, Chrome does not recognize the `landscape` option in its `printToPDF` command
  when explicit page dimensions are given. Hence, we provide a `landscape` option here that
  swaps the page dimensions (e.g. it turns 11.7x8.3" A4 into 8.3"x11.7").
  """
  @spec styles([style_option()]) :: blob()
  def styles(opts \\ []), do: do_styles(opts)

  defp do_styles(opts) do
    {width, height} = get_paper_size(opts)

    assigns = [
      width: "#{width}in",
      height: "#{height}in",
      header_height: Keyword.get(opts, :header_height, "0"),
      header_font_size: Keyword.get(opts, :header_font_size, "10pt"),
      footer_height: Keyword.get(opts, :footer_height, "0"),
      footer_font_size: Keyword.get(opts, :footer_font_size, "10pt"),
      header_zoom: Keyword.get(opts, :header_zoom, "0.75"),
      footer_zoom: Keyword.get(opts, :footer_zoom, "0.75"),
      webkit_print_color_adjust: Keyword.get(opts, :webkit_print_color_adjust, "exact")
    ]

    render_styles(assigns)
  end

  EEx.function_from_string(:defp, :render_styles, @styles, [:assigns])

  # Inverts the paper size if landscape orientation.
  defp maybe_rotate_paper(size, false) when tuple_size(size) === 2, do: size
  defp maybe_rotate_paper({w, h}, true), do: {h, w}

  # Fetches paper size from opts, translates from config or uses given {width, height} tuple.
  defp get_paper_size(manual) when tuple_size(manual) === 2, do: manual

  defp get_paper_size(name) when is_atom(name) do
    @paper_sizes_in_inch
    |> Map.get(name, @default_paper_size)
  end

  defp get_paper_size(opts) when is_list(opts) do
    opts
    |> Keyword.get(:size, @default_paper_size)
    |> get_paper_size()
    |> maybe_rotate_paper(Keyword.get(opts, :landscape, false))
  end
end