Packages

A docx template processor - Elixir port of the Ruby `sablon` gem.

Current section

Files

Jump to
sablon README.md
Raw

README.md

# 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](https://github.com/senny/sablon) and
follows its template syntax, so templates written for either work with both.
## Installation
```elixir
def deps do
[{:sablon, "~> 0.4"}]
end
```
The only runtime dependency is [Jason](https://hex.pm/packages/jason) (JSON parsing for the CLI); XML, HTML and ZIP handling are all built in.
## Usage
```elixir
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»
```
```elixir
%{
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:
```elixir
%{
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 | `p` `div` `h1``h6` `ul` `ol` `li` `table` `tr` `th` `td` `caption` `thead` `tbody` `tfoot` |
| Inline | `span` `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
```elixir
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.
```elixir
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:
```elixir
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:
```elixir
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:
```json
{"_sablon": {"properties": {"start_page_number": 42}}, "title": "Bruschetta"}
```
## Testing your own templates
```elixir
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