atml_pdf
An Elixir library that parses ATML — an XML-based format for defining label layouts — and renders the result to PDF.
Overview
ATML describes a single label as a tree of rows and columns. The library runs a three-stage pipeline:
ATML XML string
→ AtmlPdf.Parser (XML → element structs)
→ AtmlPdf.Layout (resolve dimensions, font inheritance)
→ AtmlPdf.Renderer (element tree → PDF via the pdf library)Demo
Installation
Add atml_pdf to your dependencies in mix.exs:
def deps do
[
{:atml_pdf, "~> 1.0"}
]
endQuick Start
Command line
Render a template file to PDF directly from the shell:
# Output written next to the template (label.pdf)
mix atml_pdf.render label.xml
# Explicit output path
mix atml_pdf.render label.xml /tmp/label.pdfRender to a file
xml = """
<document width="400pt" height="200pt" font-family="Helvetica" font-size="8pt">
<row height="fill">
<col width="fill" vertical-align="center" text-align="center"
font-size="14pt" font-weight="bold">
SHIPPING LABEL
</col>
</row>
</document>
"""
:ok = AtmlPdf.render(xml, "/tmp/label.pdf")Render to binary
{:ok, binary} = AtmlPdf.render_binary(xml)
# binary is a valid PDF you can send over HTTP, write to S3, etc.ATML Language
Document structure
Every ATML template has a single <document> root. Layout is expressed as alternating rows and columns:
<document width="400pt" height="600pt" font-family="Helvetica" font-size="8pt">
<row height="60pt" border-bottom="solid 1pt #000000">
<col width="80pt" vertical-align="center" padding="4pt">
<img src="/assets/logo.png" width="60pt" height="40pt" />
</col>
<col width="fill" vertical-align="center" font-size="14pt" font-weight="bold"
text-align="center">
SHIPPING LABEL
</col>
</row>
<row height="fill" border-bottom="solid 1pt #000000">
<col width="50%" padding="6pt" border-right="solid 1pt #000000">
<row height="fit"><col font-weight="bold" font-size="7pt">SENDER</col></row>
<row height="fill"><col padding-top="4pt">John Doe, 123 Street</col></row>
</col>
<col width="fill" padding="6pt">
<row height="fit"><col font-weight="bold" font-size="7pt">RECIPIENT</col></row>
<row height="fill"><col padding-top="4pt">Jane Smith, 456 Avenue</col></row>
</col>
</row>
<row height="28pt">
<col text-align="center" vertical-align="center"
font-size="11pt" font-weight="bold">
VN-123456789-SG
</col>
</row>
</document>Nesting rules
<document>
└── <row>
└── <col>
├── text
├── <img>
└── <row> ← nest rows inside cols to subdivide further
└── <col><document>→<row>children only<row>→<col>children only<col>→ text,<img>, or<row>(mixed content allowed)-
A
<col>cannot be a direct child of another<col>
Dimensions
| Value | Example | Meaning |
|---|---|---|
| Points | 100pt | Fixed size (1 pt = 1/72 inch) |
| Pixels | 120px | Fixed size (1 px = 0.75 pt) |
| Percentage | 50% | Relative to parent container |
fill | fill |
Consume all remaining space; split equally among fill siblings |
fit | fit | Shrink-wrap to content size |
Spacing (padding)
<!-- all sides -->
<!-- top+bottom | left+right -->
<!-- top | right | bottom | left -->
<!-- per-side override -->
Borders
border="solid 1pt #000000"
border-bottom="dashed 1pt #cccccc"
border-right="dotted 2px #aaaaaa"
border-top="none"
Format: <style> <width> <color> where style is solid, dashed, or dotted,
width is <n>pt or <n>px, and color is #rrggbb or #rgb.
Fonts
Font attributes cascade from <document> down through all descendants. A child
overrides only the attribute it declares; the rest continue to inherit.
<document font-family="Helvetica" font-size="8pt" font-weight="normal">
<row>
<!-- inherits family and weight -->
<row>
<!-- inherits family and 12pt size -->
</col>
</row>
</col>
</row>
</document>| Attribute | Values | Default |
|---|---|---|
font-family | any font name | "Helvetica" |
font-size | <n>pt | 8pt |
font-weight | normal | bold | normal |
Alignment
| Attribute | Values | Default |
|---|---|---|
text-align | left | center | right | left |
vertical-align | top | center | bottom | top |
Images (<img>)
<img> must be a direct child of <col>. Three src formats are supported:
<!-- Local file path -->
<img src="/path/to/logo.png" width="60pt" height="40pt" />
<!-- Standard data URI (browser / tool default) -->
<img src="data:image/png;base64,iVBORw0KGgo..." width="60pt" height="40pt" />
<!-- Legacy base64 prefix -->
<img src="base64:iVBORw0KGgo..." width="60pt" height="40pt" />
Supported MIME types in data URIs: image/png, image/jpeg, image/gif,
image/webp.
Scaling behaviour:
-
One axis fixed, other
fit→ proportional scaling - Both fixed → stretch to fill (no aspect ratio preservation)
-
Both
fit→ intrinsic size
Barcodes
Generate a barcode PNG with Barlix, encode it
as a data URI, and pass it as an <img src>:
barcode_src =
"VN-123456789-SG"
|> Barlix.Code128.encode!()
|> Barlix.PNG.print(xdim: 2, height: 40, margin: 4)
|> then(fn {:ok, iodata} ->
"data:image/png;base64," <> Base.encode64(IO.iodata_to_binary(iodata))
end)<img src="data:image/png;base64,..." width="300pt" height="40pt" />Barlix supports Code39, Code93, Code128, ITF, EAN13, and UPC-E. Add it to your deps:
{:barlix, "~> 0.6"}Mix Task
mix atml_pdf.render renders an ATML template file to a PDF file without
writing any Elixir code.
mix atml_pdf.render TEMPLATE [OUTPUT] [--backend BACKEND]| Argument | Required | Description |
|---|---|---|
TEMPLATE | yes | Path to the ATML XML template file |
OUTPUT | no |
Destination PDF path. Defaults to the template path with .pdf extension |
--backend | no |
PDF backend: PdfAdapter (default) or ExGutenAdapter (UTF-8) |
# Minimal — output written as label.pdf in the same directory
mix atml_pdf.render label.xml
# Explicit output path
mix atml_pdf.render templates/label.xml /tmp/output.pdf
# Use ExGuten backend for UTF-8 / multilingual text
mix atml_pdf.render label.xml /tmp/label.pdf --backend ExGutenAdapter
# Absolute paths work too
mix atml_pdf.render /data/templates/label.xml /data/output/label.pdf
Exit codes: 0 on success, 1 on any error (missing file, parse failure,
render failure).
Backend Configuration
atml_pdf uses a pluggable backend system for PDF generation. This allows you to switch between different PDF libraries based on your needs.
Available Backends
| Backend | Description | UTF-8 Support | Status |
|---|---|---|---|
AtmlPdf.PdfBackend.PdfAdapter |
Default backend using the pdf hex package. Supports WinAnsi encoding only. | ❌ ASCII + Latin-1 | ✅ Stable |
AtmlPdf.PdfBackend.ExGutenAdapter | ExGuten backend with full UTF-8 support and immutable API. | ✅ Full Unicode | ✅ Available |
Configuration
Application-level configuration (affects all render calls):
# config/config.exs
config :atml_pdf,
pdf_backend: AtmlPdf.PdfBackend.PdfAdapter # Default (WinAnsi only)
# Or use ExGuten for UTF-8 support
config :atml_pdf,
pdf_backend: AtmlPdf.PdfBackend.ExGutenAdapterRuntime override (per-document):
# Use PdfAdapter (WinAnsi encoding)
AtmlPdf.render(xml, path, backend: AtmlPdf.PdfBackend.PdfAdapter)
# Use ExGuten (UTF-8 support)
AtmlPdf.render(xml, path, backend: AtmlPdf.PdfBackend.ExGutenAdapter)
# Or when rendering to binary
{:ok, binary} = AtmlPdf.render_binary(xml, backend: AtmlPdf.PdfBackend.ExGutenAdapter)Font registration (ExGutenAdapter)
Every .ttf file in priv/fonts/ is registered automatically at startup
using its filename stem as the font name. The bundled fonts are:
| File | Registered as | Coverage |
|---|---|---|
NotoSans-Regular.ttf | "NotoSans", "NotoSans-Regular" | Latin, Vietnamese, extended Latin |
NotoSansThai-Regular.ttf | "NotoSansThai-Regular" | Thai script |
Drop any additional .ttf into priv/fonts/ and it becomes available as a
font-family value in ATML — no code changes required.
For fonts outside priv/fonts/, register them via application config:
config :atml_pdf, :fonts, [
{"NotoSansCJK-Regular", "/usr/share/fonts/NotoSansCJK-Regular.ttf"}
]All registered TTF fonts are automatically included in the fallback glyph chain, so characters not covered by the primary font are rendered by the first fallback that supports them.
Character encoding
| Backend | Encoding | Supports |
|---|---|---|
PdfAdapter | WinAnsi (ASCII + Latin-1) | English, Western European, common symbols |
ExGutenAdapter | Full UTF-8 | All of the above + Vietnamese, Thai, CJK, Cyrillic, Greek, emoji |
Or per-document
AtmlPdf.render(xml, path, backend: AtmlPdf.PdfBackend.ExGutenAdapter)
## API Reference
### `AtmlPdf.render/3`
@spec render(String.t(), Path.t(), keyword()) :: :ok | {:error, String.t()}
Parses `template`, resolves layout, and writes the PDF to `path`. Returns `:ok`
on success or `{:error, reason}` on failure.
**Options:**
- `:backend` - PDF backend module (defaults to application config or `PdfAdapter`)
- `:compress` - Enable PDF compression (backend-specific)
### `AtmlPdf.render_binary/2`
@spec render_binary(String.t(), keyword()) :: {:ok, binary()} | {:error, String.t()}
Same as `render/3` but returns `{:ok, binary}` instead of writing to disk.
**Options:**
- `:backend` - PDF backend module (defaults to application config or `PdfAdapter`)
- `:compress` - Enable PDF compression (backend-specific)
## Pipeline Modules
| Module | Responsibility |
|---|---|
| `AtmlPdf.Parser` | Parses ATML XML into `%Document{}` / `%Row{}` / `%Col{}` / `%Img{}` structs |
| `AtmlPdf.Layout` | Resolves `fill`, `fit`, `%`, `pt`, `px` dimensions; propagates font inheritance; applies min/max constraints |
| `AtmlPdf.Renderer` | Walks the resolved tree and issues `Pdf.*` calls to produce a PDF process; handles coordinate-system flip (top-down layout → PDF bottom-left origin) |
## Development
Install dependencies
mix deps.get
Compile
mix compile
Run tests
mix test
Run tests with coverage
mix test --cover
Format
mix format
Check formatting (CI)
mix format --check-formatted
## License
MIT