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 document 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 configured backend)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 page-width="400pt" page-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.Multi-page documents
Content that exceeds the page height is automatically split across multiple
pages. No changes to your template are required — just define <row> elements
as normal and the renderer inserts page breaks as needed.
# Default — auto page split
AtmlPdf.render(xml, "/tmp/report.pdf")
# Trim to a single page (overflow rows are clipped)
AtmlPdf.render(xml, "/tmp/label.pdf", multiple_page: false)
See examples/multi_page_order.xml for a working 40-item order summary that
spans multiple A4 pages.
ATML Language
Document structure
Every ATML template has a single <document> root. Layout is expressed as alternating rows and columns:
<document page-width="400pt" page-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>
└── <break /> ← forces a new page between rows<document>→<row>and<break>children only<row>→<col>children only<col>→ text,<img>, or<row>(mixed content allowed)-
A
<col>cannot be a direct child of another<col> <break>has no attributes or children; ignored in single-page mode
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] [--single-page]| 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) |
--single-page | no | Trim output to a single page; overflow rows are clipped |
# 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
# Multi-page order example (40 items, auto page split)
mix atml_pdf.render examples/multi_page_order.xml --backend ExGutenAdapter
# Trim to single page — overflow rows are clipped
mix atml_pdf.render label.xml /tmp/label.pdf --single-page
# 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 |
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 orPdfAdapter):multiple_page- Whenfalse, trims output to a single page and clips overflow rows. Defaults totrue.: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 orPdfAdapter):multiple_page- Whenfalse, trims output to a single page and clips overflow rows. Defaults totrue.:compress- Enable PDF compression (backend-specific)
Examples
The examples/ directory contains ready-to-render ATML templates:
| File | Description |
|---|---|
examples/shipping_label.xml | Single-page shipping label with logo, barcode, and address |
examples/shipping_label_multilingual.xml | Shipping label with multilingual text (requires ExGutenAdapter) |
examples/multi_page_order.xml | 40-item order summary spanning multiple A4 pages |
examples/two_page_labels.xml |
Two shipping labels in one document separated by <break /> |
mix atml_pdf.render examples/shipping_label.xml
mix atml_pdf.render examples/multi_page_order.xml --backend ExGutenAdapter
mix atml_pdf.render examples/two_page_labels.xmlPipeline 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 backend calls to produce a PDF; handles coordinate-system flip (top-down layout → PDF bottom-left origin) and automatic page splitting |
Documentation
| File | Description |
|---|---|
docs/ATML_language_specs.md | Full ATML language specification — element reference, attribute tables, dimension rules, and layout semantics |
docs/ADAPTER_IMPLEMENTATION.md | Guide for implementing a new PDF backend — behaviour callbacks, contract details, and step-by-step instructions |
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-formattedLicense
MIT