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