Packages

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

Current section

Files

Jump to
prawn_ex README.md
Raw

README.md

# PrawnEx
[![CI](https://github.com/half-blood-labs/prawn_ex/actions/workflows/ci.yml/badge.svg)](https://github.com/half-blood-labs/prawn_ex/actions/workflows/ci.yml)
[![Hex.pm](https://img.shields.io/hexpm/v/prawn_ex.svg)](https://hex.pm/packages/prawn_ex)
[![Hex.pm](https://img.shields.io/hexpm/dt/prawn_ex.svg)](https://hex.pm/packages/prawn_ex)
[![Hex.pm](https://img.shields.io/hexpm/l/prawn_ex.svg)](https://hex.pm/packages/prawn_ex)
[![Elixir](https://img.shields.io/badge/elixir-%3E%3D%201.16-purple)](https://elixir-lang.org)
**Version** 0.5.0 · **Elixir** ~> 1.16
Prawn-style declarative PDF generation for Elixir. Pure Elixir, no Chrome or HTML: build a document spec and emit PDF 1.4.
## Features
- **Document & pages** — Multi-page PDFs, configurable page size (A4, Letter, etc.).
- **Text** — Set font and size (Helvetica, Times-Roman, Times-Bold, Courier, etc.), draw text at position or append to cursor.
- **Embedded fonts**`register_font/3` embeds a real TrueType font (`/FontFile2`, flate-compressed, WinAnsi widths from the font's own metrics), so documents can use brand typefaces instead of the base-14 stand-ins. CFF-flavoured OpenType is not supported yet.
- **Graphics** — Lines, rectangles, rounded rectangles (Bézier corners), polygons, move-to/line-to/curve-to paths; stroke, fill, and fill-stroke.
- **Colors & opacity** — Gray (stroking and non-stroking) and RGB; `set_opacity/2` for translucent fills and strokes (ExtGState alpha).
- **Tables** — Grid with optional header row, configurable column widths, row height, padding, borders; **cell alignment** per column (`:left`, `:center`, `:right`).
- **Charts** — Bar charts and line charts from data (no external deps); `:font` to draw labels and legends in an embedded typeface, `:corner_radius` for rounded bars.
- **Flow layout**`PrawnEx.Layout`: margin box + cursor, `vstack` / `hstack`, optional **region** + **new-page** overflow, and `PrawnEx.Layout.Markup` for a tiny line-oriented DSL (see [Flow layout](#flow-layout-prawnexlayout)); still pure PDF ops under the hood.
- **Images** — Embed **JPEG** (`/DCTDecode`) or **PNG** (`/FlateDecode`): 8-bit RGB/RGBA, non-interlaced, path or binary; optional width/height; `image_dir` config for relative paths.
- **Links** — External link annotations (clickable URLs).
- **Headers & footers** — Per-page callbacks with page number for titles and “Page N”.
## Getting started
Add the dependency and build your first PDF:
```elixir
# mix.exs
def deps do
[{:prawn_ex, "~> 0.5.0"}]
end
```
```elixir
PrawnEx.build("output.pdf", fn doc ->
doc
|> PrawnEx.add_page()
|> PrawnEx.set_font("Helvetica", 12)
|> PrawnEx.text_at({72, 700}, "Hello, PDF!")
end)
```
See [Demo](#demo) for a full tour, or try the [invoice](#examples) or [report with chart](#examples) examples.
## Usage
```elixir
PrawnEx.build("output.pdf", fn doc ->
doc
|> PrawnEx.set_font("Helvetica", 12)
|> PrawnEx.text_at({100, 700}, "Hello, PDF!")
|> PrawnEx.rectangle(100, 600, 200, 50)
|> PrawnEx.stroke()
end)
```
Or build a document and get binary:
```elixir
binary =
PrawnEx.Document.new()
|> PrawnEx.add_page()
|> PrawnEx.set_font("Helvetica", 12)
|> PrawnEx.text_at({72, 72}, "Hello")
|> PrawnEx.to_binary()
```
Coordinates use PDF points (72 pt = 1 inch); origin is bottom-left.
### Tables
Draw tables with an optional header row (Phase 2):
```elixir
rows = [["Product", "Qty", "Price"], ["Widget", "2", "$10"], ["Gadget", "1", "$25"]]
doc
|> PrawnEx.table(rows, at: {50, 650}, column_widths: [200, 80, 80], header: true)
```
Options: `:at`, `:column_widths` (list or `:auto`), `:row_height`, `:cell_padding`, `:header`, `:border`, `:align` (`:left` / `:center` / `:right` or list per column), `:font_size`, `:header_font_size`.
### Headers and footers
Use `build(path, opts, fun)` with `:header` and/or `:footer` callbacks (receiving `doc` and page number). Ideal for "Page N" and repeating titles:
```elixir
PrawnEx.build("out.pdf", [
footer: fn doc, page_num ->
doc
|> PrawnEx.set_font("Helvetica", 9)
|> PrawnEx.text_at({50, 30}, "Page \#{page_num}")
end
], fn doc ->
# your content; footer is injected on every page
doc |> PrawnEx.add_page() |> ...
end)
```
### Charts
Bar and line charts (Phase 3), built from drawing primitives:
```elixir
# Bar chart: list of {label, value}
PrawnEx.bar_chart(doc, [{"Jan", 40}, {"Feb", 55}, {"Mar", 70}],
at: {50, 500}, width: 400, height: 200, bar_color: 0.4)
# Line chart: list of y-values (x = index) or [{x, y}, ...]
PrawnEx.line_chart(doc, [10, 25, 15, 40, 35], at: {50, 400}, width: 400, height: 150)
```
Options: `:at`, `:width`, `:height`, `:bar_color` / `:stroke_color`, `:axis`, `:labels`, `:padding`.
### Flow layout (`PrawnEx.Layout`)
For documents that are mostly **stacked blocks** (title, paragraphs, table), a positional API forces you to repeat `page_h - N` math. `PrawnEx.Layout` tracks a **baseline cursor** inside a margin box and emits the same `PrawnEx` ops (`text_at`, `text_box`, `table`).
- **`attach(doc, page_size:, margins:, region:, on_overflow:)`**`margins` can be a number (all sides) or `%{left:, right:, top:, bottom:}` (missing keys default to 50 pt). Optional **`region: %{floor_y: y}`** (or bare `y`) sets a minimum PDF `y` for content; with **`on_overflow: :new_page`** (default when `region` is set), `heading`, `paragraph`, `table`, and `spacer` insert **`PrawnEx.add_page/1`** before drawing when ink would cross the floor. **`on_overflow: :clip`** disables automatic page breaks. A paragraph taller than the remaining region raises `ArgumentError`.
- **`heading(layout, text, opts)`** — single line; `:level` (1 or 2), `:font`, `:font_size`, `:lead`, `:gap_after`.
- **`paragraph(layout, text, opts)`** — wraps with `text_box`; `:line_height`, `:gap_after`, optional `:width`.
- **`spacer(layout, pts)`** — move the cursor down; with a region, long spacers **split across pages**.
- **`table(layout, rows, opts)`** — forwards to `PrawnEx.table/3`; `:clearance`, `:after_gap`, `:at` / `:page_size` handled like before.
- **`vstack(layout, blocks, gap:)`** — vertical list of tuples: `{:heading, t, o}`, `{:paragraph, t, o}`, `{:spacer, pts}`, `{:table, rows, o}`, `{:run, fn l -> l end}`.
- **`hstack(layout, [{width, fn col -> col end}], gap:)`** — fixed-width columns on one row (deepest column sets row height).
- **`escape(layout, fn doc, ctx -> {doc, new_cursor_y} end)`** — one-off coordinates; `ctx` has `:cursor_y`, `:content_left`, `:content_width`, `:page_w`, `:page_h`, `:margins`.
- **`to_doc(layout)`** — unwrap for `PrawnEx.to_binary/1` or the end of a `build/3` callback.
**Markup (Phase D)**`PrawnEx.Layout.Markup.parse/1` and **`apply(layout, string, vstack_opts)`**: lines starting with `# ` / `## ` for headings, `- ` bullets (rendered as ASCII `* ` lines for built-in font encodings), blank lines separate paragraphs. Not a full Markdown engine.
See `mix run scripts/invoice.exs` for layout without markup.
### Images
Embed **JPEG** or **PNG** via `PrawnEx.image/3` (file path or raw bytes). Use `:at` (required), and optionally `:width` / `:height` in pt; default size is the image’s pixel dimensions treated as pt.
| Format | Notes |
|--------|--------|
| **JPEG** | Stream is embedded as-is with `/DCTDecode`. |
| **PNG** | 8-bit truecolor **RGB** or **RGBA** only, no interlacing. Decoded in pure Elixir; pixels are written as `/DeviceRGB` with `/FlateDecode`. **RGBA** is composited on **white** (simple transparency handling). Indexed-palette, grayscale-only, or interlaced PNGs are not supported and return `{:error, ...}`. |
**Image / asset path:** Set `config :prawn_ex, image_dir: "priv/images"` (or any directory) in your application config. Relative paths passed to `PrawnEx.image/3` are resolved from that directory. Absolute paths and raw JPEG or PNG binaries are used as-is.
```elixir
# In your config/config.exs:
config :prawn_ex, image_dir: "priv/images"
# In your code — paths are under image_dir:
doc
|> PrawnEx.image("photo.jpg", at: {50, 400})
|> PrawnEx.image("logo.png", at: {400, 700}, width: 80, height: 40)
```
Other image types produce `{:error, :unsupported_image_format}`.
### Colors
Gray: `PrawnEx.set_stroking_gray(doc, 0.8)`, `PrawnEx.set_non_stroking_gray(doc, 0.2)` (0 = black, 1 = white).
RGB: `PrawnEx.set_non_stroking_rgb(doc, r, g, b)`, `PrawnEx.set_stroking_rgb(doc, r, g, b)` (0–1).
## Demo
Generate the main tour PDF:
```bash
mix run scripts/gen_demo.exs
```
Output: `output/prawn_ex_demo.pdf` (5 pages: hero, table, charts, images, **Layout**). Page 4 shows **JPEG** / **PNG**; page 5 uses **`PrawnEx.Layout`** (Phase A + **hstack** teaser). Full **Phase A–D** walk-through (vstack, hstack, **region + new page**, **Markup**):
```bash
mix run scripts/layout_phases_demo.exs
```
`output/layout_phases_demo.pdf` (expect at least two pages so Phase C is visible). Set `config :prawn_ex, image_dir: "path/to/images"` in `config/config.exs` if you keep assets elsewhere.
### Examples
- **Invoice**`mix run scripts/invoice.exs``output/invoice.pdf` (header, line-item table with alignment, totals, footer).
- **Report with chart**`mix run scripts/report_with_chart.exs``output/report_with_chart.pdf` (table + bar chart).
## Installation
If [available in Hex](https://hex.pm/docs/publish), the package can be installed
by adding `prawn_ex` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:prawn_ex, "~> 0.5.0"}
]
end
```
Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc)
and published on [HexDocs](https://hexdocs.pm). Once published, the docs can
be found at <https://hexdocs.pm/prawn_ex>.