A document template processor for Word .docx files, for Elixir.

Templates are ordinary Word documents sprinkled with MailMerge fields, so they are written in Word itself and keep the styling, numbering and layout defined there. Nothing about the document has to be described in code.

This is a port of the Ruby gem sablon and follows its template syntax, so templates written for either work with both.

Installation

def deps do
  [{:sablon, "~> 0.4"}]
end

The only runtime dependency is Jason (JSON parsing for the CLI); XML, HTML and ZIP handling are all built in.

Usage

template = Sablon.template("template.docx")

Sablon.render_to_file(template, "output.docx", %{
  title: "Fabulous Document",
  technologies: ["Ruby", "Elixir", "HTML"]
})

Sablon.render_to_string/3 returns the document as a binary instead, which is what you want when serving it from a web request.

A document model is process local, so build and render a template in the same process. Rendering is otherwise free of global state and safe to run concurrently.

Writing templates

The notation «=title» below refers to a Word MailMerge field. Insert one with Insert → Field → MergeField and give it the name shown.

Insertion

«=title»
«=post.title»
«=buyer.address.street»

The dot operator is always a lookup, never a function call. It walks maps (string or atom keys), keyword lists and struct fields, and lists also answer first, last, size/length/count and a numeric index.

A field whose value is nil or false is removed together with its text.

Conditionals

«technologies:if»
    ... arbitrary document markup ...
«technologies:endIf»

nil, false and empty lists are falsy; everything else is truthy. A predicate narrows the test, and elsIf and else clauses are supported:

«body:if(present?)»
    ...
«body:elsIf(nil?)»
    ...
«body:else»
    ...
«body:endIf»

Built-in predicates are nil?, present?, blank?, empty?, any?, zero?, positive?, negative?, odd? and even?. Any other name falls back to a lookup, so a context value can expose its own flags.

Loops

«technologies:each(technology)»
    ... use `technology` to refer to the current item ...
«technologies:endEach»

Where the fields sit decides what is repeated. Fields in different table rows repeat rows, fields in different paragraphs repeat paragraphs, and a pair of fields inside one paragraph repeats only the content between them. Loops and conditionals nest.

Comments

«comment»
    ... markup that should not appear in the output ...
«endComment»

Images

Wrap a placeholder image in a pair of fields named after the context key:

«@figure:start»  [placeholder image]  «@figure:end»
%{
  figure: Sablon.content(:image, "logo.png"),
  figure2: Sablon.content(:image, {:data, data}, filename: "chart.png"),
  figure3: Sablon.content(:image, "logo.png", properties: [width: "2cm", height: "2cm"]),
  # or, for a plain path or URL
  "image:figure4" => "https://example.com/logo.png"
}

The placeholder keeps its own size unless both :width and :height are given, in cm or in. The image data is stored once even when the same value is used in the body, a header and a footer.

Content types

A value is inserted as plain text unless it is wrapped. Wrap it with Sablon.content/3, or use a typed context key:

%{
  body: Sablon.content(:html, "<p>Hello</p>"),
  "html:body2" => "<p>Hello</p>",
  "word_ml:footer" => "<w:p><w:r><w:t>Bye</w:t></w:r></w:p>"
}

:string

Newlines become line breaks; everything else keeps the formatting of the merge field it replaces.

:word_ml

WordProcessingML inserted as it is. If every top level node is a valid child of w:p the markup is added inline and only the merge field's run is replaced; otherwise the whole paragraph is replaced. Wrap all text in run tags.

:html

HTML converted to WordProcessingML. Supported out of the box:

Blockp div h1h6 ul ol li table tr th td caption thead tbody tfoot
Inlinespan b strong i em u s del sub sup a br

Inline CSS in style="..." is translated where Open XML has an equivalent: background-color, text-align, color, font-size, font-style, font-weight, text-decoration, vertical-align, border, margin, width, cellspacing, colspan, rowspan and white-space. A style a node cannot use is passed down to its children, which is how a colour set on a div reaches the runs inside it.

Toggle properties without a CSS equivalent can be set through text-decoration with the XML tag name as the value (text-decoration: dstrike), and simple single-value properties by their WordML name without the w: prefix (highlight: cyan).

Lists need ListNumber and ListBullet paragraph styles in the template; sablon clones their numbering definitions so each list numbers independently.

Section properties

Sablon.render_to_file(template, "out.docx", context, %{start_page_number: 7})

Configuration

Sablon.Configuration holds the HTML tag table and the CSS converters, and both can be extended at runtime.

Sablon.configure(fn config ->
  # a tag that only sets a property on the text it wraps
  config.register_html_tag(:bgcyan, :inline, properties: [highlight: "cyan"])

  # a tag handled by an AST class of your own
  config.register_html_tag(:chart, :block, ast_class: MyApp.Chart)

  # a new CSS property, or a replacement for a built-in one
  config.register_style_converter(:run, "custom-highlight", fn value ->
    {"highlight", value}
  end)

  config.remove_html_tag(:span)
  config.remove_style_converter(:run, "font-size")
end)

An AST class is a module with new/3, to_docx/1, children/1, put_children/2 and describe/1; see Sablon.HTMLConverter.Run for the smallest example.

Custom merge fields

Field syntax is pluggable too. A handler decides which fields it claims and builds the statement that renders them:

defmodule UppercaseHandler do
  @behaviour Sablon.Processor.Document.FieldHandlers

  @pattern ~r/^\^/

  @impl true
  def handles?(field) do
    field |> Sablon.Parser.MailMerge.Field.expression() |> String.match?(@pattern)
  end

  @impl true
  def build_statement(state, field, _options) do
    expr =
      field
      |> Sablon.Parser.MailMerge.Field.expression()
      |> String.replace(@pattern, "")
      |> Sablon.Expression.parse()

    {%UppercaseStatement{expr: expr, field: field}, state}
  end
end

Sablon.Processor.Document.register_field_handler(:uppercase, UppercaseHandler)

A statement is any struct whose module has evaluate/2. The handler named :default is used for fields no other handler claims, which is how you support «title» without the leading =.

New document parts can be processed as well:

Sablon.Template.register_processor(~r{word/styles.xml}, MyApp.StyleProcessor)

Command line

mix escript.build
cat context.json | ./sablon template.docx output.docx

Without an output path the document is written to stdout. A reserved "_sablon" key holds settings rather than template data:

{"_sablon": {"properties": {"start_page_number": 42}}, "title": "Bruschetta"}

Testing your own templates

import Sablon.Test.Assertions

test "renders the invoice" do
  Sablon.render_to_file(template, "tmp/invoice.docx", context)
  assert_docx_equal("test/fixtures/invoice_sample.docx", "tmp/invoice.docx")
end

The comparison canonicalises the XML parts first, so indentation, attribute order and compression do not cause false failures.

Differences from the Ruby gem

  • A list nested in a list of the other kind (ul inside ol) becomes a deeper level of the same numbering. The Ruby gem leaves it nested, which produces a document Word rejects.
  • HTML is parsed by a built-in parser rather than libxml2. It implements the optional-end-tag and void-element rules and preserves whitespace between inline tags.
  • Parts the template does not touch are copied through byte for byte instead of being re-serialised.

License

MIT