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