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