Text shaping and font metrics for Elixir, through HarfBuzz's Rust port.

Pica measures text from font files. A caller registers loaded fonts under a family name, then asks, by family and face, for each string's glyphs: which glyph, which characters it came from, how far it advances once kerning, ligatures and contextual forms are applied, and how far it would advance unshaped. It also reports font-wide metrics, glyph bounding boxes, and glyph outlines as drawing commands. Nothing is rendered.

A family name is looked up first among the fonts given to Pica, then, unless the caller opts out, among the fonts installed on the machine. Pica never substitutes one font for another: names match exactly, a family found in neither place is an error, and a character the font lacks shapes to glyph 0.

Font units

A font draws on a grid of its own, units_per_em to the em, where the em is the font size: at 12 points, a font with 2000 units per em has 2000 units to 12 points. Pica's numbers are in these font units.

Loading a font

load/2 reads a font from its file's bytes, in any of the containers fonts ship in: a plain TrueType or OpenType font, a font collection (several fonts in one file, chosen by :index), WOFF or WOFF2. info/1 reports what the font says about itself.

Fonts by name

A family's faces are its regular (:plain), bold, italic and bold italic fonts. register/2 files loaded fonts as a family's faces under a name, and lookup/3 finds a face by that name from anywhere in the application. Registered fonts are kept in :persistent_term, so reading them costs nothing, but registering a family again, or unregistering one, makes every process in the VM garbage collect: register fonts once, when the application starts.

A face the registered fonts lack is looked for among the machine's installed fonts, unless the lookup passes installed: false. An installed font is a face of a family when one of its family names (the typographic family name, else the family name, in any language the font gives) is exactly the name asked for, and the nearest of its kind is chosen: for :plain, the upright font whose weight is nearest 400 below 600 (semibold); for :bold, the upright font whose weight is nearest 700 at 600 or above; for :italic and :bold_italic, the same among italic or oblique fonts. A tie goes to the heavier weight, then to the width nearest normal. A family with no font of the kind asked for has no such face, so the lookup is :error: an upright font never stands in for an italic, nor a regular for a bold. A variable font is measured at its default instance.

Glyphs

A font draws text with glyphs: shapes numbered by glyph id, from 0 to one less than the font's glyph_count. Glyph 0 is .notdef, the shape a font draws for a character it lacks. The font's character map ties each character to a glyph, which glyph_ids/2 reads, though shaping may then replace glyphs, joining "f" and "i" into one, say.

nominal_advances/2 gives how far each glyph moves the pen on, as the font stores it, before shaping adjusts it for kerning or context. glyph_boxes/2 gives the box around each glyph's outline, and glyph_outlines/2 the outline itself, as the commands that draw it. All three measure at the font's default location: a variable font, which can vary its weight or width, is measured as it is drawn when no variation is chosen.

Shaping

shape/3 turns text into the glyphs a font draws it with, each positioned: a Pica.Glyph per glyph, saying which glyph, which characters it came from, and how far it moves the pen on. Shaping applies the font's rules through HarfBuzz, the standard shaping engine, in its Rust port, harfrust: kerning, which moves particular pairs of letters closer or apart, as in "AV"; ligatures, one glyph standing for several characters, as in "fi"; letters whose form depends on their neighbours, as in Arabic; and accents placed over their letters.

A font's rules are grouped into OpenType features, each named by a four-letter tag: kern for kerning, liga for the standard ligatures, smcp for small capitals. HarfBuzz turns on a default set for the text's script, its writing system, such as Latin or Arabic, and the :features option turns features on or off over it. The script and the direction the text runs in are guessed from the text unless given; a language, which picks a font's forms for one language, such as Turkish, is used only when given. Shaping is done at the font's default location, in font units unless a :scale is given.

Scale

Font units depend on no size, but turning them into a size rounds them, and where the rounding happens changes the total. With :scale, shaping itself positions each glyph at the scale and rounds it as HarfBuzz does, so a sum of advances is the one a HarfBuzz-based renderer gets at that size. A scale is the em divided into that many units: at 12 points, scale: 12 * 64 puts every length in 1/64 point.

The rounding is HarfBuzz's own. A renderer whose HarfBuzz takes each glyph's advance from FreeType instead rounds by FreeType's arithmetic, so its advances can differ from Pica's by a unit on some glyphs; Pica does not imitate it.

Summary

Types

A variable font's axis: its four-letter tag, such as "wght", and its range.

A face of a family: its regular, bold, italic or bold italic font.

A glyph's box, in font units: {x_min, y_min, x_max, y_max}, measured from the point where the glyph is drawn, on the baseline, with y rising upward, so y_min is negative for a glyph that descends below the baseline.

A glyph's outline, in font units: the commands that draw it, in order, measured from the point where the glyph is drawn, on the baseline, with y rising upward.

What a font says about itself, as info/1 reports it. Every length is in font units.

Why a font did not load.

One command of a glyph_outline/0: {:move, x, y}, {:line, x, y}, {:quad, control_x, control_y, x, y}, {:cubic, first_control_x, first_control_y, second_control_x, second_control_y, x, y}, or :close.

Functions

The names of the registered families, sorted.

Each glyph's box (see glyph_box/0), or :none for a glyph with no outline, such as a space.

The glyph each character of text maps to through the font's character map, one per character (Unicode code point), with 0 for a character the font lacks.

Each glyph's outline (see glyph_outline/0), or :none for a glyph that draws nothing, such as a space, and for every glyph of a font with no outlines.

What a loaded font says about itself (see info/0).

Loads a font from the bytes of a font file.

Finds a face of family by name: {:ok, font}, or :error when the face is found nowhere.

Each glyph's nominal advance, in font units: how far the glyph moves the pen on, as the font stores it, before shaping adjusts it.

Registers loaded fonts as the faces of family, so lookup/3 finds them by name.

Whether family is registered. Installed fonts are not registered.

Shapes text into the glyphs font draws it with (see Pica.Glyph), in the order they are drawn, from left to right, with every length in font units, or at the :scale given.

Forgets a registered family. Returns :ok whether or not it was registered.

Types

axis()

@type axis() :: %{
  tag: String.t(),
  minimum: float(),
  default: float(),
  maximum: float()
}

A variable font's axis: its four-letter tag, such as "wght", and its range.

face()

@type face() :: :plain | :bold | :italic | :bold_italic

A face of a family: its regular, bold, italic or bold italic font.

glyph_box()

@type glyph_box() :: {integer(), integer(), integer(), integer()}

A glyph's box, in font units: {x_min, y_min, x_max, y_max}, measured from the point where the glyph is drawn, on the baseline, with y rising upward, so y_min is negative for a glyph that descends below the baseline.

It is the glyph's control box: the box around every point of its outline, including the off-curve points that steer its curves, computed from the outline rather than read from the box a font may store. Points are in whole font units: a TrueType glyph built from a scaled copy of another has each point rounded to the nearest unit, and a corner of a CFF glyph that falls between units is rounded outward.

glyph_outline()

@type glyph_outline() :: [outline_command()]

A glyph's outline, in font units: the commands that draw it, in order, measured from the point where the glyph is drawn, on the baseline, with y rising upward.

Each contour, a closed loop of the outline, begins with a :move to its start and ends with :close, which draws the line back to the start; no :line to the start comes before it. In between, :line draws a straight line to its point, :quad a quadratic curve steered by one control point, and :cubic a cubic curve steered by two (see outline_command/0). A TrueType curve that runs through several control points arrives as one :quad per control point, split at the points on the curve that lie halfway between them.

A contour whose first point is off the curve starts at its last point, or halfway between its last and first when both are off the curve, as FreeType starts it. Coordinates are floats: a TrueType glyph's are whole font units, a glyph built from a scaled copy of another having each point rounded to the nearest unit, and a CFF glyph's can fall between units.

info()

@type info() :: %{
  family: String.t() | :none,
  subfamily: String.t() | :none,
  units_per_em: pos_integer(),
  ascender: integer(),
  descender: integer(),
  line_gap: integer(),
  weight_class: non_neg_integer() | :none,
  italic: boolean(),
  glyph_count: non_neg_integer(),
  axes: [axis()],
  container: :sfnt | :collection | :woff | :woff2,
  outlines: :truetype | :cff | :cff2 | :none,
  index: non_neg_integer(),
  font_count: pos_integer()
}

What a font says about itself, as info/1 reports it. Every length is in font units.

  • :family, :subfamily — the font's family and style names: the typographic names (name IDs 16 and 17) where the font has them, else the legacy ones (1 and 2), in English where the font has an English name. :none when the font names neither.
  • :units_per_em — the size of the font's grid.
  • :ascender, :descender, :line_gap — the line metrics of the font's hhea table. The descender is negative, below the baseline.
  • :weight_class — from 100 (thin) to 900 (black), 400 being regular and 700 bold; :none when the font has no OS/2 table.
  • :italic — whether the font says it is italic.
  • :glyph_count — how many glyphs the font holds.
  • :axes — a variable font's axes of variation, each with its range and default; empty for a font that does not vary.
  • :container — what the bytes were: :sfnt (a plain font), :collection, :woff or :woff2.
  • :outlines — what draws the glyphs: :truetype (glyf), :cff, :cff2, or :none.
  • :index, :font_count — which font of its file this is, and how many the file holds.

load_error()

@type load_error() ::
  :unknown_format
  | :truncated
  | {:index_out_of_range, non_neg_integer()}
  | {:missing_table, String.t()}
  | {:malformed, String.t()}

Why a font did not load.

  • :unknown_format — the bytes do not begin with the signature of any font container.
  • :truncated — the data ends before a structure or a table it declares.
  • {:index_out_of_range, font_count} — the file has no font at the index asked for; it holds font_count.
  • {:missing_table, tag} — a table Pica reads to describe a font or measure its glyphs is absent.
  • {:malformed, detail} — anything else that does not read, such as WOFF data that does not decode, or advance widths or outlines that do not read.

outline_command()

@type outline_command() ::
  {:move, float(), float()}
  | {:line, float(), float()}
  | {:quad, float(), float(), float(), float()}
  | {:cubic, float(), float(), float(), float(), float(), float()}
  | :close

One command of a glyph_outline/0: {:move, x, y}, {:line, x, y}, {:quad, control_x, control_y, x, y}, {:cubic, first_control_x, first_control_y, second_control_x, second_control_y, x, y}, or :close.

Functions

families()

@spec families() :: [String.t()]

The names of the registered families, sorted.

glyph_boxes(font, glyph_ids)

@spec glyph_boxes(Pica.Font.t(), [non_neg_integer()]) :: [glyph_box() | :none]

Each glyph's box (see glyph_box/0), or :none for a glyph with no outline, such as a space.

Raises ArgumentError for a glyph id the font does not have, or for a glyph whose outline does not read. More than 64 glyphs are measured on a dirty scheduler, so a whole font's boxes never hold up the processes around it.

Examples

iex> {:ok, font} = Pica.load(File.read!("test/fonts/Lato-Regular.ttf"))
iex> Pica.glyph_boxes(font, Pica.glyph_ids(font, "M g"))
[{183, 0, 1674, 1433}, :none, {62, -365, 996, 1030}]

glyph_ids(font, text)

@spec glyph_ids(Pica.Font.t(), String.t()) :: [non_neg_integer()]

The glyph each character of text maps to through the font's character map, one per character (Unicode code point), with 0 for a character the font lacks.

This is the font's own mapping, before shaping: a ligature or a contextual form appears only in what shaping returns. Raises ArgumentError for text that is not valid UTF-8.

Examples

iex> {:ok, font} = Pica.load(File.read!("test/fonts/Lato-Regular.ttf"))
iex> Pica.glyph_ids(font, "AV M")
[3, 40, 2, 24]

glyph_outlines(font, glyph_ids)

@spec glyph_outlines(Pica.Font.t(), [non_neg_integer()]) :: [glyph_outline() | :none]

Each glyph's outline (see glyph_outline/0), or :none for a glyph that draws nothing, such as a space, and for every glyph of a font with no outlines.

Outlines are drawn at the font's default location, unscaled and unhinted: the font's own points, never adjusted to a pixel grid.

Raises ArgumentError for a glyph id the font does not have, or for a glyph whose outline does not read. More than 64 glyphs are drawn on a dirty scheduler, so a whole font's outlines never hold up the processes around it.

Examples

Lato draws "I" as one rectangle:

iex> {:ok, font} = Pica.load(File.read!("test/fonts/Lato-Regular.ttf"))
iex> Pica.glyph_outlines(font, Pica.glyph_ids(font, "I "))
[
  [{:move, 377.0, 0.0}, {:line, 183.0, 0.0}, {:line, 183.0, 1433.0}, {:line, 377.0, 1433.0}, :close],
  :none
]

info(font)

@spec info(Pica.Font.t()) :: info()

What a loaded font says about itself (see info/0).

Examples

iex> {:ok, font} = Pica.load(File.read!("test/fonts/Lato-Bold.ttf"))
iex> info = Pica.info(font)
iex> {info.family, info.subfamily, info.weight_class, info.units_per_em}
{"Lato", "Bold", 700, 2000}

load(bytes, options \\ [])

@spec load(binary(), keyword()) :: {:ok, Pica.Font.t()} | {:error, load_error()}

Loads a font from the bytes of a font file.

Returns {:ok, font}, or {:error, reason} for bytes that are not a font Pica can read (see load_error/0). The bytes are copied, so the binary may be dropped afterwards.

Options

  • :index (non_neg_integer/0) - Which font to load from a collection, counting from 0. The default value is 0.

Examples

iex> {:ok, font} = Pica.load(File.read!("test/fonts/Lato-Regular.ttf"))
iex> Pica.info(font).family
"Lato"

iex> Pica.load("not a font")
{:error, :unknown_format}

lookup(family, face \\ :plain, options \\ [])

@spec lookup(String.t(), face() | keyword(), keyword()) ::
  {:ok, Pica.Font.t()} | :error

Finds a face of family by name: {:ok, font}, or :error when the face is found nowhere.

The face, :plain unless given, comes from the fonts registered under family first. When they lack it, the machine's installed fonts are searched, unless installed: false: for an installed font whose family name is exactly family, of the face asked for, as "Fonts by name" above describes. A missing face is never answered with another: a registered plain face does not stand in for a missing bold. The face may be left out while options are given, as in lookup("Lato", installed: false).

The first search of the installed fonts reads every font file on the machine, which can take a second or more; its result is kept for the life of the VM, so fonts installed afterwards are not seen.

Options

  • :installed (boolean/0) - Whether to look among the machine's installed fonts for a face the registered fonts lack. The default value is true.

Examples

iex> {:ok, regular} = Pica.load(File.read!("test/fonts/Lato-Regular.ttf"))
iex> Pica.register("Lato for the lookup example", plain: regular)
iex> {:ok, font} = Pica.lookup("Lato for the lookup example")
iex> Pica.info(font).subfamily
"Regular"
iex> Pica.lookup("Lato for the lookup example", :bold, installed: false)
:error

nominal_advances(font, glyph_ids)

@spec nominal_advances(Pica.Font.t(), [non_neg_integer()]) :: [non_neg_integer()]

Each glyph's nominal advance, in font units: how far the glyph moves the pen on, as the font stores it, before shaping adjusts it.

Raises ArgumentError for a glyph id the font does not have.

Examples

iex> {:ok, font} = Pica.load(File.read!("test/fonts/Lato-Regular.ttf"))
iex> Pica.nominal_advances(font, Pica.glyph_ids(font, "AV M"))
[1354, 1354, 512, 1857]

register(family, faces)

@spec register(String.t(), [{face(), Pica.Font.t()}]) :: :ok

Registers loaded fonts as the faces of family, so lookup/3 finds them by name.

The name is the caller's: it need not be the font's own family name. Registering a family again replaces it whole, so a face given before and not now is no longer registered. Fonts are meant to be registered once, when the application starts: registering again or unregistering is costly for the whole VM (see "Fonts by name" above). Raises NimbleOptions.ValidationError for a face that is not a loaded font, and ArgumentError when no face is given.

Faces

  • :plain (struct of type Pica.Font) - The plain face, a loaded font.

  • :bold (struct of type Pica.Font) - The bold face, a loaded font.

  • :italic (struct of type Pica.Font) - The italic face, a loaded font.

  • :bold_italic (struct of type Pica.Font) - The bold_italic face, a loaded font.

Examples

iex> {:ok, regular} = Pica.load(File.read!("test/fonts/Lato-Regular.ttf"))
iex> {:ok, bold} = Pica.load(File.read!("test/fonts/Lato-Bold.ttf"))
iex> Pica.register("Lato for the register example", plain: regular, bold: bold)
:ok
iex> Pica.registered?("Lato for the register example")
true

registered?(family)

@spec registered?(String.t()) :: boolean()

Whether family is registered. Installed fonts are not registered.

shape(font, text, options \\ [])

@spec shape(Pica.Font.t(), String.t(), keyword()) :: [Pica.Glyph.t()]

Shapes text into the glyphs font draws it with (see Pica.Glyph), in the order they are drawn, from left to right, with every length in font units, or at the :scale given.

HarfBuzz's default features for the text's script are on, and its direction and script are guessed from the text; the options override them. A character the font lacks shapes to glyph 0, .notdef, with its cluster; Pica never draws it from another font. A character Unicode marks as default-ignorable, one meant never to be drawn, such as a soft hyphen (U+00AD) or a zero-width joiner (U+200D), is hidden as HarfBuzz hides it, whether or not the font has it: it shapes to the font's space glyph with an x_advance of 0, or, in a font with no space, to no glyph at all. Raises ArgumentError for text that is not valid UTF-8, and NimbleOptions.ValidationError for an option not of its form. Text longer than 1,000 bytes is shaped on a dirty scheduler, so a long text never holds up the processes around it.

Options

  • :features (keyword/0) - OpenType features to turn on or off over HarfBuzz's defaults, for the whole text, as a keyword list of each feature's four-character tag and its value: true or false, or a number, for a feature that chooses among alternate glyphs. A feature the font does not have does nothing. For example [kern: false, liga: false, smcp: true]. The default value is [].

  • :direction - The direction the text runs in. Guessed from the script when not given, and left to right for text with no script, such as digits.

  • :script (String.t/0) - The text's script, as its four-letter ISO 15924 code in any case, such as "Latn", "Arab" or "Hebr". Guessed from the first character that belongs to a script when not given. A code HarfBuzz does not know shapes as a script with no rules of its own.

  • :language (String.t/0) - The text's language, as a BCP 47 tag, such as "tr" or "sr-Cyrl", which picks the font's forms for that language where it has them. None when not given, so the font's default forms apply.

  • :scale (pos_integer/0) - How many units the em, the font size, is divided into, in place of the font's own units per em: every advance and offset comes back in these units, each glyph's rounded to a whole unit as HarfBuzz rounds it. At 12 points, scale: 12 * 64 gives 1/64 point. Font units when not given.

Examples

Kerning moves "V" closer to "A", so "A" advances less than the font stores:

iex> {:ok, font} = Pica.load(File.read!("test/fonts/Lato-Regular.ttf"))
iex> Pica.shape(font, "AV")
[
  %Pica.Glyph{glyph_id: 3, cluster: 0, x_advance: 1246, nominal_advance: 1354, x_offset: 0, y_offset: 0},
  %Pica.Glyph{glyph_id: 40, cluster: 1, x_advance: 1354, nominal_advance: 1354, x_offset: 0, y_offset: 0}
]

In "office", one glyph draws "ffi", so the next cluster is 4:

iex> {:ok, font} = Pica.load(File.read!("test/fonts/Lato-Regular.ttf"))
iex> font |> Pica.shape("office") |> Enum.map(fn glyph -> {glyph.glyph_id, glyph.cluster} end)
[{111, 0}, {76, 1}, {49, 4}, {59, 5}]

Lato has no "★", so it is glyph 0, keeping its cluster:

iex> {:ok, font} = Pica.load(File.read!("test/fonts/Lato-Regular.ttf"))
iex> font |> Pica.shape("A★V") |> Enum.map(fn glyph -> {glyph.glyph_id, glyph.cluster} end)
[{3, 0}, {0, 1}, {40, 2}]

Without kerning, "A" advances as the font stores it:

iex> {:ok, font} = Pica.load(File.read!("test/fonts/Lato-Regular.ttf"))
iex> font |> Pica.shape("AV", features: [kern: false]) |> Enum.map(fn glyph -> glyph.x_advance end)
[1354, 1354]

At 12 points in 1/64 point, "AV" is 999/64 point wide, each glyph rounded as HarfBuzz rounds it:

iex> {:ok, font} = Pica.load(File.read!("test/fonts/Lato-Regular.ttf"))
iex> font |> Pica.shape("AV", scale: 12 * 64) |> Enum.map(fn glyph -> glyph.x_advance end)
[479, 520]

unregister(family)

@spec unregister(String.t()) :: :ok

Forgets a registered family. Returns :ok whether or not it was registered.