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.

Checked against real validators

Standards conformance is easy to claim and easy to get subtly wrong, so it is checked with independent tools rather than asserted. Conformance belongs to a document, not a library, so every result below names the document it was measured on.

veraPDF 1.30.2 runs in CI on every commit, against these documents as the examples regenerate them; the same job fails if any of them differs from its committed bytes. Each passes on every commit; the counts are as measured at 0.4.0:

Standard Document Passes veraPDF 1.30.2 in CI on every commit; at 0.4.0
PDF/UA-1 (accessibility, ISO 14289-1) compliant.pdf 106 of 106 rules, 2864 of 2864 checks
accessible.pdf 106 of 106 rules, 1698 of 1698 checks
archival.pdf 106 of 106 rules, 2255 of 2255 checks
archival_form.pdf 106 of 106 rules, 1401 of 1401 checks
tagged_form.pdf 106 of 106 rules, 1411 of 1411 checks
signed_archival.pdf 106 of 106 rules, 946 of 946 checks
linked/Main-report.pdf 106 of 106 rules, 830 of 830 checks
linked/Attached documents/Appendix-A.pdf 106 of 106 rules, 3761 of 3761 checks
PDF/A-2b (archival, ISO 19005-2) archival.pdf 144 of 144 rules, 3088 of 3088 checks
archival_form.pdf 144 of 144 rules, 6800 of 6800 checks
signed_archival.pdf 144 of 144 rules, 1538 of 1538 checks
PDF/A-2u (archival, text extractable) archival.pdf 146 of 146 rules, 3700 of 3700 checks
archival_form.pdf 146 of 146 rules, 7192 of 7192 checks
signed_archival.pdf 146 of 146 rules, 1842 of 1842 checks
PDF/A-2a (accessible archival) archival.pdf 153 of 153 rules, 4009 of 4009 checks
archival_form.pdf 153 of 153 rules, 7391 of 7391 checks
signed_archival.pdf 153 of 153 rules, 1997 of 1997 checks

PAC 2026.1, a second and independent PDF/UA checker, was run as a manual audit on each PDF/UA document above, as committed at the commit named:

PAC is a Windows desktop application with no command line, so it is an audit at a point in time rather than a gate. What it found and what remains is in ROADMAP.md.

PKCS#7 detached signatures: the test suite verifies SHA-256, SHA-384 and SHA-512 signatures with an independent verifier that shares no code with the signer. CI verifies signed.pdf and signed_archival.pdf with openssl cms -verify on every commit, and checks that a tampered copy of each fails. That checks the signature's integrity only: the examples' certificates are self-signed, so chain trust is not checked. signed_archival.pdf is a signed PDF/A-2a document, and veraPDF's three PDF/A-2 signature rules run on it: the byte range covers the whole file but the signature, the signature carries the signer's certificate, and it has a single signer.

The veraPDF reports are uploaded as a build artifact on every run, and the release checklist attaches them to the GitHub release, so the claim is checkable without taking anyone's word for it. Reproduce them yourself — the documents are committed:

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

# A cross-document set: a report and the appendix it links into, both tagged.
verapdf --flavour ua1 "examples/output/linked/Main-report.pdf"
verapdf --flavour ua1 "examples/output/linked/Attached documents/Appendix-A.pdf"

CI validates all seventeen on every push, not the two above.

Validation earned its place, and keeps earning it. The first run found five defects the 1,200 tests of the time had not — among them every content stream declaring a /Length one byte too large, and /Scope on table headers written somewhere readers ignore. Since then it has found link annotations unreachable from the structure tree, links with no alternate description, Note elements with no /ID, and — by asking what the validated documents do not contain — four heading levels that had never been checked at all. All are in CHANGELOG.md.

A clean run says only as much as the documents you ran it on. veraPDF does check that a link annotation is nested in a /Link element and carries an alternate description — both are machine rules, ISO 14289-1 clause 7.18.5, tests 1 and 2 in veraPDF 1.30.2's PDF/UA-1 profile. Tincture passed 106/106 for two releases while getting the first of those wrong, because compliant.pdf contained no link annotations: a rule with nothing to match counts as passed, so the score was full marks on a paper that never asked the question. The gap was in the corpus, not the validator. It now carries a tagged link.

Read the table as "these rules held on these documents". Whether the corpus exercises the feature you care about is a separate question, and the one worth asking — so here is the answer, kept honest by a test that fails when it drifts:

What the validated documents actually exercise

Of the 38 structure types Tincture can emit, the validated corpus emits 20:

Caption Document Figure Form H1 H2 L LBody LI Lbl Link Note P Sect TBody TD TH THead TR Table

The other 18 have never been through a validator. Their PDF/UA rules have never run against Tincture's output, so a clean run says nothing about them:

Art BlockQuote Code Div Formula H H3 H4 H5 H6 Index Part Quote Reference Span TFoot TOC TOCI

Form is the newest covered: the tagged form examples own each of their widgets with one, so the clause 7.18 rules for widgets run against the corpus.

H3–H6 are unexercised rather than known-bad: clause 7.4's heading-nesting rules have only ever been run two levels deep.

This list is the honest scope of the conformance claim above. It is meant to shrink.

Install

# mix.exs
{:tincture, "~> 0.4.0"}

Upgrading from 0.3.x: 0.4.0 turns the 0.3.x deprecation warnings into errors, and adds a few refusals of its own. The CHANGELOG lists each one with its message and what to do instead.

Docs on HexDocs. 0.x is deliberate: the engine is mature and the standards output is checked by the validators above, but the API under this name is new and may still move before 1.0.

At 0.4.0: 1,867 tests · coverage: CI fails below 80%; 91.8% in CI at 0.4.0 · Credo --strict clean, with pre-existing findings in lib/ exempted inline one by one and test/ and scripts/ exempt from the structural checks · 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; axial and radial gradients with any number of stops; constant alpha on fill and stroke; JPEG and PNG embedding with alpha.

Documents — metadata, bookmarks, hyperlinks, links into another PDF file at a given page, interactive forms (text, choice, checkbox, radio group, push button, signature), AES-256 encryption.

Every form field carries its own appearance stream, so it displays the same in every viewer and in print; an invisible signature field, which draws nothing, is the one exception. Fields may use embedded fonts. A form can be tagged, each widget owned by a Form structure element, and archival: a form or a signed document can be PDF/A-2a and PDF/UA-1 at once.

Three limitations sit in the viewer, not the file, and each is documented on the function it concerns:

A viewer that saves a filled form may remove the PDF/A identification and add objects of its own: PDF/A describes the file Tincture writes, not the file after someone has filled it in (set_pdf_a/2).

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

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

mix examples
gradient.exs A report cover — axial and radial gradients, alpha, a watermark
invoice.exs A commercial invoice — embedded fonts, a table, justified terms, a payment link
form.exs Every interactive field type, each with a generated appearance
accessible.exs A tagged report — headings, table scope, alt text
compliant.exs A PDF/UA template, annotated requirement by requirement
archival.exs PDF/A-2a and PDF/UA-1 in one document
archival_form.exs An interactive form in a tagged PDF/A-2a document, also PDF/UA-1
tagged_form.exs An accessible form, PDF/UA-1: every field type, each widget owned by a Form element
signed.exs A digitally signed agreement
signed_archival.exs A signed document that is PDF/A-2a and PDF/UA-1 at once
remote_links.exs A tagged report linking into an appendix in a separate file
stamp.exs An approval stamp drawn once as a form XObject and placed four times
telemetry.exs The 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:

erlguten Joe Armstrong The 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.
NGerlguten Carl Wright The maintained continuation, which carried erlguten forward with font handling and additional layout work. Last released 2013.
ex_guten Hugh Watkins, MIT, 2026 The 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.
Tincture this repository Continues 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)

Names in plain text are internal — they record where the code came from, not an API you can call.

erlguten module Tincture module Purpose
eg_pdf Tincture.PDF Core PDF state
eg_pdf_page Tincture.PDF.Page Page management
eg_pdf_lib Tincture.PDF.Ops Drawing operations
eg_pdf_obj / eg_pdf_op Tincture.PDF.Serialize, .Object, .FontEmbed Binary assembly
eg_pdf_image Tincture.PDF.Image Image embedding
eg_font_map Tincture.Font Font registry and metrics
eg_afm Tincture.Font.AFM Adobe Font Metrics parsing
eg_richText Tincture.Typography.RichText Rich text representation
eg_line_break Tincture.Typography.LineBreak Line breaking
eg_hyphenate Tincture.Typography.Hyphen TeX hyphenation
eg_table Tincture.Layout.Table Table layout
eg_block Tincture.Layout.Box Text box layout
eg_xml_lite / eg_xml2richText Tincture.Layout.Template XML templates

Among the 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.