defmodule Pdf.Builder do
@moduledoc """
Declarative PDF builder from template lists.
Renders a list of content tuples into a PDF document, applying
global configuration for page size, margins, fonts, and templates.
## Example
template = [
{:text, "Title", %{font_size: 24, bold: true}},
{:spacer, 10},
{:text, "Body text", %{font_size: 12}},
{:line, %{color: :gray}},
{:page_break},
{:text, "Page 2", %{font_size: 18}}
]
config = %{
size: :a4,
margin: 40,
font: "Helvetica",
font_size: 12
}
doc = Pdf.Builder.render(template, config)
binary = Pdf.export(doc)
"""
@doc """
Render a template list with the given config into a PDF document.
## Config keys
- `:size` — page size (default `:a4`)
- `:margin` — margin value or map (default `0`)
- `:font` — default font name (default `"Helvetica"`)
- `:font_size` — default font size (default `12`)
- `:compress` — compress streams (default `true`)
- `:header` — `fn doc, page_info -> doc end` template
- `:footer` — `fn doc, page_info -> doc end` template
- `:watermark` — `fn doc, page_info -> doc end` template
- `:background` — `fn doc, page_info -> doc end` template
"""
def render(template, config \\ %{}) when is_list(template) do
config = normalize_config(config)
opts = [
size: config.size,
margin: config.margin,
compress: config.compress
]
doc = Pdf.new(opts)
doc = register_templates(doc, config)
doc =
doc
|> Pdf.set_font(config.font, config.font_size)
Enum.reduce(List.flatten(template), doc, &render_element/2)
end
@doc """
Render a template list into an existing document.
Nested lists are automatically flattened.
"""
def render_into(document, template) when is_list(template) do
Enum.reduce(List.flatten(template), document, &render_element/2)
end
defp normalize_config(config) when is_map(config) do
%{
size: Map.get(config, :size, :a4),
margin: Map.get(config, :margin, 0),
font: Map.get(config, :font, "Helvetica"),
font_size: Map.get(config, :font_size, 12),
compress: Map.get(config, :compress, true),
header: Map.get(config, :header),
footer: Map.get(config, :footer),
watermark: Map.get(config, :watermark),
background: Map.get(config, :background),
styles: Map.get(config, :styles, %{}),
debug: Map.get(config, :debug)
}
end
defp register_templates(doc, config) do
doc
|> register_styles(config.styles)
|> maybe_register(:header, config.header)
|> maybe_register(:footer, config.footer)
|> maybe_register(:watermark, config.watermark)
|> maybe_register(:background, config.background)
|> maybe_debug_grid(config.debug)
end
defp maybe_debug_grid(doc, nil), do: doc
defp maybe_debug_grid(doc, true), do: Pdf.debug_grid(doc)
defp maybe_debug_grid(doc, debug_opts) when is_map(debug_opts),
do: Pdf.debug_grid(doc, debug_opts)
defp register_styles(doc, styles) when map_size(styles) == 0, do: doc
defp register_styles(doc, styles), do: Pdf.register_styles(doc, styles)
defp maybe_register(doc, _name, nil), do: doc
defp maybe_register(doc, name, func) when is_function(func, 2) do
Pdf.on_page(doc, name, func)
end
# ── Map-based element renderers ─────────────────────────────────
defp render_element(%{text: string} = el, doc) do
style = Map.drop(el, [:text])
if map_size(style) == 0, do: Pdf.text(doc, string), else: Pdf.text(doc, string, style)
end
defp render_element(%{custom: func}, doc) when is_function(func, 1) do
func.(doc)
end
defp render_element(%{spacer: amount}, doc) do
Pdf.spacer(doc, amount)
end
defp render_element(%{line: style}, doc) when is_map(style) do
Pdf.horizontal_line(doc, style)
end
defp render_element(%{line: true}, doc) do
Pdf.horizontal_line(doc)
end
defp render_element(%{page_break: true}, doc) do
Pdf.page_break(doc)
end
defp render_element(%{page_break: size}, doc) do
Pdf.page_break(doc, size)
end
defp render_element(%{watermark: text} = el, doc) do
style = Map.drop(el, [:watermark])
if map_size(style) == 0, do: Pdf.watermark(doc, text), else: Pdf.watermark(doc, text, style)
end
defp render_element(%{background: style}, doc) do
Pdf.background(doc, style)
end
defp render_element(%{rect: {x, y}, size: {w, h}} = el, doc) do
fill = Map.get(el, :fill)
stroke = Map.get(el, :stroke)
lw = Map.get(el, :line_width, 0.5)
doc = Pdf.save_state(doc)
doc = Pdf.set_line_width(doc, lw)
doc =
if fill do
doc |> Pdf.set_fill_color(fill) |> Pdf.rectangle({x, y}, {w, h}) |> Pdf.fill()
else
doc
end
doc =
if stroke do
doc |> Pdf.set_stroke_color(stroke) |> Pdf.rectangle({x, y}, {w, h}) |> Pdf.stroke()
else
doc
end
Pdf.restore_state(doc)
end
defp render_element(%{line_from: {x1, y1}, line_to: {x2, y2}} = el, doc) do
stroke = Map.get(el, :stroke, {0, 0, 0})
lw = Map.get(el, :line_width, 0.5)
doc
|> Pdf.save_state()
|> Pdf.set_stroke_color(stroke)
|> Pdf.set_line_width(lw)
|> Pdf.line({x1, y1}, {x2, y2})
|> Pdf.stroke()
|> Pdf.restore_state()
end
# ── Element renderers ──────────────────────────────────────────────
defp render_element({:text, string}, doc) do
Pdf.text(doc, string)
end
defp render_element({:text, string, style}, doc) do
Pdf.text(doc, string, style)
end
defp render_element({:spacer, amount}, doc) do
Pdf.spacer(doc, amount)
end
defp render_element({:line}, doc) do
Pdf.horizontal_line(doc)
end
defp render_element({:line, style}, doc) do
Pdf.horizontal_line(doc, style)
end
defp render_element({:page_break}, doc) do
Pdf.page_break(doc)
end
defp render_element({:page_break, size}, doc) do
Pdf.page_break(doc, size)
end
defp render_element({:watermark, text}, doc) do
Pdf.watermark(doc, text)
end
defp render_element({:watermark, text, style}, doc) do
Pdf.watermark(doc, text, style)
end
defp render_element({:background, style}, doc) do
Pdf.background(doc, style)
end
defp render_element({:image, path, style}, doc) do
pos = Pdf.cursor_xy(doc)
opts = []
opts = if Map.has_key?(style, :width), do: [{:width, style.width} | opts], else: opts
opts = if Map.has_key?(style, :height), do: [{:height, style.height} | opts], else: opts
Pdf.add_image(doc, {pos.x, pos.y}, path, opts)
end
defp render_element({:image, path}, doc) do
pos = Pdf.cursor_xy(doc)
Pdf.add_image(doc, {pos.x, pos.y}, path)
end
defp render_element({:table, data, opts}, doc) do
Pdf.styled_table(doc, data, opts)
end
defp render_element({:table, data}, doc) do
Pdf.styled_table(doc, data)
end
defp render_element({:set_font, name, size}, doc) do
Pdf.set_font(doc, name, size)
end
defp render_element({:set_font, name, size, opts}, doc) do
Pdf.set_font(doc, name, size, opts)
end
end