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