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