Packages

Prawn-style declarative PDF generation for Elixir. Pure Elixir, no Chrome or HTML.

Current section

Files

Jump to
prawn_ex lib prawn_ex.ex
Raw

lib/prawn_ex.ex

defmodule PrawnEx do
@moduledoc """
Prawn-style declarative PDF generation for Elixir.
Pure Elixir, no Chrome or HTML. Build a document spec and emit PDF 1.4 binary.
## Image / asset path
When using this library as a dependency, set in your application config:
config :prawn_ex, image_dir: "priv/images"
Relative paths passed to `image/3` (e.g. `"photo.jpg"`, `"logo.png"`) are then resolved from that directory.
Absolute paths and raw JPEG or PNG binaries are used as-is.
## Example
PrawnEx.build("out.pdf", fn doc ->
doc
|> PrawnEx.set_font("Helvetica", 12)
|> PrawnEx.text("Hello, PDF!")
|> PrawnEx.rectangle(100, 400, 200, 50)
|> PrawnEx.stroke()
end)
See module docs for `PrawnEx.Document`, `PrawnEx.Units`, and `PrawnEx.Layout` (flow helpers on top of this API).
"""
alias PrawnEx.Document
alias PrawnEx.PDF.Writer
@doc """
Builds a PDF by running the function on a new document, then writes to the given path.
Options (when passing a keyword list as second argument):
- `:header` - `fn(doc, page_number) -> doc` — add ops at top of each page (e.g. title, line)
- `:footer` - `fn(doc, page_number) -> doc` — add ops at bottom of each page (e.g. "Page N")
Returns `:ok` or `{:error, reason}`.
"""
@spec build(String.t(), (Document.t() -> Document.t())) :: :ok | {:error, term()}
@spec build(String.t(), keyword(), (Document.t() -> Document.t())) :: :ok | {:error, term()}
def build(path, opts, fun) when is_list(opts) and is_function(fun, 1) do
doc = Document.new() |> fun.() |> inject_headers_footers(opts)
write_to_file(doc, path)
end
def build(path, fun) when is_function(fun, 1) do
build(path, [], fun)
end
@doc """
Writes the document to a file at `path`.
"""
@spec write_to_file(Document.t(), String.t()) :: :ok | {:error, term()}
def write_to_file(doc, path) do
binary = to_binary(doc)
File.write(path, binary)
end
@doc """
Converts the document to PDF binary.
"""
@spec to_binary(Document.t()) :: binary()
def to_binary(doc), do: Writer.write(doc)
@doc """
Adds a new page. The new page becomes current.
"""
@spec add_page(Document.t()) :: Document.t()
def add_page(doc), do: Document.add_page(doc)
@doc """
Sets the current font (e.g. "Helvetica") and size in points.
"""
@spec set_font(Document.t(), String.t(), number()) :: Document.t()
def set_font(doc, font_name, size), do: Document.append_op(doc, {:set_font, font_name, size})
@doc """
Appends text at the current position (single line).
"""
@spec text(Document.t(), String.t()) :: Document.t()
def text(doc, s), do: Document.append_op(doc, {:text, s})
@doc """
Draws text at the given position `{x, y}` (PDF coordinates: origin bottom-left).
"""
@spec text_at(Document.t(), {number(), number()}, String.t()) :: Document.t()
def text_at(doc, pos, s), do: Document.append_op(doc, {:text_at, pos, s})
@doc """
Draws text wrapped to fit within a width. First line baseline at `{x, y}`; subsequent lines below (smaller y).
Options:
- `:at` - `{x, y}` (required) — position of first line baseline
- `:width` - max width in pt (required)
- `:font_name` - default `"Helvetica"`
- `:font_size` - default `12`
- `:line_height` - default `1.2 * font_size`
"""
@spec text_box(Document.t(), String.t(), keyword()) :: Document.t()
def text_box(doc, text, opts) do
at = Keyword.fetch!(opts, :at)
width = Keyword.fetch!(opts, :width)
font_name = Keyword.get(opts, :font_name, "Helvetica")
font_size = Keyword.get(opts, :font_size, 12)
line_height = Keyword.get(opts, :line_height, font_size * 1.2)
{x, y} = at
lines = PrawnEx.Text.wrap_to_lines(text, width, font_size)
if lines == [] do
doc
else
doc
|> Document.append_op({:set_font, font_name, font_size})
|> then(fn d ->
Enum.with_index(lines)
|> Enum.reduce(d, fn {line, i}, acc ->
Document.append_op(acc, {:text_at, {x, y - i * line_height}, line})
end)
end)
end
end
@doc """
Draws a line from `{x1, y1}` to `{x2, y2}`. Call `stroke/1` to draw it.
"""
@spec line(Document.t(), {number(), number()}, {number(), number()}) :: Document.t()
def line(doc, from, to), do: Document.append_op(doc, {:line, from, to})
@doc """
Moves the path to `{x, y}` without drawing. Use with `line_to/2` and then `stroke/1` for polylines.
"""
@spec move_to(Document.t(), {number(), number()}) :: Document.t()
def move_to(doc, pos), do: Document.append_op(doc, {:move_to, pos})
@doc """
Draws a line from the current path point to `{x, y}`. Call `stroke/1` after the path is complete.
"""
@spec line_to(Document.t(), {number(), number()}) :: Document.t()
def line_to(doc, pos), do: Document.append_op(doc, {:line_to, pos})
@doc """
Adds a rectangle at `(x, y)` with `width` and `height`. Call `stroke/1` or `fill/1` to draw it.
"""
@spec rectangle(Document.t(), number(), number(), number(), number()) :: Document.t()
def rectangle(doc, x, y, width, height),
do: Document.append_op(doc, {:rectangle, x, y, width, height})
@doc """
Strokes the current path (e.g. after `rectangle/5` or `line/3`).
"""
@spec stroke(Document.t()) :: Document.t()
def stroke(doc), do: Document.append_op(doc, :stroke)
@doc """
Fills the current path.
"""
@spec fill(Document.t()) :: Document.t()
def fill(doc), do: Document.append_op(doc, :fill)
@doc """
Sets the non-stroking (fill and text) color to gray. `g` in 0..1 (0=black, 1=white).
"""
@spec set_non_stroking_gray(Document.t(), number()) :: Document.t()
def set_non_stroking_gray(doc, g), do: Document.append_op(doc, {:set_non_stroking_gray, g})
@doc """
Sets the stroking (lines, borders) color to gray. `g` in 0..1.
"""
@spec set_stroking_gray(Document.t(), number()) :: Document.t()
def set_stroking_gray(doc, g), do: Document.append_op(doc, {:set_stroking_gray, g})
@doc """
Sets the non-stroking (fill and text) color to RGB. `r`, `g`, `b` in 0..1.
"""
@spec set_non_stroking_rgb(Document.t(), number(), number(), number()) :: Document.t()
def set_non_stroking_rgb(doc, r, g, b),
do: Document.append_op(doc, {:set_non_stroking_rgb, r, g, b})
@doc """
Sets the stroking (lines, borders) color to RGB. `r`, `g`, `b` in 0..1.
"""
@spec set_stroking_rgb(Document.t(), number(), number(), number()) :: Document.t()
def set_stroking_rgb(doc, r, g, b), do: Document.append_op(doc, {:set_stroking_rgb, r, g, b})
@doc """
Adds an external link annotation on the current page. Clicking the rectangle opens the URL.
`x`, `y` are bottom-left in pt; `width` and `height` define the clickable area.
"""
@spec link(Document.t(), number(), number(), number(), number(), String.t()) :: Document.t()
def link(doc, x, y, width, height, url) do
doc = ensure_current_page(doc)
Document.append_annotation(doc, %{type: :link, rect: {x, y, width, height}, url: url})
end
@doc """
Draws a bar chart. `data` is a list of `{label, value}` or `[label, value]`.
Options: `:at`, `:width`, `:height`, `:bar_color` (gray 0–1), `:axis`, `:labels`, `:label_font_size`, `:padding`.
"""
@spec bar_chart(Document.t(), [{String.t(), number()} | [term()]], keyword()) :: Document.t()
def bar_chart(doc, data, opts \\ []) do
doc = ensure_current_page(doc)
opts = Keyword.put_new(opts, :at, {50, 600})
PrawnEx.Chart.bar_chart(doc, data, opts)
end
@doc """
Draws a line chart. `data` is a list of y-values (x = index) or `[{x, y}, ...]`.
Options: `:at`, `:width`, `:height`, `:stroke_color`, `:axis`, `:padding`.
"""
@spec line_chart(Document.t(), [number()] | [{number(), number()}], keyword()) :: Document.t()
def line_chart(doc, data, opts \\ []) do
doc = ensure_current_page(doc)
opts = Keyword.put_new(opts, :at, {50, 600})
PrawnEx.Chart.line_chart(doc, data, opts)
end
@doc """
Embeds an image at the given position. `path_or_binary` is a file path or image binary.
Supported formats: **JPEG** (embedded as DCT) and **PNG** (8-bit RGB or RGBA, non-interlaced;
RGBA is composited on white). Other formats return `{:error, :unsupported_image_format}`.
If `path_or_binary` is a relative path, it is resolved against the configured image directory
(see "Image / asset path" in the module docs). Set `config :prawn_ex, image_dir: "priv/images"`
in your app to define where to look for image files.
Options: `:at` (required) `{x, y}` bottom-left of image, `:width` and `:height` in pt (default: intrinsic size).
"""
@spec image(Document.t(), String.t() | binary(), keyword()) :: Document.t() | {:error, term()}
def image(doc, path_or_binary, opts) do
at = Keyword.fetch!(opts, :at)
{x, y} = at
path_or_binary = resolve_image_path(path_or_binary)
case load_image(path_or_binary) do
{:ok, spec} ->
w = Keyword.get(opts, :width, spec.width)
h = Keyword.get(opts, :height, spec.height)
doc = ensure_current_page(doc)
{doc, id} = Document.add_image(doc, spec)
Document.append_op(doc, {:image, id, x, y, w, h})
err ->
err
end
end
defp load_image(path_or_binary) do
with {:ok, bytes} <- read_image_bytes(path_or_binary) do
cond do
jpeg_bytes?(bytes) -> PrawnEx.Image.JPEG.load(bytes)
png_bytes?(bytes) -> PrawnEx.Image.PNG.load(bytes)
true -> {:error, :unsupported_image_format}
end
end
end
defp read_image_bytes(data) when is_binary(data) do
cond do
jpeg_bytes?(data) or png_bytes?(data) ->
{:ok, data}
true ->
File.read(data)
end
end
defp jpeg_bytes?(<<0xFF, 0xD8, _::binary>>), do: true
defp jpeg_bytes?(_), do: false
defp png_bytes?(data) when byte_size(data) >= 8 do
binary_part(data, 0, 8) == <<137, 80, 78, 71, 13, 10, 26, 10>>
end
defp png_bytes?(_), do: false
# Resolve relative paths against config :prawn_ex, :image_dir (asset path for users of the dep).
defp resolve_image_path(path_or_binary) when is_binary(path_or_binary) do
cond do
jpeg_bytes?(path_or_binary) or png_bytes?(path_or_binary) ->
path_or_binary
Path.type(path_or_binary) == :absolute ->
path_or_binary
true ->
case Application.get_env(:prawn_ex, :image_dir) do
nil -> path_or_binary
dir -> Path.join(Path.expand(dir), path_or_binary)
end
end
end
@doc """
Draws a table at the given position. `rows` is a list of rows (list of cell values).
First row can be styled as header with `header: true` (default).
## Options
- `:at` - `{x, y}` top-left of table (default `{50, 750}`)
- `:column_widths` - list of pt widths or `:auto`
- `:row_height`, `:cell_padding`, `:header`, `:border`, `:font_size`, `:header_font_size`
## Example
PrawnEx.table(doc, [["Name", "Score"], ["Alice", "95"], ["Bob", "87"]],
at: {50, 650}, column_widths: [200, 80])
"""
@spec table(Document.t(), [list()], keyword()) :: Document.t()
def table(doc, rows, opts \\ []) do
doc = ensure_current_page(doc)
opts = Keyword.put_new(opts, :at, {50, 750})
opts = Keyword.put(opts, :page_size, doc.opts[:page_size] || :a4)
PrawnEx.Table.layout(doc, rows, opts)
end
defp ensure_current_page(%Document{pages: []} = doc), do: Document.add_page(doc)
defp ensure_current_page(doc), do: doc
defp inject_headers_footers(doc, opts) do
header_cb = Keyword.get(opts, :header)
footer_cb = Keyword.get(opts, :footer)
if header_cb == nil and footer_cb == nil, do: doc, else: do_inject(doc, header_cb, footer_cb)
end
defp do_inject(doc, header_cb, footer_cb) do
Enum.with_index(doc.pages)
|> Enum.reduce(doc, fn {_page, i}, acc ->
page_num = i + 1
header_ops = if header_cb, do: ops_from_callback(header_cb, acc, page_num), else: []
footer_ops = if footer_cb, do: ops_from_callback(footer_cb, acc, page_num), else: []
Document.inject_page_ops(acc, i, header_ops, footer_ops)
end)
end
defp ops_from_callback(cb, doc, page_num) do
# Run callback with a doc that has one empty page; it adds header/footer ops
blank = Document.new(doc.opts) |> Document.add_page()
result = cb.(blank, page_num)
case Document.current_page(result) do
nil -> []
page -> page.content_ops
end
end
end