Packages

Typographic-quality PDF generation with no runtime dependencies. TeX hyphenation, Knuth-Plass line breaking, TrueType/OpenType embedding with subsetting, tables, forms, AES-256 encryption, and verified PDF/UA and PDF/A output.

Current section

Files

Jump to
tincture lib tincture.ex
Raw

lib/tincture.ex

defmodule Tincture do
@moduledoc """
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.
```mermaid
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.
"""
import Bitwise
require Logger
alias Tincture.Font
alias Tincture.Font.Context
alias Tincture.Font.UnicodeRanges
alias Tincture.PDF
alias Tincture.PDF.Archival
alias Tincture.PDF.Ops
alias Tincture.PDF.Serialize
alias Tincture.Typography
alias Tincture.Typography.Line
alias Tincture.Typography.RichText
alias Tincture.Typography.RichText.Break
alias Tincture.Typography.RichText.Space
alias Tincture.Typography.RichText.Word
alias Tincture.Unicode
@type page_size :: :a4 | :letter | :legal | {number(), number()}
@typedoc "How a shape is painted. See `rectangle/6`."
@type paint :: PDF.paint()
@type rich_text :: RichText.t()
@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}
@doc """
Create a new PDF state struct.
"""
@spec new() :: PDF.t()
def new do
%PDF{}
end
@doc """
Set the current page size.
"""
@spec page_size(PDF.t(), page_size()) :: PDF.t()
def page_size(%PDF{} = pdf, size) when size in [:a4, :letter, :legal] do
%PDF{pdf | page_size: size}
end
def page_size(%PDF{} = pdf, {width, height})
when is_number(width) and is_number(height) and width > 0 and height > 0 do
%PDF{pdf | page_size: {width, height}}
end
@doc """
Set document metadata fields (for example: `:title`, `:author`, `:keywords`).
"""
@spec set_metadata(PDF.t(), map() | keyword()) :: PDF.t()
def set_metadata(%PDF{} = pdf, metadata) when is_map(metadata) or is_list(metadata) do
PDF.set_metadata(pdf, metadata)
end
@doc """
Add a new page and make it the current page.
"""
@spec add_page(PDF.t()) :: PDF.t()
def add_page(%PDF{} = pdf) do
PDF.add_page(pdf)
end
@doc """
Switch the current page by page number (1-based).
"""
@spec set_page(PDF.t(), pos_integer()) :: PDF.t()
def set_page(%PDF{} = pdf, page_number) when is_integer(page_number) and page_number > 0 do
PDF.set_page(pdf, page_number)
end
@doc """
Add a document bookmark pointing to a page number.
"""
@spec add_bookmark(PDF.t(), String.t(), pos_integer()) :: PDF.t()
def add_bookmark(%PDF{} = pdf, title, page_number) do
PDF.add_bookmark(pdf, title, page_number)
end
@doc """
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)
"""
@spec link(PDF.t(), number(), number(), number(), number(), PDF.link_target() | String.t()) ::
PDF.t()
def link(%PDF{} = pdf, x, y, width, height, target) do
link(pdf, x, y, width, height, target, [])
end
@spec link(
PDF.t(),
number(),
number(),
number(),
number(),
PDF.link_target() | String.t(),
keyword()
) :: PDF.t()
def link(%PDF{} = pdf, x, y, width, height, target, opts)
when is_number(x) and is_number(y) and is_number(width) and is_number(height) and
is_list(opts) do
PDF.add_link(pdf, {x, y, x + width, y + height}, target, opts)
end
@doc """
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})
"""
@spec text_link(PDF.t(), number(), number(), String.t(), PDF.link_target() | String.t()) ::
PDF.t()
def text_link(%PDF{} = pdf, x, y, text, target) do
text_link(pdf, x, y, text, target, [])
end
@spec text_link(
PDF.t(),
number(),
number(),
String.t(),
PDF.link_target() | String.t(),
keyword()
) :: PDF.t()
def text_link(%PDF{} = pdf, x, y, text, target, opts)
when is_number(x) and is_number(y) and is_binary(text) and is_list(opts) do
{font_name, font_size} = pdf.current_font
width = text_width_for_font(pdf, font_name, font_size, text)
# Approximate the drawn extent from the font size: PDF has no notion of a
# text bounding box at draw time, and ascender/descender metrics are not
# available for every registered font.
ascent = font_size * 0.75
descent = font_size * 0.25
{drawn, link_opts} =
case Keyword.pop(opts, :color) do
{nil, rest} ->
{text_at(pdf, x, y, text), rest}
{{_r, _g, _b} = rgb, rest} ->
drawn =
pdf
|> save_state()
|> set_fill_color(rgb)
|> text_at(x, y, text)
|> restore_state()
{drawn, rest}
end
link(drawn, x, y - descent, width, ascent + descent, target, link_opts)
end
@doc """
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)
"""
@spec text_field(PDF.t(), number(), number(), number(), number(), String.t(), keyword()) ::
PDF.t()
def text_field(%PDF{} = pdf, x, y, width, height, name, opts \\ [])
when is_number(x) and is_number(y) and is_number(width) and is_number(height) and
is_binary(name) and is_list(opts) do
PDF.add_form_field(pdf, :text, name, {x, y, x + width, y + height}, opts)
end
@doc """
Add a checkbox.
Checkboxes are square, so a single `size` gives both dimensions.
## Options
* `:value` — `true` 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)
"""
@spec checkbox(PDF.t(), number(), number(), number(), String.t(), keyword()) :: PDF.t()
def checkbox(%PDF{} = pdf, x, y, size, name, opts \\ [])
when is_number(x) and is_number(y) and is_number(size) and is_binary(name) and
is_list(opts) do
PDF.add_form_field(pdf, :checkbox, name, {x, y, x + size, y + size}, opts)
end
@doc """
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")
"""
@spec radio_group(PDF.t(), String.t(), [keyword()], keyword()) :: PDF.t()
def radio_group(%PDF{} = pdf, name, buttons, opts \\ [])
when is_binary(name) and is_list(buttons) and is_list(opts) do
PDF.add_radio_group(pdf, name, buttons, opts)
end
@doc """
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)
"""
@spec push_button(PDF.t(), number(), number(), number(), number(), String.t(), keyword()) ::
PDF.t()
def push_button(%PDF{} = pdf, x, y, width, height, name, opts \\ [])
when is_number(x) and is_number(y) and is_number(width) and is_number(height) and
is_binary(name) and is_list(opts) do
PDF.add_form_field(pdf, :push_button, name, {x, y, x + width, y + height}, opts)
end
@doc """
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")
"""
@spec signature_field(PDF.t(), number(), number(), number(), number(), String.t(), keyword()) ::
PDF.t()
def signature_field(%PDF{} = pdf, x, y, width, height, name, opts \\ [])
when is_number(x) and is_number(y) and is_number(width) and is_number(height) and
is_binary(name) and is_list(opts) do
PDF.add_form_field(pdf, :signature, name, {x, y, x + width, y + height}, opts)
end
@doc """
Add a choice field — a dropdown by default, or a list box.
## Options
* `:options` — **required**, the list of choices.
* `:value` — the initially selected choice.
* `:dropdown` — `true` (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"
)
"""
@spec choice_field(PDF.t(), number(), number(), number(), number(), String.t(), keyword()) ::
PDF.t()
def choice_field(%PDF{} = pdf, x, y, width, height, name, opts \\ [])
when is_number(x) and is_number(y) and is_number(width) and is_number(height) and
is_binary(name) and is_list(opts) do
PDF.add_form_field(pdf, :choice, name, {x, y, x + width, y + height}, opts)
end
@doc """
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.
"""
@spec encrypt(PDF.t(), keyword()) :: PDF.t()
def encrypt(%PDF{} = pdf, opts \\ []) when is_list(opts) do
%PDF{pdf | encryption: Tincture.PDF.Encrypt.new(opts)}
end
@doc """
Set the current font name and size.
"""
@spec set_font(PDF.t(), String.t(), number()) :: PDF.t()
def set_font(%PDF{} = pdf, font_name, size) do
Ops.set_font(pdf, font_name, size)
end
@doc """
Register an embedded TrueType font by name from a `.ttf` file path.
"""
@spec register_ttf_font(PDF.t(), String.t(), Path.t()) :: PDF.t()
def register_ttf_font(%PDF{} = pdf, font_name, path) do
register_ttf_font(pdf, font_name, path, [])
end
@doc """
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)
"""
@spec register_ttf_font(PDF.t(), String.t(), Path.t(), keyword()) :: PDF.t()
def register_ttf_font(%PDF{} = pdf, font_name, path, opts) when is_list(opts) do
PDF.register_ttf_font(pdf, font_name, path, opts)
end
@doc """
Register an embedded OpenType font by name from an `.otf` file path.
"""
@spec register_otf_font(PDF.t(), String.t(), Path.t()) :: PDF.t()
def register_otf_font(%PDF{} = pdf, font_name, path) do
register_otf_font(pdf, font_name, path, [])
end
@doc """
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`.
"""
@spec register_otf_font(PDF.t(), String.t(), Path.t(), keyword()) :: PDF.t()
def register_otf_font(%PDF{} = pdf, font_name, path, opts) when is_list(opts) do
PDF.register_otf_font(pdf, font_name, path, opts)
end
@doc """
Place text at X/Y coordinates on the current page.
"""
@spec text_at(PDF.t(), number(), number(), String.t()) :: PDF.t()
def text_at(%PDF{} = pdf, x, y, text) do
Ops.text_at(pdf, x, y, text)
end
@doc """
Place text at X/Y coordinates, splitting glyph runs across fallback fonts when needed.
"""
@spec text_at_with_fallback(PDF.t(), number(), number(), String.t(), [String.t()]) :: PDF.t()
def text_at_with_fallback(%PDF{} = pdf, x, y, text, fallback_fonts \\ [])
when is_number(x) and is_number(y) and is_binary(text) and is_list(fallback_fonts) do
text_at_with_fallback(pdf, x, y, text, fallback_fonts, [])
end
@spec text_at_with_fallback(
PDF.t(),
number(),
number(),
String.t(),
[String.t()],
keyword()
) :: PDF.t()
def text_at_with_fallback(%PDF{} = pdf, x, y, text, fallback_fonts, opts)
when is_number(x) and is_number(y) and is_binary(text) and is_list(fallback_fonts) and
is_list(opts) do
shaping = normalize_shaping_option(Keyword.get(opts, :shaping, :off))
kerning = normalize_kerning_option(Keyword.get(opts, :kerning, :off))
{next_pdf, _rendered_width} =
draw_text_with_fallback(
pdf,
x,
y,
text,
fallback_fonts,
shaping,
kerning,
fn doc, draw_x, draw_y, segment ->
text_at(doc, draw_x, draw_y, segment)
end
)
next_pdf
end
@doc """
Place rotated text at X/Y, splitting glyph runs across fallback fonts when needed.
"""
@spec text_at_rotated_with_fallback(
PDF.t(),
number(),
number(),
number(),
String.t(),
[String.t()]
) :: PDF.t()
def text_at_rotated_with_fallback(
%PDF{} = pdf,
x,
y,
angle_degrees,
text,
fallback_fonts \\ []
)
when is_number(x) and is_number(y) and is_number(angle_degrees) and is_binary(text) and
is_list(fallback_fonts) do
text_at_rotated_with_fallback(pdf, x, y, angle_degrees, text, fallback_fonts, [])
end
@spec text_at_rotated_with_fallback(
PDF.t(),
number(),
number(),
number(),
String.t(),
[String.t()],
keyword()
) :: PDF.t()
def text_at_rotated_with_fallback(
%PDF{} = pdf,
x,
y,
angle_degrees,
text,
fallback_fonts,
opts
)
when is_number(x) and is_number(y) and is_number(angle_degrees) and is_binary(text) and
is_list(fallback_fonts) and is_list(opts) do
shaping = normalize_shaping_option(Keyword.get(opts, :shaping, :off))
kerning = normalize_kerning_option(Keyword.get(opts, :kerning, :off))
{next_pdf, _rendered_width} =
draw_text_with_fallback(
pdf,
x,
y,
text,
fallback_fonts,
shaping,
kerning,
fn doc, draw_x, draw_y, segment ->
text_at_rotated(doc, draw_x, draw_y, angle_degrees, segment)
end
)
next_pdf
end
defp draw_text_with_fallback(
%PDF{} = pdf,
x,
y,
text,
fallback_fonts,
shaping,
kerning,
draw_fun
) do
{primary_font, size} = pdf.current_font
entry_font = pdf.current_font
fallback_fonts = normalize_fallback_fonts(pdf, fallback_fonts, primary_font)
font_order = [primary_font | fallback_fonts]
shaped_text = shape_text_for_fonts(text, shaping, pdf, font_order)
if shaped_text == "" do
{pdf, 0.0}
else
shaped_text
|> split_text_by_fallback_font(pdf, font_order, primary_font)
|> Enum.reduce({pdf, x * 1.0, 0.0}, fn {font_name, segment},
{acc_pdf, cursor_x, total_width} ->
{next_pdf, segment_width} =
acc_pdf
|> set_font(font_name, size)
|> draw_text_segment(
cursor_x,
y,
segment,
font_name,
size,
kerning,
draw_fun
)
next_x = cursor_x + segment_width
{next_pdf, next_x, total_width + segment_width}
end)
|> then(fn {next_pdf, _cursor_x, total_width} ->
restored_pdf =
if next_pdf.current_font == entry_font do
next_pdf
else
{entry_name, entry_size} = entry_font
set_font(next_pdf, entry_name, entry_size)
end
{restored_pdf, total_width}
end)
end
end
defp draw_text_segment(
%PDF{} = pdf,
cursor_x,
y,
segment,
font_name,
size,
:gpos,
draw_fun
) do
graphemes = String.graphemes(segment)
with true <- length(graphemes) > 1,
%{ttf_metrics: ttf_metrics} <- Map.get(pdf.embedded_fonts, font_name),
true <- is_map(ttf_metrics),
units_per_em when is_integer(units_per_em) and units_per_em > 0 <-
Map.get(ttf_metrics, :units_per_em),
gpos_pair_kerns when is_map(gpos_pair_kerns) and map_size(gpos_pair_kerns) > 0 <-
Map.get(ttf_metrics, :gpos_pair_kerns) do
{next_pdf, next_x, _prev_codepoint} =
Enum.reduce(graphemes, {pdf, cursor_x, nil}, fn grapheme,
{acc_pdf, acc_x, prev_codepoint} ->
current_codepoint = Context.kerning_codepoint(grapheme)
kerning_units =
Context.pair_kerning_units(prev_codepoint, current_codepoint, gpos_pair_kerns)
draw_x = acc_x + kerning_units * size / units_per_em
next_pdf = draw_fun.(acc_pdf, draw_x, y, grapheme)
grapheme_width = text_width_for_font(next_pdf, font_name, size, grapheme)
next_prev_codepoint =
if is_integer(current_codepoint) and current_codepoint >= 0 do
current_codepoint
else
prev_codepoint
end
{next_pdf, draw_x + grapheme_width, next_prev_codepoint}
end)
{next_pdf, next_x - cursor_x}
else
_other ->
next_pdf = draw_fun.(pdf, cursor_x, y, segment)
{next_pdf, text_width_for_font(next_pdf, font_name, size, segment)}
end
end
defp draw_text_segment(
%PDF{} = pdf,
cursor_x,
y,
segment,
font_name,
size,
_kerning,
draw_fun
) do
next_pdf = draw_fun.(pdf, cursor_x, y, segment)
{next_pdf, text_width_for_font(next_pdf, font_name, size, segment)}
end
@doc """
Place text at X/Y coordinates rotated by the given angle in degrees.
"""
@spec text_at_rotated(PDF.t(), number(), number(), number(), String.t()) :: PDF.t()
def text_at_rotated(%PDF{} = pdf, x, y, angle_degrees, text) do
Ops.text_at_rotated(pdf, x, y, angle_degrees, text)
end
@doc """
Draw a stroked line between two points.
"""
@spec line(PDF.t(), number(), number(), number(), number(), paint()) :: PDF.t()
def line(%PDF{} = pdf, x1, y1, x2, y2, paint \\ :stroke) do
Ops.line(pdf, x1, y1, x2, y2, paint)
end
@doc """
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)
"""
@spec rectangle(PDF.t(), number(), number(), number(), number(), paint()) :: PDF.t()
def rectangle(%PDF{} = pdf, x, y, width, height, paint \\ :stroke) do
Ops.rectangle(pdf, x, y, width, height, paint)
end
@doc """
Draw a circle. See `rectangle/6` for the `paint` modes.
"""
@spec circle(PDF.t(), number(), number(), number(), paint()) :: PDF.t()
def circle(%PDF{} = pdf, cx, cy, radius, paint \\ :stroke) do
Ops.circle(pdf, cx, cy, radius, paint)
end
@doc """
Set stroke color using normalized RGB values (0..1).
"""
@spec set_stroke_color(PDF.t(), {number(), number(), number()}) :: PDF.t()
def set_stroke_color(%PDF{} = pdf, rgb) do
Ops.set_stroke_color(pdf, rgb)
end
@doc """
Set fill color using normalized RGB values (0..1).
"""
@spec set_fill_color(PDF.t(), {number(), number(), number()}) :: PDF.t()
def set_fill_color(%PDF{} = pdf, rgb) do
Ops.set_fill_color(pdf, rgb)
end
@doc """
Move current path point to X/Y.
"""
@spec move_to(PDF.t(), number(), number()) :: PDF.t()
def move_to(%PDF{} = pdf, x, y) do
Ops.move_to(pdf, x, y)
end
@doc """
Add a line segment from current path point to X/Y.
"""
@spec line_to(PDF.t(), number(), number()) :: PDF.t()
def line_to(%PDF{} = pdf, x, y) do
Ops.line_to(pdf, x, y)
end
@doc """
Add a cubic Bezier segment to the current path.
"""
@spec bezier(PDF.t(), number(), number(), number(), number(), number(), number()) :: PDF.t()
def bezier(%PDF{} = pdf, x1, y1, x2, y2, x3, y3) do
Ops.bezier(pdf, x1, y1, x2, y2, x3, y3)
end
@doc """
Stroke the current path.
"""
@spec stroke(PDF.t()) :: PDF.t()
def stroke(%PDF{} = pdf) do
Ops.stroke(pdf)
end
@doc """
Fill the current path.
"""
@spec fill(PDF.t()) :: PDF.t()
def fill(%PDF{} = pdf) do
Ops.fill(pdf)
end
@doc """
Fill the current path using the even-odd rule.
"""
@spec fill_even_odd(PDF.t()) :: PDF.t()
def fill_even_odd(%PDF{} = pdf) do
Ops.fill_even_odd(pdf)
end
@doc """
Set the clipping path from the current path.
"""
@spec clip(PDF.t()) :: PDF.t()
def clip(%PDF{} = pdf) do
Ops.clip(pdf)
end
@doc """
Set the clipping path from the current path using the even-odd rule.
"""
@spec clip_even_odd(PDF.t()) :: PDF.t()
def clip_even_odd(%PDF{} = pdf) do
Ops.clip_even_odd(pdf)
end
@doc """
Set stroke line width.
"""
@spec set_line_width(PDF.t(), number()) :: PDF.t()
def set_line_width(%PDF{} = pdf, width) do
Ops.set_line_width(pdf, width)
end
@doc """
Set line cap style: 0 butt, 1 round, 2 projecting square.
"""
@spec set_line_cap(PDF.t(), 0 | 1 | 2) :: PDF.t()
def set_line_cap(%PDF{} = pdf, cap) do
Ops.set_line_cap(pdf, cap)
end
@doc """
Set line join style: 0 miter, 1 round, 2 bevel.
"""
@spec set_line_join(PDF.t(), 0 | 1 | 2) :: PDF.t()
def set_line_join(%PDF{} = pdf, join) do
Ops.set_line_join(pdf, join)
end
@doc """
Set dash pattern and phase.
"""
@spec set_dash(PDF.t(), [number()], number()) :: PDF.t()
def set_dash(%PDF{} = pdf, pattern, phase) do
Ops.set_dash(pdf, pattern, phase)
end
@doc """
Set miter limit for stroked joins.
"""
@spec set_miter_limit(PDF.t(), number()) :: PDF.t()
def set_miter_limit(%PDF{} = pdf, limit) do
Ops.set_miter_limit(pdf, limit)
end
@doc """
Save graphics state.
"""
@spec save_state(PDF.t()) :: PDF.t()
def save_state(%PDF{} = pdf) do
Ops.save_state(pdf)
end
@doc """
Restore graphics state.
"""
@spec restore_state(PDF.t()) :: PDF.t()
def restore_state(%PDF{} = pdf) do
Ops.restore_state(pdf)
end
@doc """
Draw a JPEG image at X/Y with the given width and height.
"""
@spec image_jpeg(PDF.t(), number(), number(), number(), number(), Path.t()) :: PDF.t()
def image_jpeg(%PDF{} = pdf, x, y, width, height, path) do
Ops.image_jpeg(pdf, x, y, width, height, path)
end
@doc """
Draw a PNG image at X/Y with the given width and height.
"""
@spec image_png(PDF.t(), number(), number(), number(), number(), Path.t()) :: PDF.t()
def image_png(%PDF{} = pdf, x, y, width, height, path) do
Ops.image_png(pdf, x, y, width, height, path)
end
@doc """
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.
"""
@spec tag(PDF.t(), atom(), keyword(), (PDF.t() -> PDF.t())) :: PDF.t()
def tag(pdf, tag, opts \\ [], fun)
def tag(%PDF{} = pdf, tag, opts, fun)
when is_atom(tag) and is_list(opts) and is_function(fun, 1) do
pdf
|> PDF.begin_structure(tag, opts)
|> fun.()
|> case do
%PDF{} = tagged ->
PDF.end_structure(tagged)
other ->
raise ArgumentError,
"the function passed to tag/4 must return a %Tincture.PDF{}, got: #{inspect(other)}"
end
end
@doc """
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.
"""
@spec artifact(PDF.t(), (PDF.t() -> PDF.t())) :: PDF.t()
def artifact(%PDF{} = pdf, fun) when is_function(fun, 1) do
pdf
|> PDF.begin_artifact()
|> fun.()
|> case do
%PDF{} = marked ->
PDF.end_artifact(marked)
other ->
raise ArgumentError,
"the function passed to artifact/2 must return a %Tincture.PDF{}, " <>
"got: #{inspect(other)}"
end
end
@doc """
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.
"""
@spec set_language(PDF.t(), String.t()) :: PDF.t()
def set_language(%PDF{} = pdf, language) do
PDF.set_language(pdf, language)
end
@doc """
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.
"""
@spec sign(PDF.t(), String.t(), keyword()) :: PDF.t()
def sign(%PDF{} = pdf, field_name, opts) when is_binary(field_name) and is_list(opts) do
PDF.set_signature(pdf, field_name, opts)
end
@doc """
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.
"""
@spec set_pdf_a(PDF.t(), atom()) :: PDF.t()
def set_pdf_a(%PDF{} = pdf, level) when is_atom(level) do
PDF.set_pdf_a(pdf, level)
end
@doc """
Whether the document carries logical structure.
"""
@spec tagged?(PDF.t()) :: boolean()
def tagged?(%PDF{} = pdf), do: PDF.tagged?(pdf)
@doc """
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.
"""
@spec export(PDF.t(), keyword()) :: binary()
def export(pdf, opts \\ [])
def export(%PDF{} = pdf, opts) when is_list(opts) do
case {Keyword.get(opts, :enforce, true), Archival.violations(pdf)} do
{_enforce, []} ->
Serialize.export(pdf)
{false, violations} ->
Logger.warning(
"exporting a document that claims PDF/A and does not conform, because " <>
"enforce: false was given. The file asserts a conformance it does not have:\n" <>
Archival.describe(violations)
)
Serialize.export(pdf)
{true, violations} ->
{part, conformance} = pdf.pdf_a
level = "#{part}#{conformance |> Atom.to_string() |> String.upcase()}"
raise ArgumentError,
"this document declares PDF/A-#{level} but does not conform:\n" <>
Archival.describe(violations) <>
"\n\nExporting would write a conformance claim into a file that does not " <>
"meet it. Fix the above, drop set_pdf_a/2, or pass enforce: false to " <>
"export regardless."
end
end
@doc """
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`.
"""
@spec pdf_a_violations(PDF.t()) :: [Archival.violation()]
def pdf_a_violations(%PDF{} = pdf), do: Archival.violations(pdf)
@doc """
Export and write a PDF to disk.
Takes the same options as `export/2`.
"""
@spec save(PDF.t(), Path.t(), keyword()) :: :ok | {:error, term()}
def save(%PDF{} = pdf, path, opts \\ []) when is_binary(path) and is_list(opts) do
with :ok <- File.mkdir_p(Path.dirname(path)) do
File.write(path, export(pdf, opts))
end
end
@doc """
Render a laid-out paragraph of rich text at X/Y origin using typography layout.
"""
@spec text_paragraph(PDF.t(), number(), number(), rich_text(), number(), [paragraph_option()]) ::
PDF.t()
def text_paragraph(%PDF{} = pdf, x, y, %RichText{} = rich_text, max_width, opts \\ [])
when is_number(x) and is_number(y) and is_number(max_width) and max_width > 0 and
is_list(opts) do
rotate = Keyword.get(opts, :rotate)
fallback_fonts = Keyword.get(opts, :fallback_fonts, [])
bidi = Keyword.get(opts, :bidi, :off)
shaping = Keyword.get(opts, :shaping, :off)
kerning = Keyword.get(opts, :kerning, :off)
if not is_nil(rotate) and not is_number(rotate) do
raise ArgumentError, "rotate option must be a number of degrees"
end
if not is_list(fallback_fonts) do
raise ArgumentError, "fallback_fonts option must be a list"
end
bidi = normalize_bidi_option(bidi)
shaping = normalize_shaping_option(shaping)
kerning = normalize_kerning_option(kerning)
layout_opts = Keyword.drop(opts, [:rotate, :fallback_fonts, :bidi, :shaping, :kerning])
# Token widths are baked in at construction, where an embedded font cannot
# be resolved - its metrics live on this document. Re-measure before layout.
rich_text = RichText.remeasure(rich_text, Context.from_pdf(pdf))
Typography.layout_paragraph(rich_text, max_width, layout_opts)
|> Enum.reduce(pdf, fn %Line{} = line, acc_pdf ->
render_line(
acc_pdf,
x + line.x,
y + line.y,
line.tokens,
rotate,
fallback_fonts,
bidi,
shaping,
kerning
)
end)
end
defp render_line(pdf, start_x, line_y, tokens, rotate, fallback_fonts, bidi, shaping, kerning) do
visual_tokens = bidi_visual_tokens(tokens, bidi)
{pdf, _cursor_x} =
Enum.reduce(visual_tokens, {pdf, start_x * 1.0}, fn token, {acc_pdf, cursor_x} ->
render_token(
token,
acc_pdf,
cursor_x,
line_y * 1.0,
rotate,
fallback_fonts,
shaping,
kerning
)
end)
pdf
end
defp render_token(
%Word{} = word,
pdf,
cursor_x,
line_y,
rotate,
fallback_fonts,
shaping,
kerning
) do
{next_pdf, rendered_width} =
pdf
|> set_font(word.font, word.size)
|> then(fn doc ->
case rotate do
degrees when is_number(degrees) ->
draw_text_with_fallback(
doc,
cursor_x,
line_y,
word.text,
fallback_fonts,
shaping,
kerning,
fn draw_doc, draw_x, draw_y, segment ->
text_at_rotated(draw_doc, draw_x, draw_y, degrees, segment)
end
)
_ ->
draw_text_with_fallback(
doc,
cursor_x,
line_y,
word.text,
fallback_fonts,
shaping,
kerning,
fn draw_doc, draw_x, draw_y, segment ->
text_at(draw_doc, draw_x, draw_y, segment)
end
)
end
end)
{next_pdf, cursor_x + rendered_width}
end
defp render_token(
%Space{} = space,
pdf,
cursor_x,
_line_y,
_rotate,
_fallback_fonts,
_shaping,
_kerning
) do
{pdf, cursor_x + space.width}
end
defp render_token(
%Break{},
pdf,
cursor_x,
_line_y,
_rotate,
_fallback_fonts,
_shaping,
_kerning
) do
{pdf, cursor_x}
end
defp normalize_bidi_option(:off), do: :off
defp normalize_bidi_option(:basic), do: :basic
defp normalize_bidi_option(_other),
do: raise(ArgumentError, "bidi option must be :off or :basic")
defp normalize_shaping_option(:off), do: :off
defp normalize_shaping_option(:latin_ligatures), do: :latin_ligatures
defp normalize_shaping_option(:gsub_ligatures), do: :gsub_ligatures
defp normalize_shaping_option(_other),
do:
raise(
ArgumentError,
"shaping option must be :off, :latin_ligatures, or :gsub_ligatures"
)
defp normalize_kerning_option(:off), do: :off
defp normalize_kerning_option(:gpos), do: :gpos
defp normalize_kerning_option(_other),
do: raise(ArgumentError, "kerning option must be :off or :gpos")
defp bidi_visual_tokens(tokens, :off), do: tokens
defp bidi_visual_tokens(tokens, :basic) do
entries =
tokens
|> Enum.map(fn token -> %{token: token, dir: token_direction(token)} end)
base_dir = first_strong_dir(entries) || :ltr
resolved = resolve_neutral_token_dirs(entries, base_dir)
runs = chunk_by_dir(resolved)
runs =
case base_dir do
:rtl -> Enum.reverse(runs)
_ -> runs
end
runs
|> Enum.flat_map(fn {dir, run_entries} ->
run_tokens = Enum.map(run_entries, & &1.token)
if dir == :rtl do
run_tokens
|> Enum.reverse()
|> Enum.map(&reverse_rtl_token_text/1)
else
run_tokens
end
end)
end
defp token_direction(%Word{text: text}), do: word_direction(text)
defp token_direction(%Space{}), do: :neutral
defp token_direction(%Break{}), do: :neutral
defp word_direction(text) when is_binary(text) do
text
|> String.to_charlist()
|> Enum.find_value(:neutral, fn codepoint ->
cond do
rtl_codepoint?(codepoint) -> :rtl
ltr_codepoint?(codepoint) -> :ltr
true -> nil
end
end)
end
defp rtl_codepoint?(cp) do
(cp >= 0x0590 and cp <= 0x08FF) or (cp >= 0xFB1D and cp <= 0xFEFC)
end
defp ltr_codepoint?(cp) do
(cp >= ?A and cp <= ?Z) or (cp >= ?a and cp <= ?z) or (cp >= ?0 and cp <= ?9) or
(cp >= 0x00C0 and cp <= 0x02AF)
end
defp first_strong_dir(entries) do
entries
|> Enum.find_value(fn entry ->
if entry.dir in [:ltr, :rtl], do: entry.dir, else: nil
end)
end
defp resolve_neutral_token_dirs(entries, base_dir) do
Enum.with_index(entries)
|> Enum.map(fn {entry, idx} ->
if entry.dir == :neutral do
prev = prev_strong_dir(entries, idx)
next = next_strong_dir(entries, idx)
%{entry | dir: prev || next || base_dir}
else
entry
end
end)
end
defp prev_strong_dir(entries, idx) do
entries
|> Enum.take(idx)
|> Enum.reverse()
|> Enum.find_value(fn entry -> if entry.dir in [:ltr, :rtl], do: entry.dir, else: nil end)
end
defp next_strong_dir(entries, idx) do
entries
|> Enum.drop(idx + 1)
|> Enum.find_value(fn entry -> if entry.dir in [:ltr, :rtl], do: entry.dir, else: nil end)
end
defp chunk_by_dir([]), do: []
defp chunk_by_dir([first | rest]) do
{runs_reversed, current_dir, current_entries} =
Enum.reduce(rest, {[], first.dir, [first]}, fn entry, {runs_acc, dir, entries_acc} ->
if entry.dir == dir do
{runs_acc, dir, [entry | entries_acc]}
else
{[{dir, Enum.reverse(entries_acc)} | runs_acc], entry.dir, [entry]}
end
end)
Enum.reverse([{current_dir, Enum.reverse(current_entries)} | runs_reversed])
end
defp reverse_rtl_token_text(%Word{text: text} = word) do
%Word{word | text: reverse_graphemes(text)}
end
defp reverse_rtl_token_text(token), do: token
defp reverse_graphemes(text) do
text
|> String.graphemes()
|> Enum.reverse()
|> Enum.join()
end
defp shape_text_for_fonts(text, :off, _pdf, _font_order), do: text
defp shape_text_for_fonts(text, shaping_mode, pdf, font_order)
when shaping_mode in [:latin_ligatures, :gsub_ligatures] do
Enum.reduce(
ordered_ligature_replacements(pdf, font_order, shaping_mode),
text,
fn {source, target}, acc ->
target_codepoint =
target
|> String.to_charlist()
|> hd()
source_length = source |> String.graphemes() |> length()
if Enum.any?(font_order, fn font_name ->
font_supports_ligature_replacement?(
pdf,
font_name,
source,
target,
target_codepoint,
source_length,
shaping_mode
)
end) do
String.replace(acc, source, target)
else
acc
end
end
)
end
defp ordered_ligature_replacements(%PDF{} = pdf, font_order, shaping_mode)
when is_list(font_order) and shaping_mode in [:latin_ligatures, :gsub_ligatures] do
gsub_ligature_replacements =
font_order
|> Enum.reduce([], fn font_name, acc ->
acc ++ font_gsub_ligature_replacements(pdf, font_name, shaping_mode)
end)
|> Enum.uniq()
defaults =
latin_ligature_replacements()
|> Enum.reject(fn replacement -> Enum.member?(gsub_ligature_replacements, replacement) end)
(gsub_ligature_replacements ++ defaults)
|> Enum.sort_by(fn {source, _target} -> {-String.length(source), source} end)
end
defp font_gsub_ligature_replacements(%PDF{} = pdf, font_name, shaping_mode)
when shaping_mode in [:latin_ligatures, :gsub_ligatures] do
case Map.get(pdf.embedded_fonts, font_name) do
%{ttf_metrics: ttf_metrics} when is_map(ttf_metrics) ->
gsub_ligatures = font_gsub_ligature_map(ttf_metrics, shaping_mode)
gsub_ligatures
|> Enum.filter(fn {source, target} ->
is_binary(source) and byte_size(source) > 0 and is_binary(target) and
byte_size(target) > 0
end)
|> Enum.map(fn {source, target} -> {source, target} end)
_ ->
[]
end
end
defp font_gsub_ligature_map(ttf_metrics, :latin_ligatures) when is_map(ttf_metrics) do
case Map.get(ttf_metrics, :gsub_ligatures) do
gsub_ligatures when is_map(gsub_ligatures) -> gsub_ligatures
_other -> %{}
end
end
defp font_gsub_ligature_map(ttf_metrics, :gsub_ligatures) when is_map(ttf_metrics) do
case Map.get(ttf_metrics, :gsub_substitutions_all) do
gsub_substitutions_all
when is_map(gsub_substitutions_all) and map_size(gsub_substitutions_all) > 0 ->
gsub_substitutions_all
_other ->
case Map.get(ttf_metrics, :gsub_ligatures_all) do
gsub_ligatures_all when is_map(gsub_ligatures_all) ->
gsub_ligatures_all
_other ->
case Map.get(ttf_metrics, :gsub_ligatures) do
gsub_ligatures when is_map(gsub_ligatures) -> gsub_ligatures
_other -> %{}
end
end
end
end
defp font_supports_ligature_replacement?(
%PDF{} = pdf,
font_name,
source,
target,
target_codepoint,
source_length,
shaping_mode
)
when is_binary(source) and byte_size(source) > 0 and is_binary(target) and
byte_size(target) > 0 and is_integer(target_codepoint) and target_codepoint >= 0 and
is_integer(source_length) and source_length >= 1 and
shaping_mode in [:latin_ligatures, :gsub_ligatures] do
font_supports_codepoint?(pdf, font_name, target_codepoint) and
ligature_mapping_supported?(pdf, font_name, source, target, shaping_mode) and
ligature_feature_supported?(pdf, font_name, shaping_mode) and
ligature_context_length_allowed?(pdf, font_name, source_length)
end
defp ligature_mapping_supported?(%PDF{} = pdf, font_name, source, target, shaping_mode)
when shaping_mode in [:latin_ligatures, :gsub_ligatures] do
case Map.get(pdf.embedded_fonts, font_name) do
%{ttf_metrics: ttf_metrics} when is_map(ttf_metrics) ->
gsub_ligatures = font_gsub_ligature_map(ttf_metrics, shaping_mode)
if map_size(gsub_ligatures) > 0 do
Map.get(gsub_ligatures, source) == target
else
Enum.member?(latin_ligature_replacements(), {source, target})
end
_ ->
Enum.member?(latin_ligature_replacements(), {source, target})
end
end
defp ligature_feature_supported?(%PDF{} = pdf, font_name, shaping_mode)
when shaping_mode in [:latin_ligatures, :gsub_ligatures] do
case Map.get(pdf.embedded_fonts, font_name) do
%{ttf_metrics: %{gsub_features: gsub_features, gsub_scripts: gsub_scripts}}
when is_list(gsub_features) and is_list(gsub_scripts) ->
feature_ok? =
case shaping_mode do
:latin_ligatures ->
gsub_features == [] or Enum.member?(gsub_features, "liga")
:gsub_ligatures ->
gsub_features == [] or
Enum.any?(["liga", "rlig", "ccmp"], &Enum.member?(gsub_features, &1))
end
script_ok? = ligature_script_supported?(gsub_scripts, shaping_mode)
feature_ok? and script_ok?
_ ->
true
end
end
defp ligature_script_supported?(gsub_scripts, :latin_ligatures) when is_list(gsub_scripts),
do: gsub_scripts == [] or Enum.member?(gsub_scripts, "latn")
defp ligature_script_supported?(_gsub_scripts, :gsub_ligatures), do: true
defp ligature_context_length_allowed?(%PDF{} = pdf, font_name, source_length)
when is_integer(source_length) and source_length >= 1 do
case Map.get(pdf.embedded_fonts, font_name) do
%{ttf_metrics: %{os2_max_context: max_context}}
when is_integer(max_context) and max_context > 0 ->
source_length <= max_context
_ ->
true
end
end
defp latin_ligature_replacements do
[
{"ffl", "ffl"},
{"ffi", "ffi"},
{"ff", "ff"},
{"fi", "fi"},
{"fl", "fl"}
]
end
defp normalize_fallback_fonts(%PDF{} = pdf, fallback_fonts, primary_font) do
fallback_fonts
|> Enum.map(fn font_name ->
if is_binary(font_name) and byte_size(font_name) > 0 do
font_name
else
raise ArgumentError, "fallback fonts must be non-empty font-name strings"
end
end)
|> Enum.reject(&(&1 == primary_font))
|> Enum.uniq()
|> Enum.map(fn font_name ->
if font_available_in_pdf?(pdf, font_name) do
font_name
else
raise ArgumentError, "unknown font: #{font_name}"
end
end)
end
defp append_segment([], font_name, segment), do: [{font_name, segment}]
defp append_segment([{font_name, text} | rest], font_name, segment),
do: [{font_name, text <> segment} | rest]
defp append_segment(segments, font_name, segment), do: [{font_name, segment} | segments]
defp split_text_by_fallback_font(text, pdf, font_order, primary_font) do
text
|> String.graphemes()
|> Enum.reduce([], fn grapheme, acc ->
selected_font = select_fallback_font_for_grapheme(pdf, font_order, primary_font, grapheme)
append_segment(acc, selected_font, grapheme)
end)
|> Enum.reverse()
end
defp select_fallback_font_for_grapheme(%PDF{} = pdf, font_order, primary_font, grapheme) do
case String.to_charlist(grapheme) do
[base_codepoint, selector_codepoint] ->
if Unicode.variation_selector_codepoint?(selector_codepoint) do
Enum.find(font_order, fn font_name ->
font_has_non_default_variation?(pdf, font_name, base_codepoint, selector_codepoint)
end) ||
Enum.find(font_order, primary_font, fn font_name ->
font_supports_variation_grapheme?(
pdf,
font_name,
base_codepoint,
selector_codepoint
)
end)
else
Enum.find(font_order, primary_font, fn font_name ->
font_supports_grapheme_codepoints?(pdf, font_name, [
base_codepoint,
selector_codepoint
])
end)
end
codepoints ->
Enum.find(font_order, primary_font, fn font_name ->
font_supports_grapheme_codepoints?(pdf, font_name, codepoints)
end)
end
end
defp font_supports_variation_grapheme?(
%PDF{} = pdf,
font_name,
base_codepoint,
selector_codepoint
)
when is_integer(base_codepoint) and is_integer(selector_codepoint) do
font_has_non_default_variation?(pdf, font_name, base_codepoint, selector_codepoint) or
font_supports_codepoint?(pdf, font_name, base_codepoint)
end
defp font_supports_grapheme_codepoints?(%PDF{} = pdf, font_name, codepoints)
when is_list(codepoints) do
Enum.all?(codepoints, fn codepoint ->
Unicode.zero_advance_codepoint?(codepoint) or
font_supports_codepoint?(pdf, font_name, codepoint)
end)
end
defp font_has_non_default_variation?(
%PDF{} = pdf,
font_name,
base_codepoint,
selector_codepoint
)
when is_integer(base_codepoint) and is_integer(selector_codepoint) do
case Map.get(pdf.embedded_fonts, font_name) do
%{ttf_metrics: %{cmap_non_default_uvs: cmap_non_default_uvs}}
when is_map(cmap_non_default_uvs) ->
Map.has_key?(cmap_non_default_uvs, {base_codepoint, selector_codepoint})
_ ->
false
end
end
defp font_available_in_pdf?(%PDF{} = pdf, font_name) do
Font.font_available?(font_name) or Map.has_key?(pdf.embedded_fonts, font_name)
end
defp font_supports_codepoint?(%PDF{} = pdf, font_name, codepoint)
when is_integer(codepoint) and codepoint >= 0 do
if Font.font_available?(font_name) do
codepoint <= 255
else
embedded_font_supports_codepoint?(Map.get(pdf.embedded_fonts, font_name), codepoint)
end
end
defp embedded_font_supports_codepoint?(
%{ttf_metrics: %{cmap_by_code: cmap_by_code}},
codepoint
)
when is_map(cmap_by_code) and map_size(cmap_by_code) > 0 do
Map.has_key?(cmap_by_code, codepoint)
end
defp embedded_font_supports_codepoint?(
%{ttf_metrics: %{cmap_by_code: cmap_by_code} = ttf_metrics},
codepoint
)
when is_map(cmap_by_code) and map_size(cmap_by_code) == 0 do
unicode_ranges = Map.get(ttf_metrics, :os2_unicode_ranges)
code_page_ranges = Map.get(ttf_metrics, :os2_code_page_ranges)
cond do
is_tuple(unicode_ranges) and not UnicodeRanges.all_zero?(unicode_ranges) ->
UnicodeRanges.supports_codepoint?(unicode_ranges, codepoint)
is_tuple(code_page_ranges) and not code_page_ranges_all_zero?(code_page_ranges) ->
code_page_ranges_support_codepoint?(code_page_ranges, codepoint)
true ->
codepoint <= 255
end
end
defp embedded_font_supports_codepoint?(%{}, codepoint), do: codepoint <= 255
defp embedded_font_supports_codepoint?(_other, _codepoint), do: false
defp code_page_ranges_support_codepoint?({range1, range2}, codepoint)
when is_integer(range1) and is_integer(range2) and is_integer(codepoint) do
cond do
codepoint < 0 ->
false
codepoint <= 0x7F ->
true
codepoint <= 0xFF ->
latin_1_supported? = (range1 &&& 1 <<< 0) != 0
latin_1_supported?
codepoint >= 0x0100 and codepoint <= 0x024F ->
latin_2_supported? = (range1 &&& 1 <<< 1) != 0
turkish_supported? = (range1 &&& 1 <<< 4) != 0 and turkish_codepoint?(codepoint)
baltic_supported? = (range1 &&& 1 <<< 7) != 0 and baltic_codepoint?(codepoint)
vietnamese_supported? = (range1 &&& 1 <<< 8) != 0 and vietnamese_codepoint?(codepoint)
latin_2_supported? or turkish_supported? or baltic_supported? or vietnamese_supported?
codepoint >= 0x0400 and codepoint <= 0x04FF ->
cyrillic_supported? = (range1 &&& 1 <<< 2) != 0
cyrillic_supported?
codepoint >= 0x0370 and codepoint <= 0x03FF ->
greek_supported? = (range1 &&& 1 <<< 3) != 0
greek_supported?
codepoint >= 0x0590 and codepoint <= 0x05FF ->
hebrew_supported? = (range1 &&& 1 <<< 5) != 0
hebrew_supported?
codepoint >= 0x0600 and codepoint <= 0x06FF ->
arabic_supported? = (range1 &&& 1 <<< 6) != 0
arabic_supported?
codepoint >= 0x0E00 and codepoint <= 0x0E7F ->
thai_supported? = (range2 &&& 1 <<< 0) != 0
thai_supported?
true ->
false
end
end
defp code_page_ranges_support_codepoint?(_ranges, _codepoint), do: false
defp turkish_codepoint?(0x011E), do: true
defp turkish_codepoint?(0x011F), do: true
defp turkish_codepoint?(0x0130), do: true
defp turkish_codepoint?(0x0131), do: true
defp turkish_codepoint?(0x015E), do: true
defp turkish_codepoint?(0x015F), do: true
defp turkish_codepoint?(_codepoint), do: false
defp baltic_codepoint?(0x0104), do: true
defp baltic_codepoint?(0x0105), do: true
defp baltic_codepoint?(0x010C), do: true
defp baltic_codepoint?(0x010D), do: true
defp baltic_codepoint?(0x0112), do: true
defp baltic_codepoint?(0x0113), do: true
defp baltic_codepoint?(0x0116), do: true
defp baltic_codepoint?(0x0117), do: true
defp baltic_codepoint?(0x0122), do: true
defp baltic_codepoint?(0x0123), do: true
defp baltic_codepoint?(0x012A), do: true
defp baltic_codepoint?(0x012B), do: true
defp baltic_codepoint?(0x012E), do: true
defp baltic_codepoint?(0x012F), do: true
defp baltic_codepoint?(0x0136), do: true
defp baltic_codepoint?(0x0137), do: true
defp baltic_codepoint?(0x013B), do: true
defp baltic_codepoint?(0x013C), do: true
defp baltic_codepoint?(0x0145), do: true
defp baltic_codepoint?(0x0146), do: true
defp baltic_codepoint?(0x014C), do: true
defp baltic_codepoint?(0x014D), do: true
defp baltic_codepoint?(0x0156), do: true
defp baltic_codepoint?(0x0157), do: true
defp baltic_codepoint?(0x0160), do: true
defp baltic_codepoint?(0x0161), do: true
defp baltic_codepoint?(0x016A), do: true
defp baltic_codepoint?(0x016B), do: true
defp baltic_codepoint?(0x0172), do: true
defp baltic_codepoint?(0x0173), do: true
defp baltic_codepoint?(0x017D), do: true
defp baltic_codepoint?(0x017E), do: true
defp baltic_codepoint?(_codepoint), do: false
defp vietnamese_codepoint?(0x01A0), do: true
defp vietnamese_codepoint?(0x01A1), do: true
defp vietnamese_codepoint?(0x01AF), do: true
defp vietnamese_codepoint?(0x01B0), do: true
defp vietnamese_codepoint?(_codepoint), do: false
defp code_page_ranges_all_zero?({0, 0}), do: true
defp code_page_ranges_all_zero?(_ranges), do: false
# `set_font/3` does not validate, so a document can be drawing with a font
# nothing knows about. Estimating keeps that cosmetic rather than fatal.
defp text_width_for_font(%PDF{} = pdf, font_name, size, text) do
pdf
|> Context.from_pdf()
|> Context.text_width(font_name, size, text, on_unknown: :estimate)
end
end