Sablon
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:
| Block | pdivh1–h6ulollitabletrthtdcaptiontheadtbodytfoot |
| Inline | spanbstrongiemusdelsubsupabr |
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 (
ulinsideol) 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