Skip to main content

lib/pdf/component/avatar.ex

defmodule Pdf.Component.Avatar do
  @moduledoc """
  Avatar component for PDF documents.

  Renders a circular (or rounded) container with an image or initials,
  with optional border and elevation (box-shadow simulation).

  Inspired by Material UI's Avatar component.

  ## Examples

      # Avatar with initials
      doc
      |> Pdf.Component.Avatar.render({100, 700}, %{
        size: 48,
        initials: "AM",
        background: {0.3, 0.5, 0.9},
        color: :white,
        elevation: 2
      })

      # Avatar with image
      doc
      |> Pdf.Component.Avatar.render({200, 700}, %{
        size: 64,
        image: "path/to/photo.jpg",
        border: 2,
        border_color: :white,
        elevation: 3
      })
  """

  @default_size 40
  @default_background {0.74, 0.74, 0.74}
  @default_color {1.0, 1.0, 1.0}
  @default_font "Helvetica"

  @doc """
  Render an avatar at `{x, y}` (top-left corner).

  ## Style options

  - `:size` — single number or `{width, height}` tuple in points (default `40`)
  - `:initials` — 1-3 character string to display (default `nil`)
  - `:image` — path to JPEG/PNG image or `{:binary, data}` (default `nil`)
  - `:background` — fill color behind initials (default gray)
  - `:color` — text color for initials (default white)
  - `:font` — font name for initials (default `"Helvetica"`)
  - `:border` — border width (default `0`)
  - `:border_color` — border color (default `:black`)
  - `:border_radius` — `:circle` (default), `:rounded`, or number
  - `:elevation` — shadow level 0-5, like Material UI (default `0`)
  """
  def render(doc, {x, y}, style \\ %{}) do
    {w, h} = normalize_size(Map.get(style, :size, @default_size))
    bg = Map.get(style, :background, @default_background)
    color = Map.get(style, :color, @default_color)
    font = Map.get(style, :font, @default_font)
    initials = Map.get(style, :initials)
    image = Map.get(style, :image)
    border_w = Map.get(style, :border, 0)
    border_color = Map.get(style, :border_color, :black)
    elevation = Map.get(style, :elevation, 0)
    radius = resolve_radius(Map.get(style, :border_radius, :circle), min(w, h))

    # Position: {x, y} is top-left, PDF coords have y going up
    # So the box bottom-left is {x, y - h}
    bx = x
    by = y - h

    doc = draw_shadow(doc, {bx, by}, {w, h}, radius, elevation)
    doc = draw_background(doc, {bx, by}, {w, h}, radius, bg)
    doc = draw_image_or_initials(doc, {bx, by}, {w, h}, radius, image, initials, color, font)
    draw_border(doc, {bx, by}, {w, h}, radius, border_w, border_color)
  end

  # ── Shadow / Elevation ──────────────────────────────────────────

  defp draw_shadow(doc, _pos, _size, _radius, 0), do: doc

  defp draw_shadow(doc, {x, y}, {w, h}, radius, elevation) when elevation > 0 do
    layers = shadow_layers(elevation)

    Enum.reduce(layers, doc, fn {offset_x, offset_y, spread, opacity}, doc ->
      sx = x + offset_x - spread
      sy = y + offset_y - spread
      sw = w + spread * 2
      sh = h + spread * 2
      sr = min(radius + spread, min(sw, sh) / 2)

      doc
      |> Pdf.save_state()
      |> Pdf.set_fill_color({0.0, 0.0, 0.0})
      |> set_fill_opacity(opacity)
      |> Pdf.rounded_rectangle({sx, sy}, {sw, sh}, sr)
      |> Pdf.fill()
      |> Pdf.restore_state()
    end)
  end

  # Shadow layers: {offset_x, offset_y, spread, opacity}
  # offset_y positive = shadow moves DOWN (subtract from PDF y)
  # Small spreads + low opacity = subtle, realistic shadows

  defp shadow_layers(1) do
    [
      {0, -0.5, 1.0, 0.06},
      {0, -0.3, 0.5, 0.04}
    ]
  end

  defp shadow_layers(2) do
    [
      {0, -1.0, 1.5, 0.07},
      {0, -0.5, 1.0, 0.05},
      {0, -0.2, 0.5, 0.03}
    ]
  end

  defp shadow_layers(3) do
    [
      {0, -1.5, 2.0, 0.08},
      {0, -0.8, 1.5, 0.05},
      {0, -0.3, 0.8, 0.03}
    ]
  end

  defp shadow_layers(4) do
    [
      {0, -2.0, 2.5, 0.09},
      {0, -1.2, 2.0, 0.06},
      {0, -0.5, 1.0, 0.04},
      {0, -0.2, 0.5, 0.02}
    ]
  end

  defp shadow_layers(n) when n >= 5 do
    [
      {0, -3.0, 3.5, 0.10},
      {0, -2.0, 2.5, 0.07},
      {0, -1.0, 1.5, 0.05},
      {0, -0.5, 1.0, 0.03},
      {0, -0.2, 0.5, 0.02}
    ]
  end

  # ── Background ──────────────────────────────────────────────────

  defp draw_background(doc, _pos, _size, _radius, nil), do: doc
  defp draw_background(doc, _pos, _size, _radius, :none), do: doc
  defp draw_background(doc, _pos, _size, _radius, :transparent), do: doc

  defp draw_background(doc, {x, y}, {w, h}, radius, bg) do
    doc
    |> Pdf.save_state()
    |> Pdf.set_fill_color(bg)
    |> Pdf.rounded_rectangle({x, y}, {w, h}, radius)
    |> Pdf.fill()
    |> Pdf.restore_state()
  end

  # ── Image or Initials ───────────────────────────────────────────

  defp draw_image_or_initials(doc, {x, y}, {w, h}, radius, image, _initials, _color, _font)
       when not is_nil(image) do
    doc
    |> Pdf.save_state()
    |> Pdf.rounded_rectangle({x, y}, {w, h}, radius)
    |> Pdf.clip()
    |> Pdf.add_image({x, y}, image, width: w, height: h)
    |> Pdf.restore_state()
  end

  defp draw_image_or_initials(doc, {x, y}, {w, h}, _radius, _image, initials, color, font)
       when is_binary(initials) and byte_size(initials) > 0 do
    font_size = font_size_for_initials(initials, min(w, h))
    text_w = String.length(initials) * font_size * 0.6
    text_h = font_size * 0.7

    tx = x + (w - text_w) / 2
    ty = y + (h - text_h) / 2

    doc
    |> Pdf.save_state()
    |> Pdf.set_font(font, font_size)
    |> Pdf.set_fill_color(color)
    |> Pdf.text_at({tx, ty}, initials)
    |> Pdf.restore_state()
  end

  defp draw_image_or_initials(doc, _pos, _size, _radius, _image, _initials, _color, _font) do
    doc
  end

  # ── Border ──────────────────────────────────────────────────────

  defp draw_border(doc, _pos, _size, _radius, 0, _color), do: doc

  defp draw_border(doc, {x, y}, {w, h}, radius, border_w, color) do
    doc
    |> Pdf.save_state()
    |> Pdf.set_stroke_color(color)
    |> Pdf.set_line_width(border_w)
    |> Pdf.rounded_rectangle({x, y}, {w, h}, radius)
    |> Pdf.stroke()
    |> Pdf.restore_state()
  end

  # ── Helpers ─────────────────────────────────────────────────────

  defp normalize_size({w, h}), do: {w, h}
  defp normalize_size(s) when is_number(s), do: {s, s}

  defp resolve_radius(:circle, min_dim), do: min_dim / 2
  defp resolve_radius(:rounded, min_dim), do: min_dim * 0.2
  defp resolve_radius(r, _min_dim) when is_number(r), do: r

  defp font_size_for_initials(text, size) do
    len = String.length(text)

    cond do
      len <= 1 -> size * 0.5
      len == 2 -> size * 0.38
      true -> size * 0.3
    end
  end

  defp set_fill_opacity(doc, opacity) when opacity < 1.0 do
    Pdf.set_fill_opacity(doc, opacity)
  end

  defp set_fill_opacity(doc, _opacity), do: doc
end