Ootempl (ootempl v0.3.0)

Office Open XML document templating library for Elixir.

Ootempl enables programmatic manipulation of Microsoft Word documents (.docx) by replacing placeholders with dynamic content to generate customized documents from templates.

Features

  • Load and parse .docx templates
  • Replace {{variable}} placeholders with dynamic content
  • Support nested data access with dot notation ({{customer.name}})
  • Conditional sections with {{if condition}}...{{endif}} syntax
  • Dynamic table row generation from list data
  • Multi-row table templates for complex layouts
  • Replace placeholder images with dynamic content (PNG, JPEG, GIF)
  • Automatic image dimension scaling with aspect ratio preservation
  • Case-insensitive placeholder matching
  • Process headers, footers, footnotes, and endnotes
  • Replace placeholders in document properties (title, author, company)
  • Preserve Word formatting (bold, italic, fonts, table borders, shading)
  • Generate valid .docx output files
  • Comprehensive validation and error handling

Basic Usage

Simple Variable Replacement

# Render a template with placeholder replacement
data = %{
  "name" => "John Doe",
  "customer" => %{"email" => "john@example.com"},
  "total" => 99.99
}
Ootempl.render("template.docx", data, "output.docx")
#=> :ok

Struct Support

Elixir structs work seamlessly with render/3. You can pass structs directly without converting them to maps first. Struct fields (atoms) are matched case-insensitively to placeholders.

defmodule Customer do
  defstruct [:name, :email, :address]
end

defmodule Address do
  defstruct [:street, :city, :state, :zip]
end

# Use structs directly in your data
customer = %Customer{
  name: "John Doe",
  email: "john@example.com",
  address: %Address{
    city: "Boston",
    state: "MA",
    zip: "02101"
  }
}

data = %{
  "order_id" => "ORD-12345",
  "customer" => customer
}

# Template can reference struct fields:
# - {{customer.name}} → "John Doe"
# - {{customer.email}} → "john@example.com"
# - {{customer.address.city}} → "Boston"

Ootempl.render("invoice_template.docx", data, "invoice.docx")
#=> :ok

Struct features:

  • Nested structs: Access fields multiple levels deep
  • Case-insensitive: {{customer.Name}} matches :name field
  • Mixed data: Combine structs and maps in the same data structure
  • Lists of structs: Use structs in table templates

Table Templates

Table templates automatically duplicate rows based on list data. Template rows are identified by placeholders that reference list items.

# Simple table template
data = %{
  "title" => "Claims Report",
  "claims" => [
    %{"id" => 5565, "amount" => 100.50},
    %{"id" => 5566, "amount" => 250.00}
  ],
  "total" => 350.50
}
Ootempl.render("invoice_template.docx", data, "invoice.docx")
#=> :ok

Template structure in Word document:

| Claim ID          | Amount              |   Header (no list placeholders)
| {{claims.id}}     | {{claims.amount}}   |   Template row (references "claims" list)
| Total             | {{total}}           |   Footer (single value)

Generated output:

| Claim ID  | Amount  |
| 5565      | 100.50  |
| 5566      | 250.00  |
| Total     | 350.50  |

Multi-row templates are supported for complex table layouts:

data = %{
  "orders" => [
    %{"id" => 100, "product" => "Widget", "qty" => 5, "price" => 10.00},
    %{"id" => 101, "product" => "Gadget", "qty" => 3, "price" => 25.00}
  ]
}

Template with two rows per order:

| Order {{orders.id}}                                  |   Row 1 of template
| {{orders.qty}}x {{orders.product}} @ ${{orders.price}} each |   Row 2 of template

Generated output duplicates both rows for each order:

| Order 100           |
| 5x Widget @ $10.00 each |
| Order 101           |
| 3x Gadget @ $25.00 each |

Hierarchical Table Templates

For nested data structures with parent-child relationships, use block markers ({{#list}}...{{/list}}) in dedicated rows to create multi-level layouts:

data = %{
  "categories" => [
    %{
      "name" => "Electronics",
      "total" => "$300",
      "subtotal" => "$300",
      "items" => [
        %{"desc" => "Phone", "price" => "$200"},
        %{"desc" => "Charger", "price" => "$100"}
      ]
    }
  ]
}

Template structure (marker rows are removed from output):

| {{#categories}} |             |         |  ← Marker row (removed)
| {{name}}        | {{total}}   |         |   Header row
| {{#items}}      |             |         |  ← Marker row (removed)
|                 | {{desc}}    | {{price}}|   Body row (repeated per child)
| {{/items}}      |             |         |   Marker row (removed)
|                 | Subtotal:   | {{subtotal}} |  Footer row
| {{/categories}} |             |         |   Marker row (removed)

Generated output:

| Electronics |       |       |
|             | Phone | $200  |
|             | Charger| $100 |
|             | Subtotal: | $300 |

Block marker features:

  • Dedicated rows: Block markers must be in their own rows (removed from output)
  • Nested blocks: Support for parent/child iteration with separate data scoping
  • Data inheritance: Child rows access both parent and child data fields
  • Empty handling: Empty parent list produces no rows; empty children skips body rows

Conditional Sections

Control which sections of your document appear based on data conditions using {{if condition}}...{{endif}} markers. Sections are shown when the condition is truthy and hidden when falsy.

# Template structure:
# Standard content here.
#
# {{if show_disclaimer}}
# DISCLAIMER: This is a legal disclaimer that appears
# only when show_disclaimer is true.
# {{endif}}
#
# {{if include_pricing}}
# Pricing: $100/month
# {{endif}}

data = %{
  "show_disclaimer" => true,
  "include_pricing" => false
}
Ootempl.render("contract_template.docx", data, "contract.docx")
#=> :ok
# Generated document includes disclaimer section, excludes pricing section

If/Else Support:

Use {{else}} markers to show alternative content when a condition is false:

# Template structure:
# Dear Customer,
#
# {{if is_premium}}
# Thank you for being a premium member! You get 20% off.
# {{else}}
# Become a premium member today for 20% off all purchases.
# {{endif}}
#
# Thank you!

data_premium = %{"is_premium" => true}
Ootempl.render("letter.docx", data_premium, "premium_letter.docx")
# Output: "Thank you for being a premium member! You get 20% off."

data_standard = %{"is_premium" => false}
Ootempl.render("letter.docx", data_standard, "standard_letter.docx")
# Output: "Become a premium member today for 20% off all purchases."

Truthiness rules:

  • Truthy: non-nil, non-false, non-empty string, non-zero number
  • Falsy: nil, false, "" (empty string), 0, 0.0

Conditional features:

  • Case-insensitive markers: {{IF name}}, {{if NAME}}, {{ELSE}} all work
  • Nested data paths: {{if customer.active}}
  • Optional {{else}} for alternative content
  • Multi-paragraph sections supported
  • Sections can contain tables, images, lists, etc.

Document Properties

Placeholders in document metadata (title, author, company) are automatically replaced. Use this feature to populate document properties from your data.

# Template has placeholders in File > Properties:
# - Title: {{document_title}}
# - Author: {{author}}
# - Company: {{company_name}}

data = %{
  "document_title" => "Q4 Financial Report",
  "author" => "Jane Smith",
  "company_name" => "Acme Corporation"
}
Ootempl.render("report_template.docx", data, "Q4_report.docx")
#=> :ok
# Generated document has Title, Author, and Company fields populated

Supported property fields:

  • Core properties: dc:title, dc:subject, dc:description, dc:creator
  • App properties: Company, Manager

Headers, Footers, Footnotes, and Endnotes

Placeholders in headers, footers, footnotes, and endnotes are processed just like the main document body:

# Template has:
# - Header with: {{company_name}} - {{document_title}}
# - Footer with: Page {{page}} of {{total_pages}}
# - Footnote with: {{footnote_citation}}

data = %{
  "company_name" => "Acme Corp",
  "document_title" => "Annual Report",
  "footnote_citation" => "Source: Annual Review 2025"
}
Ootempl.render("template.docx", data, "output.docx")

Image Replacement

Replace placeholder images in templates with dynamic images from your data. Use the alt text field in Word to mark placeholder images with {{image:name}} markers.

Preparing templates in Word:

  1. Insert a placeholder image (any PNG, JPEG, or GIF)
  2. Right-click the image → "View Alt Text" (or "Edit Alt Text")
  3. Set the alt text to {{image:placeholder_name}} (e.g., {{image:company_logo}})
  4. Save the template

Data structure:

Provide image file paths in your data map using the placeholder name as the key:

data = %{
  "company_logo" => "/path/to/logo.png",
  "employee_photo" => "/path/to/photo.jpg",
  "signature" => "/path/to/signature.gif"
}
Ootempl.render("template.docx", data, "output.docx")
#=> :ok

Image format support:

  • PNG - Portable Network Graphics (.png)
  • JPEG - Joint Photographic Experts Group (.jpg, .jpeg)
  • GIF - Graphics Interchange Format (.gif)

Automatic dimension scaling:

Images are automatically scaled to fit the placeholder dimensions while preserving aspect ratio. The library calculates the minimum scale factor needed to fit the image within the template bounds:

# Template has 200x100 EMU placeholder
# Image is 800x600 pixels → scaled by 0.25x to fit
# Image is 150x75 pixels → scaled by 1.33x to fill space

data = %{"logo" => "large_image.png"}
Ootempl.render("template.docx", data, "output.docx")
# Image automatically scaled to fit placeholder bounds

Multiple images:

Templates can contain multiple placeholder images, each with a unique marker:

# Template contains three images:
# - Header logo with alt text: {{image:company_logo}}
# - Employee photo with alt text: {{image:employee_photo}}
# - Footer signature with alt text: {{image:signature}}

data = %{
  "company_logo" => "assets/logo.png",
  "employee_photo" => "photos/john_doe.jpg",
  "signature" => "signatures/ceo.gif"
}
Ootempl.render("contract_template.docx", data, "contract.docx")
#=> :ok
# All three images replaced with dynamic content

Error handling:

Image replacement returns errors for missing data or invalid files:

# Missing image key in data
data = %{"name" => "John"}
Ootempl.render("template.docx", data, "output.docx")
#=> {:error, %Ootempl.ImageError{
#     message: "Image placeholder '{{image:logo}}' has no corresponding data key 'logo'",
#     placeholder_name: "logo",
#     image_path: nil,
#     reason: :image_not_found_in_data
#   }}

# Image file doesn't exist
data = %{"logo" => "nonexistent.png"}
Ootempl.render("template.docx", data, "output.docx")
#=> {:error, %Ootempl.ImageError{
#     message: "Image file not found for placeholder 'logo': nonexistent.png",
#     placeholder_name: "logo",
#     image_path: "nonexistent.png",
#     reason: :file_not_found
#   }}

# Unsupported format
data = %{"logo" => "document.pdf"}
Ootempl.render("template.docx", data, "output.docx")
#=> {:error, %Ootempl.ImageError{
#     message: "Unsupported image format for placeholder 'logo': document.pdf (format: .pdf, only PNG, JPEG, GIF supported)",
#     placeholder_name: "logo",
#     image_path: "document.pdf",
#     reason: :unsupported_format
#   }}

Architecture

The library is organized into several modules:

Error Handling

The main render/3 function returns:

  • :ok on success (document generated successfully)
  • {:error, %PlaceholderError{}} when placeholders cannot be resolved
  • {:error, exception} on structural failures

Specific error types:

Summary

Functions

Inspects a template to discover placeholders, conditionals, and validate syntax.

Loads and pre-processes a .docx template for batch rendering.

Renders a .docx template with data to generate an output document.

Validates that a template can be successfully rendered with provided data.

Functions

inspect(template)

@spec inspect(Path.t() | Ootempl.Template.t()) ::
  {:ok, Ootempl.TemplateInfo.t()} | {:error, term()}

Inspects a template to discover placeholders, conditionals, and validate syntax.

This function analyzes a template without performing any data replacement. It returns detailed information about:

  • All variable placeholders found in the template
  • All conditional markers ({{if}}, {{else}}, {{endif}})
  • Required top-level data keys
  • Syntax validation errors (unclosed conditionals, malformed placeholders, etc.)

This is useful for:

  • Template discovery - Finding what data fields a template requires
  • Validation - Checking template syntax before rendering
  • Documentation - Generating documentation about template requirements
  • Dynamic UIs - Building forms based on template placeholders
  • Debugging - Understanding template structure and errors

Parameters

  • template - Either:
    • A file path (String) to a .docx template - loads and inspects in one call
    • A %Template{} struct from Ootempl.load/1 - faster for batch operations

Returns

  • {:ok, %TemplateInfo{}} with inspection results
  • {:error, term()} for invalid files or structural errors

Examples

### Single Template (Convenience)

# Inspect a contract template
{:ok, info} = Ootempl.inspect("contract_template.docx")

# Check if template is valid
if info.valid? do
  IO.puts("✓ Template is valid")
else
  IO.puts("✗ Found #{length(info.errors)} errors")
  Enum.each(info.errors, fn err ->
    IO.puts("  - [#{err.type}] #{err.message}")
  end)
end

# Discover required data keys
IO.puts("Required data keys: #{Enum.join(info.required_keys, ", ")}")
#=> Required data keys: customer, total, items

### Batch Inspection (Optimized)

# Load template once
{:ok, template} = Ootempl.load("invoice_template.docx")

# Inspect the same template multiple times (fast - no I/O)
templates = ["v1.docx", "v2.docx", "v3.docx"]
Enum.each(templates, fn path ->
  {:ok, tmpl} = Ootempl.load(path)
  {:ok, info} = Ootempl.inspect(tmpl)
  IO.puts("#{path}: #{length(info.placeholders)} placeholders")
end)

### Placeholder Details

# List all placeholders
Enum.each(info.placeholders, fn ph ->
  IO.puts("Placeholder: #{ph.original}")
  IO.puts("  Path: #{Enum.join(ph.path, ".")}")
  IO.puts("  Found in: #{inspect(ph.locations)}")
end)
#=> Placeholder: {{customer.name}}
#     Path: customer.name
#     Found in: [:document_body, :header1]

# List conditionals
Enum.each(info.conditionals, fn cond ->
  IO.puts("Conditional: {{if #{cond.condition}}}")
  IO.puts("  Path: #{Enum.join(cond.path, ".")}")
end)
#=> Conditional: {{if show_disclaimer}}
#     Path: show_disclaimer

Detectable Errors

  • Unclosed conditionals - {{if condition}} without matching {{endif}}
  • Orphan markers - {{endif}} or {{else}} without matching {{if}}
  • Nested conditionals - Conditional blocks inside other blocks (not supported)
  • Malformed placeholders - Invalid placeholder syntax
  • Invalid conditional syntax - Empty conditions, missing closing }}

Template Coverage

Scans all parts of the document:

  • Document body
  • Headers (header1.xml, header2.xml, header3.xml)
  • Footers (footer1.xml, footer2.xml, footer3.xml)
  • Footnotes
  • Endnotes
  • Document properties (core.xml, app.xml)

Limitations

  • Cannot determine if placeholders are in table template rows (requires data shape)
  • Does not validate whether data would satisfy placeholders (use validate/2 for that)
  • Deduplicates placeholders across locations (same placeholder reported once)

load(template_path)

@spec load(Path.t()) :: {:ok, Ootempl.Template.t()} | {:error, term()}

Loads and pre-processes a .docx template for batch rendering.

This function reads a .docx template file once, parses all XML structures, normalizes them, and returns a %Template{} struct that can be reused for multiple render operations. This provides significant performance benefits when generating multiple documents from the same template.

Performance

Loading a template eliminates ~40% of rendering time for batch operations:

  • File I/O: ~20% savings
  • XML parsing: ~18% savings
  • Normalization: ~0.2% savings

For example, generating 100 invoices:

  • Without pre-loading: ~10ms × 100 = 1000ms
  • With pre-loading: ~60ms (load) + ~6ms × 100 = 660ms (34% faster)

Parameters

  • template_path - Path to the .docx template file

Returns

  • {:ok, %Template{}} on success
  • {:error, reason} on failure (invalid file, corrupt ZIP, etc.)

Examples

# Load template once
{:ok, template} = Ootempl.load("invoice_template.docx")

# Render multiple documents (reusing parsed template)
customers
|> Enum.each(fn customer ->
  data = %{"name" => customer.name, "total" => customer.balance}
  Ootempl.render(template, data, "invoice_#{customer.id}.docx")
end)

Error Cases

Same validation errors as render/3:

  • Template file does not exist
  • Template is not a valid .docx file
  • Template has invalid XML structure

render(template, data, output_path, opts \\ [])

@spec render(Path.t() | Ootempl.Template.t(), map() | struct(), Path.t(), keyword()) ::
  :ok | {:error, term()}

Renders a .docx template with data to generate an output document.

This function accepts either a template file path (String) or a pre-loaded %Template{} struct. Using a pre-loaded template is significantly faster for batch operations.

Replaces {{variable}} placeholders in the template with values from the data map, supporting nested data access with dot notation (e.g., {{customer.name}}). Case-insensitive matching ensures {{Name}}, {{name}}, and {{NAME}} all match the same data key.

Parameters

  • template - Either:
    • A file path (String) to a .docx template - loads, parses, and renders in one call
    • A %Template{} struct from Ootempl.load/1 - skips loading/parsing (fast)
  • data - Map of data for placeholder replacement (string keys)
  • output_path - Path where the generated .docx file should be saved
  • opts - Optional keyword list:
    • :filters - Map of custom formatting filters (%{"name" => fun/2}) for this render only. These are layered over the built-ins and any filters configured via config :ootempl, filters: %{...}, and may override them. See Ootempl.Filters.

Formatting filters

Placeholders may declare formatting filters Jinja/Liquid style:

{{ invoice.date | date: "%d %B %Y" }}
{{ total | round: 2 | currency: "USD" }}

See Ootempl.Filters for the built-in filters and how to register your own.

Returns

  • :ok on success
  • {:error, %PlaceholderError{}} when placeholders cannot be resolved
  • {:error, exception} on structural failures (invalid file, corrupt ZIP, etc.)

Examples

### Single Document (Convenience API)

data = %{
  "name" => "John Doe",
  "customer" => %{"email" => "john@example.com"},
  "total" => 99.99
}
Ootempl.render("template.docx", data, "output.docx")
#=> :ok

### Batch Processing (Optimized API)

# Load template once
{:ok, template} = Ootempl.load("invoice_template.docx")

# Render many documents (40% faster)
Enum.each(customers, fn customer ->
  data = %{"name" => customer.name, "total" => customer.balance}
  Ootempl.render(template, data, "invoice_#{customer.id}.docx")
end)

### Error Handling

# Missing placeholders (collects all errors)
Ootempl.render("template.docx", %{}, "output.docx")
#=> {:error, %Ootempl.PlaceholderError{
#     message: "2 placeholders could not be resolved (first: {{name}})",
#     placeholders: [
#       %{placeholder: "{{name}}", reason: {:path_not_found, ["name"]}},
#       %{placeholder: "{{customer.email}}", reason: {:path_not_found, ["customer", "email"]}}
#     ]
#   }}

# Structural errors
Ootempl.render("missing.docx", %{}, "out.docx")
#=> {:error, %Ootempl.ValidationError{reason: :file_not_found}}

Error Cases

Structural Errors (fail-fast)

  • Template file does not exist
  • Template is not a valid .docx file
  • Template and output are the same file
  • Output directory does not exist or is not writable
  • Insufficient disk space
  • Template file is locked/in use

Placeholder Errors (collected and returned together)

  • Placeholder not found in data map
  • Invalid nested path
  • Nil values in data
  • Unsupported data types (maps, lists as values)

validate(template, data)

@spec validate(Path.t() | Ootempl.Template.t(), map() | struct()) ::
  :ok | {:error, term()}

Validates that a template can be successfully rendered with provided data.

This function runs the complete rendering pipeline (conditionals, tables, placeholders, images) without creating output files. It returns :ok if rendering would succeed, or the same errors that render/3 would return.

This is useful for:

  • Pre-flight validation - Check if data satisfies all placeholders before batch operations
  • Testing - Verify template/data combinations without filesystem I/O
  • API endpoints - Validate user input before expensive operations
  • Debugging - Quickly test if data structure matches template requirements

Parameters

  • template - Either:
    • A file path (String) to a .docx template
    • A %Template{} struct from Ootempl.load/1 (faster)
  • data - Map or struct of data for placeholder replacement

Returns

  • :ok if rendering would succeed
  • {:error, %PlaceholderError{}} when placeholders cannot be resolved
  • {:error, %ImageError{}} when image processing fails
  • {:error, exception} on structural failures

Examples

# Validate before rendering
data = %{"name" => "John", "total" => 99.99}

case Ootempl.validate("invoice.docx", data) do
  :ok ->
    # Safe to render
    Ootempl.render("invoice.docx", data, "output.docx")

  {:error, %Ootempl.PlaceholderError{} = error} ->
    IO.puts("Missing placeholders: #{inspect(error.placeholders)}")

  {:error, reason} ->
    IO.puts("Validation failed: #{inspect(reason)}")
end

# Batch validation
templates = ["contract.docx", "invoice.docx", "receipt.docx"]
Enum.filter(templates, fn template ->
  match?(:ok, Ootempl.validate(template, data))
end)

# With pre-loaded template (faster for batch validation)
{:ok, template} = Ootempl.load("invoice.docx")

customers
|> Enum.filter(fn customer ->
  data = %{"name" => customer.name, "total" => customer.balance}
  match?(:ok, Ootempl.validate(template, data))
end)
|> Enum.each(fn customer ->
  data = %{"name" => customer.name, "total" => customer.balance}
  Ootempl.render(template, data, "invoice_#{customer.id}.docx")
end)