Packages

Native PDF generation for Elixir: react-pdf-style ~PDF templates, flexbox layout, and a pure-Rust render backend. No browser, no external binaries.

Current section

Files

Jump to
mead_pdf lib mead font_set.ex
Raw

lib/mead/font_set.ex

defmodule Mead.FontSet do
@moduledoc """
An immutable parsed-font registry held as a NIF resource.
Fonts are parsed once at construction and shared across sessions —
build one set at boot and hand it to every `Mead.Session`. The bundled
Liberation Sans family (regular/bold/italic/bold-italic, OFL) is always
registered as the `"default"` family, used when a node has no
`font_family` or names an unknown one.
set =
Mead.FontSet.new(%{
"Inter" => [
%{path: "fonts/Inter-Regular.ttf"},
%{path: "fonts/Inter-Bold.ttf", weight: 700},
%{path: "fonts/Inter-Italic.ttf", italic: true}
]
})
session = Mead.Session.new(set)
Mead.render(doc, session: session)
Variant selection matches `font_family` exactly, then picks the nearest
`font_weight` with matching `italic` (italic mismatches are heavily
penalized, unknown families fall back to `"default"`).
## Fallback chains
When a face lacks a glyph, the missing characters are drawn from the
first family in the `:fallback` chain that covers them (per cluster —
the rest of the text keeps its primary face). The default family is
always the chain's implicit last entry, so declaring a chain only ever
adds coverage:
set =
Mead.FontSet.new(
%{
"Inter" => [%{path: "fonts/Inter-Regular.ttf"}],
"Noto Sans SC" => [%{path: "fonts/NotoSansSC-Regular.otf"}]
},
fallback: ["Noto Sans SC"]
)
Without a chain, a glyph missing from every stacked face renders as
the primary face's `.notdef` (tofu).
## Lazy fallback pool
`:pool` points at font files or directories on disk (e.g. an installed
Noto family) that back a last-resort, per-script fallback tail. Pool
files are scanned once at build time — coverage metadata only, no
resident font data — and a face is loaded lazily the first time a
document actually needs its script, then cached with a bounded LRU.
Resident memory stays proportional to what documents use, not the pool
size:
set =
Mead.FontSet.new(
%{"Inter" => [%{path: "fonts/Inter-Regular.ttf"}]},
pool: ["/usr/share/fonts/google-noto"]
)
Declared families (and the `:fallback` chain) always win; the pool is
only consulted for scripts no declared face covers. Selection is fixed
at build time, so cache eviction can never change which face renders a
glyph. Scripts are detected against per-script sample characters, so a
pool of heavily subsetted fonts may go undetected — point the pool at
complete font files.
"""
defstruct [:ref]
@opaque t :: %__MODULE__{ref: reference()}
@default_variants [
{400, false, "LiberationSans-Regular.ttf"},
{700, false, "LiberationSans-Bold.ttf"},
{400, true, "LiberationSans-Italic.ttf"},
{700, true, "LiberationSans-BoldItalic.ttf"}
]
@doc """
Builds a set from `%{family => [variant]}`, where each variant is a map
with `:path` or `:data`, plus optional `:weight` (default 400),
`:italic` (default false), and `:index` (default 0) — the face index
for TrueType/OpenType collections (`.ttc`), which is extracted into a
standalone face at build time. The bundled default family is always
included.
## Options
* `:fallback` - ordered list of declared family names tried, in
order, for glyphs the primary face lacks (see "Fallback chains"
above). Every name must be a family declared in `fonts`.
* `:pool` - font files or directories backing the lazy per-script
fallback tail (see "Lazy fallback pool" above). Paths must exist.
"""
@spec new(%{optional(String.t()) => [map()]}, keyword()) :: t()
def new(fonts \\ %{}, opts \\ []) do
defaults =
for {weight, italic, file} <- @default_variants do
{"default", weight, italic, 0, File.read!(Path.join(fonts_dir(), file))}
end
user =
for {family, variants} <- fonts, variant <- variants do
{to_string(family), variant[:weight] || 400, variant[:italic] || false, variant[:index] || 0,
variant_data(variant)}
end
fallback = validate_fallback!(opts, defaults ++ user)
pool = validate_pool!(opts)
%__MODULE__{ref: Mead.Native.font_set_new(defaults ++ user, fallback, pool)}
end
@doc """
Builds a set where a single font file is the entire default family.
Used by the `:font` render option (mainly tests with the block font).
"""
@spec from_file(Path.t()) :: t()
def from_file(path) do
%__MODULE__{
ref: Mead.Native.font_set_new([{"default", 400, false, 0, File.read!(path)}], [], [])
}
end
defp validate_fallback!(opts, specs) do
fallback = opts |> Keyword.get(:fallback, []) |> Enum.map(&to_string/1)
declared = MapSet.new(specs, fn {family, _, _, _, _} -> family end)
case Enum.reject(fallback, &MapSet.member?(declared, &1)) do
[] ->
fallback
missing ->
raise ArgumentError,
"fallback families #{inspect(missing)} are not declared in the font set " <>
"(declared: #{inspect(Enum.sort(declared))})"
end
end
defp validate_pool!(opts) do
pool = opts |> Keyword.get(:pool, []) |> Enum.map(&to_string/1)
case Enum.reject(pool, &File.exists?/1) do
[] -> pool
missing -> raise ArgumentError, "font pool paths do not exist: #{inspect(missing)}"
end
end
defp variant_data(%{data: data}) when is_binary(data), do: data
defp variant_data(%{path: path}), do: File.read!(path)
defp fonts_dir, do: Path.join(:code.priv_dir(:mead_pdf), "fonts")
end