lib/pdf/style.ex

defmodule Pdf.Style do
  @moduledoc """
  CSS-like styling system for PDF elements.

  Styles are maps with atom keys that can be created, merged, and resolved.
  Supports shorthand notation for padding, margin, and border (similar to CSS).

  ## Examples

      Style.new(%{font_size: 14, color: :red, padding: 10})
      Style.new(%{font: "Helvetica", bold: true, opacity: 0.5})
      Style.merge(parent_style, child_style)
  """

  defstruct font: "Helvetica",
            font_size: 12,
            bold: false,
            italic: false,
            color: :black,
            align: :left,
            leading: nil,
            underline: false,
            width: nil,
            height: nil,
            padding: {0, 0, 0, 0},
            margin: {0, 0, 0, 0},
            border: {0, 0, 0, 0},
            border_color: :black,
            background: nil,
            opacity: 1.0,
            fill_opacity: nil,
            stroke_opacity: nil,
            line_width: 1,
            stroke_color: :black,
            border_radius: 0,
            rotate: 0,
            x: nil,
            y: nil

  @type t :: %__MODULE__{}

  @doc """
  Create a new Style from a map or keyword list.

  ## Examples

      iex> Pdf.Style.new(%{font_size: 14, color: :red})
      %Pdf.Style{font_size: 14, color: :red}

      iex> Pdf.Style.new(font_size: 14, padding: 10)
      %Pdf.Style{font_size: 14, padding: {10, 10, 10, 10}}
  """
  @spec new(map | keyword) :: t()
  def new(attrs \\ %{})

  def new(attrs) when is_map(attrs) do
    attrs
    |> normalize_shorthands()
    |> then(&struct(__MODULE__, &1))
  end

  def new(attrs) when is_list(attrs) do
    attrs |> Map.new() |> new()
  end

  @doc """
  Merge two styles. The child style overrides the parent for any non-nil fields
  explicitly set in the child.

  ## Examples

      iex> parent = Pdf.Style.new(%{font_size: 12, color: :black})
      iex> child = Pdf.Style.new(%{color: :red, bold: true})
      iex> merged = Pdf.Style.merge(parent, child)
      iex> merged.font_size
      12
      iex> merged.color
      :red
      iex> merged.bold
      true
  """
  @spec merge(t(), t() | map | keyword) :: t()
  def merge(%__MODULE__{} = parent, %__MODULE__{} = child) do
    merge(parent, Map.from_struct(child))
  end

  def merge(%__MODULE__{} = parent, child) when is_map(child) do
    overrides =
      child
      |> normalize_shorthands()
      |> Enum.reject(fn {_k, v} -> is_nil(v) end)
      |> Map.new()

    struct(parent, overrides)
  end

  def merge(%__MODULE__{} = parent, child) when is_list(child) do
    merge(parent, Map.new(child))
  end

  @doc """
  Normalize a shorthand value into a {top, right, bottom, left} tuple.

  Supports CSS-like shorthand:
  - `10` → `{10, 10, 10, 10}`
  - `{5, 10}` → `{5, 10, 5, 10}`
  - `{5, 10, 15}` → `{5, 10, 15, 10}`
  - `{5, 10, 15, 20}` → `{5, 10, 15, 20}`
  """
  @spec expand_shorthand(number | tuple) :: {number, number, number, number}
  def expand_shorthand(value) when is_number(value), do: {value, value, value, value}
  def expand_shorthand({top, right, bottom, left}), do: {top, right, bottom, left}
  def expand_shorthand({vertical, horizontal}), do: {vertical, horizontal, vertical, horizontal}
  def expand_shorthand({top, horizontal, bottom}), do: {top, horizontal, bottom, horizontal}

  @doc """
  Convert a Style struct to a keyword list suitable for passing to
  existing Page/Document functions.
  """
  @spec to_opts(t()) :: keyword
  def to_opts(%__MODULE__{} = style) do
    []
    |> maybe_put(:bold, style.bold, false)
    |> maybe_put(:italic, style.italic, false)
    |> maybe_put(:font_size, style.font_size, nil)
    |> maybe_put(:color, style.color, nil)
    |> maybe_put(:align, style.align, :left)
    |> maybe_put(:leading, style.leading, nil)
    |> maybe_put(:kerning, false, false)
  end

  defp maybe_put(opts, _key, default, default), do: opts
  defp maybe_put(opts, _key, nil, _default), do: opts
  defp maybe_put(opts, key, value, _default), do: Keyword.put(opts, key, value)

  defp normalize_shorthands(attrs) do
    attrs
    |> normalize_shorthand_field(:padding)
    |> normalize_shorthand_field(:margin)
    |> normalize_shorthand_field(:border)
  end

  defp normalize_shorthand_field(attrs, key) do
    case Map.get(attrs, key) do
      nil -> attrs
      value when is_number(value) -> Map.put(attrs, key, expand_shorthand(value))
      {_, _} = value -> Map.put(attrs, key, expand_shorthand(value))
      {_, _, _} = value -> Map.put(attrs, key, expand_shorthand(value))
      {_, _, _, _} -> attrs
    end
  end
end