Current section
Files
Jump to
Current section
Files
lib/mead.ex
defmodule Mead do
@moduledoc """
Native PDF generation for Elixir.
Builds a styled element tree, lays it out with Taffy (flexbox), and renders
it to PDF via krilla — all in a Rust NIF.
"""
alias Mead.FontSet
alias Mead.Node
alias Mead.Session
alias Mead.Style
alias Phoenix.LiveView.Rendered
@a4 {595.0, 842.0}
@inherited_defaults %{
color: {0, 0, 0},
font_size: 14.0,
line_height: 1.2,
font_family: nil,
font_weight: 400.0,
italic: false,
text_align: :left,
orphans: 2,
widows: 2
}
@doc """
Renders a node tree (or a rendered `~PDF` template) to a PDF binary.
## Options
* `:page_size` - `{width, height}` in points, defaults to A4 portrait.
* `:margin` - uniform page margin in points applied on every page
(the content box is inset by it), defaults to 0.
* `:session` - a `Mead.Session` to render through (reuses parsed
fonts and warm shaping state across renders; the recommended way
to render repeatedly). See `Mead.Session.new/1`.
* `:font_set` - a prebuilt `Mead.FontSet`; a fresh session is
created for the call. Prefer `:session` for repeated renders.
* `:fonts` - `%{family => [variant]}` map to build an ephemeral font
set, see `Mead.FontSet.new/2`.
* `:font_fallback` - fallback chain for the ephemeral `:fonts` set
(`Mead.FontSet.new/2`'s `:fallback` option).
* `:font_pool` - lazy fallback pool paths for the ephemeral `:fonts`
set (`Mead.FontSet.new/2`'s `:pool` option; the pool is rescanned
per call — prefer a persistent `:session` for pooled rendering).
* `:font` - path to a single TTF/OTF used as the entire default family.
With none of the font options, rendering goes through the global
default session (`Mead.Session.default/0`): fonts from the `:mead_pdf,
:fonts` application env, parsed once per VM.
* `:title` / `:author` / `:language` - document metadata (PDF Info
dictionary + XMP).
* `:header` / `:footer` - running page chrome: a node tree (or `~PDF`
template) laid out against the content width, painted at the top /
bottom of every page's content box; the flow content shrinks by its
height. Counter spans (`Mead.Node.page_number/1`,
`page_count/1`; markup `<page_number/>`, `<page_count/>`) are
substituted after pagination — chrome containing them is re-laid
out per page, inside its reserved height.
* `:max_pages` - pagination safety cap (also honored by
`paginate/2`). Raises with a clear error instead of paginating
further; a runaway document (e.g. content that can never fit a
page) then fails fast rather than looping. Defaults to the
`:mead_pdf, :max_pages` application env, or 5000; pass `:infinity`
to disable.
Content taller than one page's content box flows onto additional pages;
see `Mead.Node` for the pagination props (`break_before`, `wrap`,
`repeat`).
"""
@spec render(Node.t() | Rendered.t(), keyword()) :: binary()
def render(root, opts \\ [])
def render(%Rendered{} = template, opts), do: template |> Mead.Markup.to_node() |> render(opts)
def render(%Node{} = root, opts) do
{width, height} = Keyword.get(opts, :page_size, @a4)
margin = to_float(Keyword.get(opts, :margin, 0)) || 0.0
%Session{ref: session} = resolve_session(opts)
{root, header, footer, assets} = normalize_document(root, opts)
meta = for key <- [:title, :author, :language], value = Keyword.get(opts, key), do: {key, value}
Mead.Native.render(
root,
header,
footer,
meta,
session,
assets,
width * 1.0,
height * 1.0,
margin,
max_pages(opts)
)
end
@doc """
Computes layout without rendering, returning the box tree.
Each box is a map with absolute `:x`/`:y` (page coordinates, top-left
origin), `:width`, `:height`, `:children`, and — for text nodes — the
wrapped `:lines`. Mirrors the geometry `render/2` draws from; intended
for tests and debugging.
"""
@spec layout(Node.t() | Rendered.t(), keyword()) :: map()
def layout(root, opts \\ [])
def layout(%Rendered{} = template, opts), do: template |> Mead.Markup.to_node() |> layout(opts)
def layout(%Node{} = root, opts) do
{width, _height} = Keyword.get(opts, :page_size, @a4)
%Session{ref: session} = resolve_session(opts)
{root, _header, _footer, assets} = normalize_document(root, [])
Mead.Native.layout(root, session, assets, width * 1.0)
end
@doc """
Paginates a node tree without rendering, returning one box tree per page.
Boxes are in page coordinates (the page margin and any header height
are included in positions). Takes the same options as `render/2`, plus:
* `:chrome` - when true, each page is returned as
`%{content: box, header: box | nil, footer: box | nil}` instead of
the bare content box.
"""
@spec paginate(Node.t() | Rendered.t(), keyword()) :: [map()]
def paginate(root, opts \\ [])
def paginate(%Rendered{} = template, opts), do: template |> Mead.Markup.to_node() |> paginate(opts)
def paginate(%Node{} = root, opts) do
{width, height} = Keyword.get(opts, :page_size, @a4)
margin = to_float(Keyword.get(opts, :margin, 0)) || 0.0
%Session{ref: session} = resolve_session(opts)
{root, header, footer, assets} = normalize_document(root, opts)
pages =
Mead.Native.paginate(
root,
header,
footer,
session,
assets,
width * 1.0,
height * 1.0,
margin,
max_pages(opts)
)
if Keyword.get(opts, :chrome, false) do
pages
else
Enum.map(pages, & &1.content)
end
end
# Session resolution, most specific wins: an explicit session, then a
# font set (fresh session for the call), then ephemeral font options,
# then the global default session (parse-once via persistent_term).
defp resolve_session(opts) do
cond do
session = Keyword.get(opts, :session) ->
session
set = Keyword.get(opts, :font_set) ->
Session.new(set)
font = Keyword.get(opts, :font) ->
Session.new(FontSet.from_file(font))
fonts = Keyword.get(opts, :fonts) ->
Session.new(
FontSet.new(fonts,
fallback: Keyword.get(opts, :font_fallback, []),
pool: Keyword.get(opts, :font_pool, [])
)
)
true ->
Session.default()
end
end
# Desugars tables, resolves inheritable text properties down the tree,
# coerces numbers to floats, and collects image binaries and barcode
# specs into a deduplicated asset list (shared across flow, header, and
# footer), so the NIF only ever sees plain nodes with concrete values
# and asset indices.
defp normalize_document(%Node{} = root, opts) do
header = chrome_node(opts, :header)
footer = chrome_node(opts, :footer)
acc = {[], %{}}
{root, acc} = root |> Mead.Table.desugar() |> normalize(@inherited_defaults, acc)
{header, acc} = normalize_chrome(header, acc)
{footer, {assets, _index}} = normalize_chrome(footer, acc)
validate_no_counters(root)
{root, header, footer, Enum.reverse(assets)}
end
defp chrome_node(opts, key) do
case Keyword.get(opts, key) do
nil -> nil
%Node{} = node -> node
%Rendered{} = template -> Mead.Markup.to_node(template)
other -> raise ArgumentError, "#{key} must be a Mead.Node or ~PDF template, got: #{inspect(other)}"
end
end
defp normalize_chrome(nil, acc), do: {nil, acc}
defp normalize_chrome(%Node{} = node, acc), do: node |> Mead.Table.desugar() |> normalize(@inherited_defaults, acc)
# Counter spans are resolved per page for chrome subtrees only; in flow
# content they would silently render their placeholder.
defp validate_no_counters(%Node{counter: counter}) when not is_nil(counter) do
raise ArgumentError,
"page_number/page_count are only valid inside the :header or :footer chrome, " <>
"not in flow content (their values exist only after pagination)"
end
defp validate_no_counters(%Node{children: children}), do: Enum.each(children, &validate_no_counters/1)
defp normalize(%Node{style: %Style{} = style} = node, inherited, asset_acc) do
effective = effective_inherited(style, inherited)
normalized_style = %{
style
| width: to_dimension(style.width),
height: to_dimension(style.height),
min_width: to_dimension(style.min_width),
min_height: to_dimension(style.min_height),
max_width: to_dimension(style.max_width),
max_height: to_dimension(style.max_height),
flex_grow: to_float(style.flex_grow),
flex_shrink: to_float(style.flex_shrink),
flex_basis: to_float(style.flex_basis),
padding: to_sides(style.padding),
margin: to_sides(style.margin),
border: to_sides(style.border),
border_radius: to_float(style.border_radius) || 0.0,
gap: to_float(style.gap) || 0.0,
color: effective.color,
font_size: effective.font_size,
line_height: effective.line_height,
font_family: effective.font_family,
font_weight: effective.font_weight,
italic: effective.italic,
text_align: effective.text_align,
orphans: effective.orphans,
widows: effective.widows
}
node = %{node | bookmark_level: (node.bookmark_level || 1) * 1.0}
{node, asset_acc} = collect_asset(node, asset_acc)
{children, asset_acc} =
Enum.map_reduce(node.children, asset_acc, &normalize(&1, effective, &2))
children =
if node.kind == :text, do: normalize_spans(children), else: resolve_break_avoid(children)
{%{node | style: normalized_style, children: children}, asset_acc}
end
# `break_before: :avoid` desugars to `keep_with_next` on the preceding
# sibling — the NIF sees only booleans. An :avoid on a first child has
# no preceding sibling and is dropped (v1: no cross-container chains).
defp resolve_break_avoid(children) do
avoid_next = Enum.map(Enum.drop(children, 1), &(&1.break_before == :avoid)) ++ [false]
Enum.zip_with(children, avoid_next, fn child, next_avoid ->
%{
child
| keep_with_next: child.keep_with_next or next_avoid,
break_before: child.break_before == true
}
end)
end
# nil = "inherit": every explicitly set value (including `italic: false`)
# overrides the inherited one.
defp effective_inherited(%Style{} = style, inherited) do
overrides = %{
color: style.color,
font_size: to_float(style.font_size),
line_height: to_float(style.line_height),
font_family: style.font_family,
font_weight: weight(style.font_weight),
italic: style.italic,
text_align: style.text_align,
orphans: to_count(style.orphans),
widows: to_count(style.widows)
}
Map.merge(inherited, overrides, fn _key, inherited_value, override ->
if is_nil(override), do: inherited_value, else: override
end)
end
defp to_count(nil), do: nil
defp to_count(n) when is_integer(n) and n >= 0, do: n
defp to_count(n) when is_float(n) and n >= 0, do: trunc(n)
# Rich-text spans: collapse whitespace runs within each span (hard
# `\n` breaks survive, CSS pre-line style — whitespace around them is
# absorbed), merge duplicate spaces across span boundaries, trim the
# paragraph edges, and drop spans that end up empty — so the NIF can
# concatenate span contents verbatim.
defp normalize_spans([]), do: []
defp normalize_spans(spans) do
Enum.each(spans, fn
%Node{kind: :span} -> :ok
%Node{kind: kind} -> raise ArgumentError, "text nodes may only contain spans, got #{inspect(kind)}"
end)
{spans, _} =
Enum.map_reduce(spans, true, fn span, prev_space? ->
content =
(span.content || "")
|> String.replace(~r/[^\S\n]*\n[^\S\n]*/, "\n")
|> String.replace(~r/[^\S\n]+/, " ")
content = if prev_space?, do: String.trim_leading(content, " "), else: content
ends_space? =
if content == "",
do: prev_space?,
else: String.ends_with?(content, " ") or String.ends_with?(content, "\n")
{%{span | content: content}, ends_space?}
end)
spans
|> Enum.reject(&(&1.content == ""))
|> trim_last_span()
|> Enum.reject(&(&1.content == ""))
end
defp trim_last_span([]), do: []
defp trim_last_span(spans) do
List.update_at(spans, -1, &%{&1 | content: String.trim_trailing(&1.content, " ")})
end
defp collect_asset(%Node{kind: :image, source: source} = node, acc) do
intern_asset(node, image_data(source), acc)
end
# The barcode spec tuple (built by Node.barcode/2) crosses the NIF
# as-is; identical specs share one encoded asset like identical images.
defp collect_asset(%Node{kind: :barcode, source: spec} = node, acc) do
intern_asset(node, spec, acc)
end
defp collect_asset(node, asset_acc), do: {node, asset_acc}
defp intern_asset(node, data, {assets, index}) do
case Map.fetch(index, data) do
{:ok, idx} ->
{%{node | asset: idx}, {assets, index}}
:error ->
idx = map_size(index)
{%{node | asset: idx}, {[data | assets], Map.put(index, data, idx)}}
end
end
defp image_data({:binary, data}) when is_binary(data), do: data
defp image_data(path) when is_binary(path), do: File.read!(path)
defp image_data(other) do
raise ArgumentError,
"image source must be a file path or {:binary, data}, got: #{inspect(other)}"
end
defp weight(nil), do: nil
defp weight(:normal), do: 400.0
defp weight(:bold), do: 700.0
defp weight(value) when is_number(value), do: value * 1.0
# Pagination cap: option -> app config -> library default (5000);
# :infinity crosses the NIF as nil (unbounded).
defp max_pages(opts) do
case Keyword.get(opts, :max_pages, Application.get_env(:mead_pdf, :max_pages, 5000)) do
:infinity ->
nil
n when is_integer(n) and n > 0 ->
n
other ->
raise ArgumentError,
":max_pages must be a positive integer or :infinity, got: #{inspect(other)}"
end
end
defp to_float(nil), do: nil
defp to_float(value) when is_number(value), do: value * 1.0
# Dimensions reach the NIF as {:pt, n} | {:percent, fraction} | nil
# (a bare Option can't carry the unit, and percents become 0..1).
defp to_dimension(nil), do: nil
defp to_dimension({:percent, n}) when is_number(n), do: {:percent, n / 100.0}
defp to_dimension(value) when is_number(value), do: {:pt, value * 1.0}
# Per-side values reach the NIF as a {top, right, bottom, left} tuple.
defp to_sides(nil), do: {0.0, 0.0, 0.0, 0.0}
defp to_sides(value) when is_number(value), do: {value * 1.0, value * 1.0, value * 1.0, value * 1.0}
defp to_sides({t, r, b, l}) when is_number(t) and is_number(r) and is_number(b) and is_number(l),
do: {t * 1.0, r * 1.0, b * 1.0, l * 1.0}
end