Tincture (Tincture v0.1.0)

Copy Markdown View Source

Typographic-quality PDF generation, in pure Elixir.

A document is an immutable struct threaded through a pipeline. Nothing is written until export/1, so a document can be built, inspected and tested before any bytes exist.

Tincture.new()
|> Tincture.page_size(:a4)
|> Tincture.set_font("Helvetica", 12)
|> Tincture.text_at(72, 700, "Hello")
|> Tincture.export()

Coordinates

PDF user space has its origin at the bottom-left of the page, with y increasing upwards — the opposite of most screen coordinate systems. A y of 700 on A4 is near the top. All positions and sizes are in points (1/72 inch), so A4 is 595 x 842 and US Letter is 612 x 792.

Text

text_at/4 draws a single string at a point. For anything that needs to wrap, use text_paragraph/6, which runs the typography engine: TeX hyphenation and Knuth-Plass line breaking, the same global optimisation TeX uses, rather than greedy first-fit.

rich = Tincture.Typography.RichText.from_plain(body, font: "Times-Roman", size: 11)

Tincture.text_paragraph(pdf, 72, 700, rich, 450,
  align: :justified,
  line_break: :optimal
)

For multi-page documents with headers, footers and page numbering, see Tincture.Layout.Template.

Fonts

The 14 standard PDF fonts are always available by name. TrueType and OpenType fonts are embedded with register_ttf_font/4 and register_otf_font/4, and are subsetted by default — only the glyphs the document draws are embedded, which typically cuts an embedded font by 70-90%.

Architecture

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

flowchart TD
    A["Tincture<br/>drawing, text, links, images"] --> B
    B["Tincture.Layout<br/>boxes, tables, page templates"] --> C
    C["Tincture.Typography<br/>hyphenation, line breaking, kerning"] --> D
    D["Tincture.PDF<br/>document state, fonts, serialisation"] --> E
    E["Tincture.Font<br/>TrueType, OpenType, CFF parsing"]

Zero runtime dependencies

No Chrome, no wkhtmltopdf, no NIFs, no ports. The whole pipeline — font parsing, subsetting, hyphenation, line breaking, PDF serialisation — is Elixir and Erlang/OTP. It runs anywhere the BEAM runs.

Summary

Functions

Add a document bookmark pointing to a page number.

Add a new page and make it the current page.

Mark a block of drawing as an artifact: decoration, not meaning.

Add a cubic Bezier segment to the current path.

Add a choice field — a dropdown by default, or a list box.

Draw a circle. See rectangle/6 for the paint modes.

Set the clipping path from the current path.

Set the clipping path from the current path using the even-odd rule.

Encrypt the document with AES-256.

Export a PDF struct to a binary.

Fill the current path.

Fill the current path using the even-odd rule.

Draw a JPEG image at X/Y with the given width and height.

Draw a PNG image at X/Y with the given width and height.

Draw a stroked line between two points.

Add a line segment from current path point to X/Y.

Add a clickable link over a rectangular region of the current page.

Move current path point to X/Y.

Create a new PDF state struct.

Set the current page size.

Every PDF/A violation Tincture can detect in this document.

Add a radio button group: one field, several buttons, at most one selected.

Register an embedded OpenType font by name from an .otf file path.

Register an embedded OpenType font by name from an .otf file path with embedding options.

Register an embedded TrueType font by name from a .ttf file path.

Register an embedded TrueType font by name from a .ttf file path with embedding options.

Restore graphics state.

Export and write a PDF to disk.

Save graphics state.

Set dash pattern and phase.

Set fill color using normalized RGB values (0..1).

Set the current font name and size.

Set the document's natural language, as a BCP 47 tag such as "en-GB".

Set line cap style: 0 butt, 1 round, 2 projecting square.

Set line join style: 0 miter, 1 round, 2 bevel.

Set stroke line width.

Set document metadata fields (for example: :title, :author, :keywords).

Set miter limit for stroked joins.

Switch the current page by page number (1-based).

Declare a PDF/A conformance level, for a document that has to outlive its software.

Set stroke color using normalized RGB values (0..1).

Sign a signature field, so the document can be shown not to have changed.

Add a signature field: a place for someone to sign.

Stroke the current path.

Tag a block of content with its logical role, making the document accessible.

Whether the document carries logical structure.

Place text at X/Y coordinates on the current page.

Place text at X/Y coordinates rotated by the given angle in degrees.

Place rotated text at X/Y, splitting glyph runs across fallback fonts when needed.

Place text at X/Y coordinates, splitting glyph runs across fallback fonts when needed.

Draw text at X/Y and make it clickable.

Render a laid-out paragraph of rich text at X/Y origin using typography layout.

Types

page_size()

@type page_size() :: :a4 | :letter | :legal | {number(), number()}

paint()

@type paint() :: Tincture.PDF.paint()

How a shape is painted. See rectangle/6.

paragraph_option()

@type paragraph_option() ::
  {:align, :left | :center | :right | :justified}
  | {:line_height, number()}
  | {:line_break, :greedy | :optimal}
  | {:optimal_cost_model, :quadratic | :box_glue}
  | {:justify_max_space_multiplier, number() | :infinity}
  | {:justify_min_space_multiplier, number()}
  | {:widow_penalty, number()}
  | {:orphan_penalty, number()}
  | {:hyphen_penalty, number()}
  | {:fitness_class_penalty, number()}
  | {:consecutive_hyphen_penalty, number()}
  | {:rotate, number()}
  | {:fallback_fonts, [String.t()]}
  | {:bidi, :off | :basic}
  | {:shaping, :off | :latin_ligatures | :gsub_ligatures}
  | {:kerning, :off | :gpos}

rich_text()

@type rich_text() :: Tincture.Typography.RichText.t()

Functions

add_bookmark(pdf, title, page_number)

@spec add_bookmark(Tincture.PDF.t(), String.t(), pos_integer()) :: Tincture.PDF.t()

Add a document bookmark pointing to a page number.

add_page(pdf)

@spec add_page(Tincture.PDF.t()) :: Tincture.PDF.t()

Add a new page and make it the current page.

artifact(pdf, fun)

@spec artifact(Tincture.PDF.t(), (Tincture.PDF.t() -> Tincture.PDF.t())) ::
  Tincture.PDF.t()

Mark a block of drawing as an artifact: decoration, not meaning.

Rules, borders, background shading, page furniture — content a sighted reader parses as visual structure and a screen reader should skip entirely. In a tagged document every operator must be either tagged or marked as an artifact; anything that is neither is read out as stray noise and fails conformance.

pdf
|> Tincture.artifact(fn pdf ->
  pdf
  |> Tincture.set_stroke_color({0.85, 0.86, 0.88})
  |> Tincture.line(50, 700, 545, 700)
  |> Tincture.stroke()
end)

Tincture.Layout.Table.render/6 does this for its own borders when it is tagging.

bezier(pdf, x1, y1, x2, y2, x3, y3)

@spec bezier(
  Tincture.PDF.t(),
  number(),
  number(),
  number(),
  number(),
  number(),
  number()
) ::
  Tincture.PDF.t()

Add a cubic Bezier segment to the current path.

checkbox(pdf, x, y, size, name, opts \\ [])

@spec checkbox(Tincture.PDF.t(), number(), number(), number(), String.t(), keyword()) ::
  Tincture.PDF.t()

Add a checkbox.

Checkboxes are square, so a single size gives both dimensions.

Options

  • :valuetrue for checked. Defaults to false.
  • :read_only, :required, :no_export — field flags.
  • :tooltip, :border, :page — as for text_field/7.

Examples

Tincture.checkbox(pdf, 72, 700, 14, "accept_terms", value: false)

choice_field(pdf, x, y, width, height, name, opts \\ [])

@spec choice_field(
  Tincture.PDF.t(),
  number(),
  number(),
  number(),
  number(),
  String.t(),
  keyword()
) ::
  Tincture.PDF.t()

Add a choice field — a dropdown by default, or a list box.

Options

  • :optionsrequired, the list of choices.
  • :value — the initially selected choice.
  • :dropdowntrue (default) for a combo box, false for a list box.
  • :editable — allow a value outside the list. Defaults to false.
  • :sort — present the options sorted. Defaults to false.
  • :read_only, :required, :no_export, :tooltip, :border, :page, :font, :size — as for text_field/7.

Examples

Tincture.choice_field(pdf, 72, 700, 200, 24, "country",
  options: ["Italy", "Norway", "Japan"],
  value: "Italy"
)

circle(pdf, cx, cy, radius, paint \\ :stroke)

@spec circle(Tincture.PDF.t(), number(), number(), number(), paint()) ::
  Tincture.PDF.t()

Draw a circle. See rectangle/6 for the paint modes.

clip(pdf)

@spec clip(Tincture.PDF.t()) :: Tincture.PDF.t()

Set the clipping path from the current path.

clip_even_odd(pdf)

@spec clip_even_odd(Tincture.PDF.t()) :: Tincture.PDF.t()

Set the clipping path from the current path using the even-odd rule.

encrypt(pdf, opts \\ [])

@spec encrypt(
  Tincture.PDF.t(),
  keyword()
) :: Tincture.PDF.t()

Encrypt the document with AES-256.

Uses the standard security handler at revision 6 (/V 5 /R 6), the encryption defined by PDF 2.0. Readers from Acrobat X (2010) onward support it, as do macOS Preview, Chrome, Firefox, PDFBox, Ghostscript and qpdf.

What this protects

A user password is real encryption. Without it the document cannot be read at all.

Tincture.encrypt(pdf, user_password: "hunter2")

An owner password on its own is not. The document is encrypted under the empty user password, so any reader can open it; the permissions below are advisory, and qpdf --decrypt strips them without knowing the password. This is how the PDF format works, not a limitation of this implementation.

# Anyone can open this; the restriction is a request, not a control.
Tincture.encrypt(pdf, owner_password: "master", permissions: [:print])

Use an owner password to express intent, and a user password when the content genuinely must not be read.

Options

  • :user_password — required to open the document. Defaults to "".
  • :owner_password — grants full rights. Defaults to the user password.
  • :permissions — what a viewer is asked to allow. Defaults to everything. One or more of :print, :modify, :copy, :annotate, :fill_forms, :extract_for_accessibility, :assemble, :print_high_quality.
  • :encrypt_metadata — encrypt document metadata too. Defaults to true.

Examples

pdf
|> Tincture.text_at(72, 700, "Confidential")
|> Tincture.encrypt(user_password: "hunter2", permissions: [:print])
|> Tincture.export()

Calling this twice replaces the earlier settings; the document is encrypted once, at export.

export(pdf, opts \\ [])

@spec export(
  Tincture.PDF.t(),
  keyword()
) :: binary()

Export a PDF struct to a binary.

Options

  • :enforce — whether to refuse a document that declares a PDF/A level and breaks it. Defaults to true, and only ever applies to a document that called set_pdf_a/2; nothing is checked otherwise.

A conformance claim is written into the file's metadata, so exporting a document that declares PDF/A and violates it produces a file that lies about itself — and nothing discovers that until someone tries to rely on it. Rather than emit one, this raises and names what is wrong.

Pass enforce: false to export anyway. The claim stays in the file, so the result asserts a conformance it does not have; the violations are logged as a warning. It exists for iterating, not for shipping.

Tincture.export(pdf)                   # refuses a false claim
Tincture.export(pdf, enforce: false)   # exports it, and says so

See pdf_a_violations/1 to inspect without exporting.

fill(pdf)

@spec fill(Tincture.PDF.t()) :: Tincture.PDF.t()

Fill the current path.

fill_even_odd(pdf)

@spec fill_even_odd(Tincture.PDF.t()) :: Tincture.PDF.t()

Fill the current path using the even-odd rule.

image_jpeg(pdf, x, y, width, height, path)

@spec image_jpeg(Tincture.PDF.t(), number(), number(), number(), number(), Path.t()) ::
  Tincture.PDF.t()

Draw a JPEG image at X/Y with the given width and height.

image_png(pdf, x, y, width, height, path)

@spec image_png(Tincture.PDF.t(), number(), number(), number(), number(), Path.t()) ::
  Tincture.PDF.t()

Draw a PNG image at X/Y with the given width and height.

line(pdf, x1, y1, x2, y2, paint \\ :stroke)

@spec line(Tincture.PDF.t(), number(), number(), number(), number(), paint()) ::
  Tincture.PDF.t()

Draw a stroked line between two points.

line_to(pdf, x, y)

@spec line_to(Tincture.PDF.t(), number(), number()) :: Tincture.PDF.t()

Add a line segment from current path point to X/Y.

link(pdf, x, y, width, height, target)

Add a clickable link over a rectangular region of the current page.

target is either a URL string (or {:url, url}) for an external link, or {:page, page_number} for an internal cross-reference.

Coordinates are in PDF user space, with the origin at the bottom-left of the page — the same space text_at/4 and rectangle/5 use. The rectangle is given as x, y, width, height; corners are normalised, so a negative width or height still produces a valid region rather than a dead link.

Linking does not draw anything. Use text_link/5 to draw text and make it clickable in one call, or pair this with your own rectangle/5 and set_fill_color/2 calls.

Options

  • :page — which page to attach the link to. Defaults to the current page.
  • :border:none (default) or {horizontal, vertical, width}. The default suppresses the black rectangle most viewers would otherwise draw around every link.

Examples

pdf
|> Tincture.link(72, 700, 120, 14, "https://elixir-lang.org")

# Internal cross-reference to page 3.
Tincture.link(pdf, 72, 680, 120, 14, {:page, 3})

# Attach a link to a page other than the current one.
Tincture.link(pdf, 72, 660, 120, 14, "https://hex.pm", page: 1)

link(pdf, x, y, width, height, target, opts)

move_to(pdf, x, y)

@spec move_to(Tincture.PDF.t(), number(), number()) :: Tincture.PDF.t()

Move current path point to X/Y.

new()

@spec new() :: Tincture.PDF.t()

Create a new PDF state struct.

page_size(pdf, size)

@spec page_size(Tincture.PDF.t(), page_size()) :: Tincture.PDF.t()

Set the current page size.

pdf_a_violations(pdf)

@spec pdf_a_violations(Tincture.PDF.t()) :: [Tincture.PDF.Archival.violation()]

Every PDF/A violation Tincture can detect in this document.

Empty for a document that declares no level. Not a conformance check — Tincture sees the document it built, not the file a validator sees, and PDF/A has requirements no library can settle for you. Validate the output with verapdf --flavour 2b.

push_button(pdf, x, y, width, height, name, opts \\ [])

@spec push_button(
  Tincture.PDF.t(),
  number(),
  number(),
  number(),
  number(),
  String.t(),
  keyword()
) ::
  Tincture.PDF.t()

Add a push button.

A push button holds no value — it exists to do something when clicked — so :action is required.

Options

  • :action — required. One of:
    • :reset — clear every field in the form.
    • {:url, url} — open a URL.
    • {:submit, url} — submit the form's values to a URL.
  • :label — the caption drawn on the button face.
  • :read_only, :required, :no_export — field flags.
  • :tooltip, :border, :font, :size, :page — as for text_field/7.

Examples

pdf
|> Tincture.push_button(72, 100, 90, 24, "submit", label: "Send",
     action: {:submit, "https://example.com/apply"})
|> Tincture.push_button(180, 100, 90, 24, "reset", label: "Clear",
     action: :reset)

radio_group(pdf, name, buttons, opts \\ [])

@spec radio_group(Tincture.PDF.t(), String.t(), [keyword()], keyword()) ::
  Tincture.PDF.t()

Add a radio button group: one field, several buttons, at most one selected.

A radio group is the one field type that is not a single widget. The value lives on the group and each button is its own widget, so buttons are given as a list rather than added one at a time — a lone radio button is not a meaningful thing to place.

Each button needs :value, :x, :y and :size. The :value is what the field takes when that button is chosen, and is what appears in the filled document. "Off" is reserved by the specification for "nothing selected" and is rejected as a button value.

Options

  • :selected — the :value of the initially selected button. Defaults to none selected.
  • :allow_deselect — let clicking the selected button turn it off again. Defaults to false, which is what a radio group usually means.
  • :read_only, :required, :no_export — field flags.
  • :tooltip, :border, :font, :size — as for text_field/7.
  • :page — the default page for buttons that do not name their own.

Examples

Tincture.radio_group(pdf, "delivery", [
  [value: "standard", x: 72, y: 700, size: 12],
  [value: "express", x: 72, y: 680, size: 12],
  [value: "collect", x: 72, y: 660, size: 12]
], selected: "standard")

rectangle(pdf, x, y, width, height, paint \\ :stroke)

@spec rectangle(Tincture.PDF.t(), number(), number(), number(), number(), paint()) ::
  Tincture.PDF.t()

Draw a rectangle.

Painting

paint selects how the shape is painted, and defaults to :stroke:

  • :stroke — outline only
  • :fill — filled with the current fill colour
  • :fill_even_odd — filled using the even-odd rule
  • :fill_and_stroke — both
  • :none — emit the path without painting, to be painted by a later fill/1 or stroke/1

A PDF path-painting operator also ends the path, so a shape can be painted once. Calling fill/1 after a shape that already stroked itself does nothing, because there is no longer a path to fill.

Examples

# A filled band across the top of the page.
pdf
|> Tincture.set_fill_color({0.06, 0.35, 0.55})
|> Tincture.rectangle(0, 746, 595, 96, :fill)

register_otf_font(pdf, font_name, path)

@spec register_otf_font(Tincture.PDF.t(), String.t(), Path.t()) :: Tincture.PDF.t()

Register an embedded OpenType font by name from an .otf file path.

register_otf_font(pdf, font_name, path, opts)

@spec register_otf_font(Tincture.PDF.t(), String.t(), Path.t(), keyword()) ::
  Tincture.PDF.t()

Register an embedded OpenType font by name from an .otf file path with embedding options.

Takes the same options as register_ttf_font/4, including :subset, which defaults to :used_text.

register_ttf_font(pdf, font_name, path)

@spec register_ttf_font(Tincture.PDF.t(), String.t(), Path.t()) :: Tincture.PDF.t()

Register an embedded TrueType font by name from a .ttf file path.

register_ttf_font(pdf, font_name, path, opts)

@spec register_ttf_font(Tincture.PDF.t(), String.t(), Path.t(), keyword()) ::
  Tincture.PDF.t()

Register an embedded TrueType font by name from a .ttf file path with embedding options.

Options

  • :subset — how much of the font to embed. Defaults to :used_text.

    • :used_text — embed only the glyphs the document actually draws. Typically cuts an embedded font by 70-90%.
    • :ascii_basic — embed the printable ASCII range (32..126). Useful when you intend to edit the text later with an external tool.
    • :none — embed the font verbatim.

    Subsetted fonts carry the six-letter /BaseFont tag the PDF specification requires (for example ABCDEF+Inter). If a font cannot be subsetted safely, Tincture logs a warning and embeds it in full rather than producing a broken document.

  • :enforce_embedding_permissions — when true, raise instead of warn if the font's OS/2 fsType restricts embedding or subsetting. Defaults to false.

Examples

Tincture.new()
|> Tincture.register_ttf_font("Inter", "priv/fonts/Inter.ttf")
|> Tincture.set_font("Inter", 12)

# Embed the whole font, e.g. for a template others will edit.
Tincture.register_ttf_font(pdf, "Inter", path, subset: :none)

restore_state(pdf)

@spec restore_state(Tincture.PDF.t()) :: Tincture.PDF.t()

Restore graphics state.

save(pdf, path, opts \\ [])

@spec save(Tincture.PDF.t(), Path.t(), keyword()) :: :ok | {:error, term()}

Export and write a PDF to disk.

Takes the same options as export/2.

save_state(pdf)

@spec save_state(Tincture.PDF.t()) :: Tincture.PDF.t()

Save graphics state.

set_dash(pdf, pattern, phase)

@spec set_dash(Tincture.PDF.t(), [number()], number()) :: Tincture.PDF.t()

Set dash pattern and phase.

set_fill_color(pdf, rgb)

@spec set_fill_color(Tincture.PDF.t(), {number(), number(), number()}) ::
  Tincture.PDF.t()

Set fill color using normalized RGB values (0..1).

set_font(pdf, font_name, size)

@spec set_font(Tincture.PDF.t(), String.t(), number()) :: Tincture.PDF.t()

Set the current font name and size.

set_language(pdf, language)

@spec set_language(Tincture.PDF.t(), String.t()) :: Tincture.PDF.t()

Set the document's natural language, as a BCP 47 tag such as "en-GB".

Without it a screen reader has to guess which language to pronounce the text as, so this is a requirement for an accessible document rather than a nicety.

set_line_cap(pdf, cap)

@spec set_line_cap(Tincture.PDF.t(), 0 | 1 | 2) :: Tincture.PDF.t()

Set line cap style: 0 butt, 1 round, 2 projecting square.

set_line_join(pdf, join)

@spec set_line_join(Tincture.PDF.t(), 0 | 1 | 2) :: Tincture.PDF.t()

Set line join style: 0 miter, 1 round, 2 bevel.

set_line_width(pdf, width)

@spec set_line_width(Tincture.PDF.t(), number()) :: Tincture.PDF.t()

Set stroke line width.

set_metadata(pdf, metadata)

@spec set_metadata(Tincture.PDF.t(), map() | keyword()) :: Tincture.PDF.t()

Set document metadata fields (for example: :title, :author, :keywords).

set_miter_limit(pdf, limit)

@spec set_miter_limit(Tincture.PDF.t(), number()) :: Tincture.PDF.t()

Set miter limit for stroked joins.

set_page(pdf, page_number)

@spec set_page(Tincture.PDF.t(), pos_integer()) :: Tincture.PDF.t()

Switch the current page by page number (1-based).

set_pdf_a(pdf, level)

@spec set_pdf_a(Tincture.PDF.t(), atom()) :: Tincture.PDF.t()

Declare a PDF/A conformance level, for a document that has to outlive its software.

PDF/A (ISO 19005) is what records-retention policies specify. It is mostly a set of constraints rather than new capability, and this adds the three things a document cannot be valid without: an sRGB output intent, so device colour has a defined meaning; XMP metadata carrying the conformance claim; and a file identifier.

pdf
|> Tincture.set_pdf_a(:a2b)
|> Tincture.set_metadata(title: "Annual report 2026")

Levels

  • :a2b — basic. Visual reproduction is preserved.
  • :a2u — as :a2b, and all text has a Unicode mapping, so it can be extracted and searched.
  • :a2a — as :a2u, and the document is tagged. Combine with tag/4 and set_language/2.

:a3b, :a3u and :a3a select part 3, which differs only in permitting arbitrary embedded files.

What this does not do

Declaring a level does not enforce it. PDF/A also requires every font to be embedded — so the standard 14 are not usable, since they are referenced by name rather than embedded — and forbids encryption. Tincture does not refuse to build a document that breaks those rules, because the check belongs to a validator that can see the whole file.

Validate the result. veraPDF is free:

verapdf --flavour 2b out.pdf

Colour

The output intent is sRGB, built into the library rather than read from the system, so a document is reproducible anywhere. Any RGB colour you set is therefore interpreted as sRGB. CMYK is not covered by this output intent and would need its own.

set_stroke_color(pdf, rgb)

@spec set_stroke_color(Tincture.PDF.t(), {number(), number(), number()}) ::
  Tincture.PDF.t()

Set stroke color using normalized RGB values (0..1).

sign(pdf, field_name, opts)

@spec sign(Tincture.PDF.t(), String.t(), keyword()) :: Tincture.PDF.t()

Sign a signature field, so the document can be shown not to have changed.

Place the field first with signature_field/7, then sign it:

pdf
|> Tincture.signature_field(50, 120, 220, 48, "signature")
|> Tincture.sign("signature",
  private_key: key,
  certificate: certificate,
  reason: "I approve this document",
  location: "Sheffield"
)

Unlike everything else in Tincture, this cannot take effect until the file exists: a signature covers the finished bytes, including where every object landed. So it is applied during export/2 — the document merely records the intent. See Tincture.PDF.Sign for how that works.

Options

  • :private_key — required. A decoded key, as :public_key.pem_entry_decode/1 returns.
  • :certificate — required. The signer's certificate, in DER.
  • :chain — intermediate certificates in DER, for a verifier that does not already hold them. Defaults to [].
  • :digest:sha256 (default), :sha384 or :sha512.
  • :signing_time — a DateTime. Defaults to now. Pass one explicitly to make output reproducible.
  • :name, :reason, :location, :contact — shown by the reader.
  • :reserved_bytes — space set aside for the signature. Defaults to 16 kB, enough for RSA-4096 with a chain. Export raises rather than truncates if the signature does not fit.

Reading a key and certificate

[key_entry] = :public_key.pem_decode(File.read!("key.pem"))
key = :public_key.pem_entry_decode(key_entry)

[{:Certificate, certificate, _}] = :public_key.pem_decode(File.read!("cert.pem"))

What a signature here does and does not prove

It proves the document has not changed since it was signed, and who signed it — as far as the certificate can be trusted. It does not prove when: there is no timestamp authority, so the time is the signing machine's own claim. Long-term validation needs an RFC 3161 timestamp, which Tincture does not yet produce.

Signing one document twice, or signing after changing it, needs incremental updates, which Tincture also does not yet produce — so one signature per document.

signature_field(pdf, x, y, width, height, name, opts \\ [])

@spec signature_field(
  Tincture.PDF.t(),
  number(),
  number(),
  number(),
  number(),
  String.t(),
  keyword()
) ::
  Tincture.PDF.t()

Add a signature field: a place for someone to sign.

This reserves the field. Tincture does not yet apply a digital signature — that needs a /ByteRange computed over the finished file — so the field is left unsigned for a viewer to fill.

Options

  • :read_only, :required, :no_export — field flags.
  • :tooltip, :border, :page — as for text_field/7.

Examples

Tincture.signature_field(pdf, 72, 120, 220, 48, "signature",
  tooltip: "Sign here")

stroke(pdf)

@spec stroke(Tincture.PDF.t()) :: Tincture.PDF.t()

Stroke the current path.

tag(pdf, tag, opts \\ [], fun)

Tag a block of content with its logical role, making the document accessible.

An untagged PDF is a picture of a document: the bytes record where each glyph sits and nothing records what it means, so a screen reader has no headings to navigate by, no table structure, and no reliable reading order. Tagging adds that layer — a structure tree in the catalog, and marked content in the page linking the drawn operators to it.

Everything drawn inside fun belongs to the element:

pdf
|> Tincture.set_language("en-GB")
|> Tincture.tag(:document, fn pdf ->
  pdf
  |> Tincture.tag(:h1, fn pdf ->
    Tincture.text_at(pdf, 50, 760, "Quarterly report")
  end)
  |> Tincture.tag(:p, fn pdf ->
    Tincture.text_at(pdf, 50, 730, "Revenue rose by nine per cent.")
  end)
end)

Nesting is how structure is expressed, so a table is a :table containing :tr containing :th and :td:

Tincture.tag(pdf, :table, fn pdf ->
  Tincture.tag(pdf, :tr, fn pdf ->
    pdf
    |> Tincture.tag(:th, [scope: :column], &Tincture.text_at(&1, 50, 700, "Region"))
    |> Tincture.tag(:th, [scope: :column], &Tincture.text_at(&1, 200, 700, "Revenue"))
  end)
end)

Tags

Container tags group other elements and wrap no content of their own: :document, :part, :article, :section, :div, :table, :thead, :tbody, :tfoot, :tr, :list, :list_item, :toc, :index.

Content tags bracket what is drawn: :p, :h1 to :h6, :heading, :th, :td, :label, :list_body, :caption, :figure, :formula, :quote, :block_quote, :code, :note, :reference, :span, :link, :toc_item.

Options

  • :alt — alternative text. Required in practice for :figure: an image with no alt text is invisible to a screen reader.
  • :actual_text — the text this content really represents, for when the glyphs are not the words (a ligature, a decorative capital).
  • :lang — a BCP 47 tag, when this element differs from the document.
  • :title — a human-readable title for the element.
  • :scope:row, :column or :both. Only valid on :th, and what tells a reader which cells a header governs.

What this does and does not give you

It produces the structure: /StructTreeRoot, marked content, the parent tree, /MarkInfo, /Lang, alt text and table scope. That is what assistive technology reads.

It does not certify PDF/UA conformance. That is a validation exercise against a checker such as veraPDF or PAC, and it imposes further rules — on fonts, colour contrast, and metadata — that a library cannot enforce on your behalf. Tag your documents and then verify them.

tagged?(pdf)

@spec tagged?(Tincture.PDF.t()) :: boolean()

Whether the document carries logical structure.

text_at(pdf, x, y, text)

@spec text_at(Tincture.PDF.t(), number(), number(), String.t()) :: Tincture.PDF.t()

Place text at X/Y coordinates on the current page.

text_at_rotated(pdf, x, y, angle_degrees, text)

@spec text_at_rotated(Tincture.PDF.t(), number(), number(), number(), String.t()) ::
  Tincture.PDF.t()

Place text at X/Y coordinates rotated by the given angle in degrees.

text_at_rotated_with_fallback(pdf, x, y, angle_degrees, text, fallback_fonts \\ [])

@spec text_at_rotated_with_fallback(
  Tincture.PDF.t(),
  number(),
  number(),
  number(),
  String.t(),
  [String.t()]
) :: Tincture.PDF.t()

Place rotated text at X/Y, splitting glyph runs across fallback fonts when needed.

text_at_rotated_with_fallback(pdf, x, y, angle_degrees, text, fallback_fonts, opts)

@spec text_at_rotated_with_fallback(
  Tincture.PDF.t(),
  number(),
  number(),
  number(),
  String.t(),
  [String.t()],
  keyword()
) :: Tincture.PDF.t()

text_at_with_fallback(pdf, x, y, text, fallback_fonts \\ [])

@spec text_at_with_fallback(Tincture.PDF.t(), number(), number(), String.t(), [
  String.t()
]) ::
  Tincture.PDF.t()

Place text at X/Y coordinates, splitting glyph runs across fallback fonts when needed.

text_at_with_fallback(pdf, x, y, text, fallback_fonts, opts)

@spec text_at_with_fallback(
  Tincture.PDF.t(),
  number(),
  number(),
  String.t(),
  [String.t()],
  keyword()
) :: Tincture.PDF.t()

text_field(pdf, x, y, width, height, name, opts \\ [])

@spec text_field(
  Tincture.PDF.t(),
  number(),
  number(),
  number(),
  number(),
  String.t(),
  keyword()
) ::
  Tincture.PDF.t()

Add a fillable text field.

The field is a widget on the current page and an entry in the document's interactive form. name addresses the field's value in the filled document, so it must be unique.

Options

  • :value — the initial value. Defaults to empty.
  • :size — font size. Defaults to 0, meaning auto-size: the viewer fits the text to the box, which is usually what you want for a field whose height you chose.
  • :font — defaults to "Helvetica".
  • :multiline — allow line breaks. Defaults to false.
  • :password — mask the value as it is typed. Defaults to false.
  • :max_length — maximum characters accepted.
  • :read_only, :required, :no_export — field flags, all false by default.
  • :tooltip — hover text.
  • :border:none (default) or {horizontal, vertical, width}.
  • :page — which page to place the widget on. Defaults to the current page.

Examples

pdf
|> Tincture.text_field(72, 700, 300, 24, "full_name", tooltip: "Your full name")
|> Tincture.text_field(72, 640, 300, 80, "notes", multiline: true)

text_link(pdf, x, y, text, target)

Draw text at X/Y and make it clickable.

The link rectangle is measured from the text using the current font, so it covers exactly the drawn glyphs. Takes the same options as link/7, plus:

  • :color — an {r, g, b} fill colour to draw the text in. The colour change is wrapped in a save/restore of the graphics state, so it does not leak into later drawing. Defaults to leaving the colour alone, so links are not silently recoloured.

Examples

pdf
|> Tincture.set_font("Helvetica", 12)
|> Tincture.text_link(72, 700, "Elixir", "https://elixir-lang.org")

# Conventional blue link.
Tincture.text_link(pdf, 72, 680, "Hex", "https://hex.pm", color: {0.0, 0.3, 0.8})

text_link(pdf, x, y, text, target, opts)

text_paragraph(pdf, x, y, rich_text, max_width, opts \\ [])

@spec text_paragraph(Tincture.PDF.t(), number(), number(), rich_text(), number(), [
  paragraph_option()
]) ::
  Tincture.PDF.t()

Render a laid-out paragraph of rich text at X/Y origin using typography layout.