Skip to main content

lib/docx_tmpl.ex

defmodule DocxTmpl do
  @moduledoc """
  Make new `.docx` files from a Handlebars-like template.

  ## Template syntax

      Hello {{name}}, welcome to {{company}}.

      {{#each items}}- {{name}} × {{qty}}
      {{/each}}

      {{#if paid}}Thanks!{{/if}}
      {{#unless paid}}Please pay.{{/unless}}

  `{{#each}}` wrapping a single `<w:tr>` repeats the table row.

  `{{image var}}` standalone in a paragraph embeds an image. The assigns
  value is `%{bytes: binary, format: :png | :jpeg | :gif, width_cm: number,
  height_cm: number}`. Missing values drop the paragraph.

  ## Why DOCX is tricky

  Word frequently splits a single placeholder like `{{name}}` across
  multiple `<w:r>` runs (different fonts, spellcheck markers, revisions).
  Before substitution we run a *smart-merge* pass that stitches such
  fragmented placeholders back together. See `DocxTmpl.SmartMerge`.

  ## Public API

    * `render/2`, `render!/2` — render an in-memory `.docx` binary.
    * `render_file/3`, `render_file!/3` — convenience wrappers for paths.

  Everything under `DocxTmpl.*` other than this module is internal and
  may change without notice.
  """

  alias DocxTmpl.{Parser, Render, SmartMerge, Structural, Zip}

  @type assigns :: map()
  @type docx_binary :: binary()

  @doc """
  Render `template` (raw `.docx` bytes) with `assigns`.
  """
  @spec render(docx_binary(), assigns()) :: {:ok, docx_binary()} | {:error, term()}
  def render(template, assigns) when is_binary(template) and is_map(assigns) do
    Render.run(template, assigns)
  end

  @doc "Raising variant of `render/2`."
  @spec render!(docx_binary(), assigns()) :: docx_binary()
  def render!(template, assigns) do
    case render(template, assigns) do
      {:ok, bin} -> bin
      {:error, reason} -> raise ArgumentError, "docx render failed: #{inspect(reason)}"
    end
  end

  @doc "Read a `.docx` from disk, render it, return the new bytes."
  @spec render_file(Path.t(), assigns(), keyword()) ::
          {:ok, docx_binary()} | {:error, term()}
  def render_file(path, assigns, _opts \\ []) do
    with {:ok, bin} <- File.read(path) do
      render(bin, assigns)
    end
  end

  @doc """
  List every variable name referenced by `template`.

  Returns a sorted, deduplicated list of identifiers used in `{{var}}`,
  `{{#if x}}`, `{{#unless x}}`, and `{{#each x}}` tags — including names
  referenced inside block bodies. Dotted paths (`a.b`) are returned as-is.

  Roles (interpolation vs. condition vs. iteration) are intentionally not
  distinguished here; callers that need that should parse the template
  themselves.
  """
  @spec variables(docx_binary()) :: {:ok, [String.t()]} | {:error, term()}
  def variables(template) when is_binary(template) do
    with {:ok, entries} <- Zip.unpack(template) do
      names =
        for {name, bin} <- entries, Zip.template_part?(name), reduce: MapSet.new() do
          acc ->
            bin
            |> SmartMerge.heal()
            |> Structural.fixup()
            |> Parser.parse()
            |> collect_names(acc)
        end

      {:ok, names |> MapSet.to_list() |> Enum.sort()}
    end
  end

  defp collect_names(nodes, acc) when is_list(nodes) do
    Enum.reduce(nodes, acc, &collect_names/2)
  end

  defp collect_names({:text, _}, acc), do: acc
  defp collect_names({:var, p}, acc), do: MapSet.put(acc, p)

  defp collect_names({kind, p, children}, acc) when kind in [:if, :unless, :each] do
    collect_names(children, MapSet.put(acc, p))
  end

  @doc "Raising variant of `render_file/3`."
  @spec render_file!(Path.t(), assigns(), keyword()) :: docx_binary()
  def render_file!(path, assigns, opts \\ []) do
    case render_file(path, assigns, opts) do
      {:ok, bin} -> bin
      {:error, reason} -> raise ArgumentError, "docx render failed: #{inspect(reason)}"
    end
  end
end