defmodule Pdf.Reader.GraphicsState do
@moduledoc """
Struct and operations for the PDF graphics state during content stream interpretation.
CTM (current transformation matrix) and text matrices are stored as 6-float
tuples `{a, b, c, d, e, f}` — the affine subset used by PDF (§8.3.3 of the
PDF spec). Full 4×4 matrices are NOT used; PDF only needs the 2D affine form.
The `:stack` holds snapshots pushed by the `q` operator and restored by `Q`.
## Text state additions (§ 9.4.4)
The `:widths_fn` field holds the per-glyph width closure for the active font.
It is set by the `Tf` operator handler and used by `advance_tm/2` to compute
exact horizontal text advance per the full § 9.4.4 formula. When `nil`, advance
defaults to 0 per glyph (only `Tc`, `Tw`, and `Tfs` terms contribute).
## Matrix convention
PDF uses the row-vector convention (§ 8.3.3):
| a b 0 |
| c d 0 |
| e f 1 |
Multiplication: M3 = M1 × M2 with formulas:
a3 = a1*a2 + b1*c2
b3 = a1*b2 + b1*d2
c3 = c1*a2 + d1*c2
d3 = c1*b2 + d1*d2
e3 = e1*a2 + f1*c2 + e2
f3 = e1*b2 + f1*d2 + f2
"""
@type matrix :: {float(), float(), float(), float(), float(), float()}
@identity {1.0, 0.0, 0.0, 1.0, 0.0, 0.0}
@type t :: %__MODULE__{
ctm: matrix(),
tm: matrix(),
tlm: matrix(),
font: nil | binary(),
font_size: float(),
leading: float(),
char_spacing: float(),
word_spacing: float(),
horizontal_scaling: float(), # percentage (100.0 = normal); Th = value / 100.0
rise: float(),
widths_fn: nil | (binary() -> [non_neg_integer()]),
stack: [t()]
}
defstruct ctm: @identity,
tm: @identity,
tlm: @identity,
font: nil,
font_size: 0.0,
leading: 0.0,
char_spacing: 0.0,
word_spacing: 0.0,
# horizontal_scaling is stored as a percentage (e.g. 100.0 = normal).
# Th = horizontal_scaling / 100.0. Default per PDF spec § 9.4.4 is 100%.
horizontal_scaling: 100.0,
rise: 0.0,
widths_fn: nil,
stack: []
# ---------------------------------------------------------------------------
# Constructor
# ---------------------------------------------------------------------------
@doc "Returns a fresh GraphicsState with identity matrices and zeroed text state."
@spec new() :: t()
def new, do: %__MODULE__{}
# ---------------------------------------------------------------------------
# Matrix operations
# ---------------------------------------------------------------------------
@doc """
Multiplies two affine matrices under the PDF row-vector convention.
`multiply(m1, m2)` returns `M3 = M1 × M2`.
Both arguments and the result are 6-element `{a, b, c, d, e, f}` tuples.
Spec reference: PDF 1.7 § 8.3.4.
"""
@spec multiply(matrix(), matrix()) :: matrix()
def multiply(
{a1, b1, c1, d1, e1, f1},
{a2, b2, c2, d2, e2, f2}
) do
{
a1 * a2 + b1 * c2,
a1 * b2 + b1 * d2,
c1 * a2 + d1 * c2,
c1 * b2 + d1 * d2,
e1 * a2 + f1 * c2 + e2,
e1 * b2 + f1 * d2 + f2
}
end
# ---------------------------------------------------------------------------
# Stack operations — `q` and `Q`
# ---------------------------------------------------------------------------
@doc """
Pushes the current graphics state onto `:stack` (implements PDF `q` operator).
The full state struct is saved. No fields are excluded — this keeps semantics
consistent with the writer's `q`/`Q` discipline.
"""
@spec push(t()) :: t()
def push(%__MODULE__{stack: stack} = state) do
%{state | stack: [state | stack]}
end
@doc """
Pops the most-recently-pushed graphics state from `:stack` (implements PDF `Q` operator).
If the stack is empty (malformed stream), this is a silent no-op — the interpreter
remains in the current state rather than crashing. Spec allows senders to have
unbalanced `q`/`Q` in practice (e.g. content streams generated without strict nesting).
"""
@spec pop(t()) :: t()
def pop(%__MODULE__{stack: []} = state), do: state
def pop(%__MODULE__{stack: [saved | rest]}) do
%{saved | stack: rest}
end
end