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 ``, ``) 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