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")
#=> :okStruct 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")
#=> :okStruct features:
- Nested structs: Access fields multiple levels deep
- Case-insensitive:
{{customer.Name}}matches:namefield - 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")
#=> :okTemplate 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 templateGenerated 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 sectionIf/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 populatedSupported 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:
- Insert a placeholder image (any PNG, JPEG, or GIF)
- Right-click the image → "View Alt Text" (or "Edit Alt Text")
- Set the alt text to
{{image:placeholder_name}}(e.g.,{{image:company_logo}}) - 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")
#=> :okImage 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 boundsMultiple 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 contentError 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:
Ootempl.Archive- ZIP archive operations for .docx filesOotempl.Xml- XML parsing and serialization using :xmerlOotempl.Xml.Normalizer- XML normalization for fragmented placeholdersOotempl.Placeholder- Placeholder detection and parsingOotempl.DataAccess- Nested data access with case-insensitive matchingOotempl.Conditional- Conditional marker detection, evaluation, and section processingOotempl.Replacement- Placeholder replacement in XML with formatting preservationOotempl.Table- Table structure detection, template row identification, and duplicationOotempl.Validator- Document validation and error handling
Error Handling
The main render/3 function returns:
:okon success (document generated successfully){:error, %PlaceholderError{}}when placeholders cannot be resolved{:error, exception}on structural failures
Specific error types:
Ootempl.PlaceholderError- One or more placeholders cannot be resolvedOotempl.ValidationError- File validation failuresOotempl.InvalidArchiveError- Invalid ZIP structureOotempl.MissingFileError- Required files missingOotempl.MalformedXMLError- XML parsing failures
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
@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 fromOotempl.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_disclaimerDetectable 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/2for that) - Deduplicates placeholders across locations (same placeholder reported once)
@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
@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 fromOotempl.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 savedopts- 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 viaconfig :ootempl, filters: %{...}, and may override them. SeeOotempl.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
:okon 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)
@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 fromOotempl.load/1(faster)
data- Map or struct of data for placeholder replacement
Returns
:okif 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)