defmodule Mead.Session do @moduledoc """ Per-scope render state over a shared `Mead.FontSet`. A session owns the warm text-shaping state — a pool of parley contexts reused across renders (and, next, the content-keyed shape cache). Sessions are cheap to create; the expensive font parsing lives in the `Mead.FontSet`, which any number of sessions share by reference. set = Mead.FontSet.new(%{"Inter" => [%{path: "fonts/Inter-Regular.ttf"}]}) session = Mead.Session.new(set) Mead.render(doc, session: session) Recommended scoping: one `FontSet` per runtime; one session per template (low volume) or per template-and-customer (high volume, keeps each customer's repeated content warm and isolates cache memory). Hold per-customer sessions as ordinary values (GenServer state, ETS, Registry) — they are plain resource references and are reclaimed normally when dropped. With no font options at all, `Mead` renders through a lazily-built global default session (fonts from the `:mead_pdf, :fonts` application env) whose handle lives in `:persistent_term` — the default path parses fonts once per VM, not once per render. """ alias Mead.FontSet defstruct [:ref, :font_set] @opaque t :: %__MODULE__{ref: reference(), font_set: FontSet.t()} @default_key {Mead, :default_session} @default_shape_cache_entries 4096 @doc """ Creates a session over a parsed `Mead.FontSet`. ## Options * `:shape_cache_entries` - capacity (in paragraphs) of the session's content-keyed shape cache, default #{@default_shape_cache_entries}. Repeated text — the same label across renders, repeated cell values, cloned table headers — is shaped once and replayed from the cache. Bounded LRU, evicted inline, dropped with the session. `0` disables the cache. """ @spec new(FontSet.t(), keyword()) :: t() def new(%FontSet{} = set, opts \\ []) do entries = Keyword.get(opts, :shape_cache_entries, @default_shape_cache_entries) if !(is_integer(entries) and entries >= 0) do raise ArgumentError, ":shape_cache_entries must be a non-negative integer, got: #{inspect(entries)}" end %__MODULE__{ref: Mead.Native.session_new(set.ref, entries), font_set: set} end @doc """ The global default session, built on first use from the `:mead_pdf, :fonts` application env (same shape as `Mead.FontSet.new/2`, with the `:mead_pdf, :font_fallback` env as the fallback chain) and kept in `:persistent_term`. Only this set-once handle uses `persistent_term`; per-customer sessions should be held as plain values (updating `persistent_term` triggers a global GC scan, which is fine at boot and wrong for churn). """ @spec default() :: t() def default do case :persistent_term.get(@default_key, nil) do nil -> set = FontSet.new( Application.get_env(:mead_pdf, :fonts, %{}), fallback: Application.get_env(:mead_pdf, :font_fallback, []), pool: Application.get_env(:mead_pdf, :font_pool, []) ) session = new(set) # Concurrent first calls may race and both build; last write wins # and every built session is valid — acceptable for a boot path. :persistent_term.put(@default_key, session) session session -> session end end @doc """ Drops the global default session; the next default render rebuilds it. Call after changing the `:mead_pdf, :fonts` application env at runtime. """ @spec reset_default() :: :ok def reset_default do :persistent_term.erase(@default_key) :ok end end