Tincture

Typographic-quality PDF generation for Elixir, with no runtime dependencies.

No Chrome, no wkhtmltopdf, no NIFs, no ports. Font parsing, subsetting, hyphenation, line breaking, PDF serialisation, encryption and signing are all Elixir and Erlang/OTP. It runs anywhere the BEAM runs.

Tincture.new()
|> Tincture.page_size(:a4)
|> Tincture.register_ttf_font("Body", "Georgia.ttf")
|> Tincture.set_font("Body", 11)
|> Tincture.text_paragraph(50, 700, RichText.from_plain(text, font: "Body", size: 11), 495,
align: :justified, line_break: :optimal)
|> Tincture.export()

Tincture is a fork of ex_guten by Hugh Watkins (MIT), itself an Elixir port of Joe Armstrong's Erlang erlguten. Most of the engine is his work, carried forward here — see Heritage and NOTICE.

Verified against real validators

Standards conformance is easy to claim and easy to get subtly wrong, so it is checked with veraPDF and OpenSSL rather than asserted:

StandardResult
PDF/UA-1 (accessibility, ISO 14289-1)isCompliant=true — 106/106 rules
PDF/A-2b (archival, ISO 19005-2)isCompliant=true — 144/144 rules
PDF/A-2u (archival, text extractable)isCompliant=true — 146/146 rules
PDF/A-2a (accessible archival)isCompliant=true — 153/153 rules
PKCS#7 detached signaturesverified by openssl cms -verify

Reproduce them yourself — the documents are committed:

verapdf --flavour ua1 examples/output/compliant.pdf
verapdf --flavour 2a examples/output/archival.pdf

Validation earned its place: it found five real defects that 1,200 tests had not, including every content stream declaring a /Length one byte too large, and /Scope on table headers being written somewhere readers ignore. Those are recorded in CHANGELOG.md.

Status

Not published to Hex. The engine is mature, the suite is thorough and the standards output is independently verified, but the API under this name is new and may still move. Publishing claims a name permanently, so it waits.

# mix.exs
{:tincture, github: "thatsme/tincture"}

1,224 tests · 89% coverage · Credo --strict clean · Dialyzer clean · CI on Elixir 1.16–1.19.

What it does

Text and typography — TeX hyphenation (Liang patterns, five locales), Knuth-Plass global line breaking, justification with configurable widow, orphan and hyphen penalties, GPOS kerning, GSUB ligatures, basic bidi.

Fonts — the standard 14, plus TrueType and OpenType embedding with subsetting on by default, typically 70–90% off an embedded font. Full cmap, glyf, CFF, name and OS/2 parsing.

Layout — text boxes with flow and spill, multi-column flow, tables with automatic column widths, page templates with headers, footers and numbering, XML-driven documents.

Graphics — lines, rectangles, circles and Bézier paths with fill, stroke and clip; JPEG and PNG embedding with alpha.

Documents — metadata, bookmarks, hyperlinks, interactive forms (text, choice, checkbox, radio group, push button, signature), AES-256 encryption.

Standards — tagged PDF for accessibility, PDF/A archival output with a built-in sRGB output intent, and detached PKCS#7 digital signatures.

Operations — telemetry spans per document, per page and per embedded font, with :telemetry as an optional dependency so the zero-dependency position holds.

Examples

Seven runnable scripts, each with its PDF committed so you can see the output without running anything. See examples/README.md.

mix examples
invoice.exsA commercial invoice — embedded fonts, a table, justified terms, a payment link
form.exsEvery interactive field type, with generated appearances
accessible.exsA tagged report — headings, table scope, alt text
compliant.exsA PDF/UA template, annotated requirement by requirement
archival.exsPDF/A-2a and PDF/UA-1 in one document
signed.exsA digitally signed agreement
telemetry.exsThe telemetry spans, printed as a document is built

What it does not do

Stated plainly, because a library's gaps matter as much as its features:

ROADMAP.md sets out each of these and roughly in what order.

Heritage

Tincture is the fourth link in a chain, and none of it started here:

erlgutenJoe ArmstrongThe original Erlang typesetting system, named for Gutenberg. Armstrong designed the typographic model, the box/glue layout approach and the PDF object assembly that Tincture still follows.
NGerlgutenCarl WrightThe maintained continuation, which carried erlguten forward with font handling and additional layout work. Last released 2013.
ex_gutenHugh Watkins, MIT, 2026The Elixir port Tincture forks from. Its struct-based (non-gen_server) architecture, TrueType/OpenType embedding, Knuth-Plass implementation and parity suite against Armstrong's original eg_test documents are all retained here.
Tincturethis repositoryContinues the line.

The git history is intentionally preserved rather than squashed, so Hugh Watkins' original commits remain attributable in the log. His copyright notice is retained in LICENSE, and NOTICE records the full chain along with the provenance of the bundled font metrics and hyphenation data under priv/.

Tincture is an independent continuation. It is not endorsed by, affiliated with, or maintained by any of the above authors.

Architecture

Each layer is usable on its own; the one above it is a convenience.

┌──────────────────────────────────────────────┐
Tincture drawing, text, links,
forms, tagging, signing
├──────────────────────────────────────────────┤
Tincture.Layout boxes, tables, templates
├──────────────────────────────────────────────┤
Tincture.Typography hyphenation, line
breaking, kerning
├──────────────────────────────────────────────┤
Tincture.PDF document state, fonts,
serialisation, crypto
├──────────────────────────────────────────────┤
Tincture.Font TrueType, OpenType, CFF
└──────────────────────────────────────────────┘

A document is an immutable struct threaded through a pipeline. Nothing is written until export/2, so a document can be built, inspected and tested before any bytes exist — with one deliberate exception: a signature covers the finished file, so Tincture.PDF.Sign reserves space, measures the real offsets and patches the result.

Module mapping (erlguten → Tincture)

erlguten moduleTincture modulePurpose
eg_pdfTincture.PDFCore PDF state
eg_pdf_pageTincture.PDF.PagePage management
eg_pdf_libTincture.PDF.OpsDrawing operations
eg_pdf_obj / eg_pdf_opTincture.PDF.Serialize, .Object, .FontEmbedBinary assembly
eg_pdf_imageTincture.PDF.ImageImage embedding
eg_font_mapTincture.FontFont registry and metrics
eg_afmTincture.Font.AFMAdobe Font Metrics parsing
eg_richTextTincture.Typography.RichTextRich text representation
eg_line_breakTincture.Typography.LineBreakLine breaking
eg_hyphenateTincture.Typography.HyphenTeX hyphenation
eg_tableTincture.Layout.TableTable layout
eg_blockTincture.Layout.BoxText box layout
eg_xml_lite / eg_xml2richTextTincture.Layout.TemplateXML templates

Modules with no erlguten ancestor: Font.Context, Font.UnicodeRanges, PDF.Archival, PDF.CMS, PDF.Encrypt, PDF.ICC, PDF.Sign, PDF.Structure, Telemetry.

Design decisions

Structs over gen_server. erlguten holds PDF state in a process. ex_guten replaced that with immutable structs and a pipeline API; Tincture keeps it.

Layered. Need raw PDF output? Use Tincture.PDF. Need typesetting? Use the top-level API.

Refuse to lie about the output.set_pdf_a/2 writes a conformance claim into the file, so export/2 refuses to produce one the document does not meet, naming every violation. Passing that check is not conformance — Tincture sees the document it built, not the file a validator sees — which is why the examples are validated rather than trusted.

Development

git clone https://github.com/thatsme/tincture.git
cd tincture
mix deps.get
mix check # format, warnings-as-errors, Credo, Dialyzer, coverage
mix examples # rebuild every example PDF

Benchmarks and showcase renders live under scripts/:

EX_GUTEN_BENCH_ITERS=500 mix run scripts/benchmark_typography.exs
EX_GUTEN_DOC_BENCH_ITERS=100 mix run scripts/benchmark_document.exs
mix run scripts/render_invoice_showcase.exs tmp/invoice_showcase.pdf

Acknowledgments

License

MIT.