defmodule Pdf.Component.Card do
@moduledoc """
Card component for PDF documents.
Renders a container with optional header, body, and footer sections,
elevation (box-shadow), and rounded corners. Designed for structured
content blocks like profile cards, info panels, and summaries.
Inspired by Material UI's Card component.
## Examples
# Simple card with callback
doc |> Pdf.Component.Card.render({50, 700}, {300, 150}, %{
elevation: 2,
border_radius: 8
}, fn doc, area ->
doc |> Pdf.text_at({area.x, area.y - 14}, "Card content")
end)
# Card with header and body
doc |> Pdf.Component.Card.render({50, 700}, {300, 200}, %{
elevation: 3,
header: %{title: "User Profile", subtitle: "Senior Developer"},
padding: 12
}, fn doc, area ->
doc |> Pdf.text_at({area.x, area.y - 14}, "Card body content here")
end)
"""
@default_background {1.0, 1.0, 1.0}
@default_border_radius 8
@default_padding 12
@default_header_bg {0.97, 0.97, 0.97}
@default_header_border {0.90, 0.90, 0.90}
@doc """
Render a card at `{x, y}` (top-left) with size `{w, h}`.
## Style options
- `:background` — card background color (default white)
- `:border_radius` — corner radius (default `8`)
- `:border` — border width (default `0`)
- `:border_color` — border color (default light gray)
- `:elevation` — shadow level 0-5 (default `1`)
- `:padding` — inner padding (default `12`)
- `:header` — map with `:title`, `:subtitle`, `:background`, `:height`
- `:footer` — map with `:text`, `:background`, `:height`
The callback receives `fn doc, area -> ... end` where `area` is the
content area after header and padding are accounted for.
"""
def render(doc, {x, y}, {w, h}, style \\ %{}, callback \\ nil) do
bg = Map.get(style, :background, @default_background)
radius = Map.get(style, :border_radius, @default_border_radius)
border_w = Map.get(style, :border, 0)
border_color = Map.get(style, :border_color, {0.88, 0.88, 0.88})
elevation = Map.get(style, :elevation, 1)
padding = Map.get(style, :padding, @default_padding)
header = Map.get(style, :header)
footer = Map.get(style, :footer)
# PDF coords: {x, y} is top-left, so bottom-left = {x, y - h}
bx = x
by = y - h
# Shadow
doc = draw_shadow(doc, {bx, by}, {w, h}, radius, elevation)
# Background
doc = doc
|> Pdf.save_state()
|> Pdf.set_fill_color(bg)
|> Pdf.rounded_rectangle({bx, by}, {w, h}, radius)
|> Pdf.fill()
|> Pdf.restore_state()
# Header
{doc, header_h} = draw_header(doc, {bx, y}, w, radius, header)
# Footer
{doc, footer_h} = draw_footer(doc, {bx, by}, w, radius, footer)
# Border (on top of everything)
doc = if border_w > 0 do
doc
|> Pdf.save_state()
|> Pdf.set_stroke_color(border_color)
|> Pdf.set_line_width(border_w)
|> Pdf.rounded_rectangle({bx, by}, {w, h}, radius)
|> Pdf.stroke()
|> Pdf.restore_state()
else
doc
end
# Content area callback
if is_function(callback, 2) do
content_area = %{
x: bx + padding,
y: y - header_h - padding,
width: w - padding * 2,
height: h - header_h - footer_h - padding * 2
}
callback.(doc, content_area)
else
doc
end
end
# ── Header ─────────────────────────────────────────────────────
defp draw_header(doc, _pos, _w, _radius, nil), do: {doc, 0}
defp draw_header(doc, {x, y}, w, radius, header) when is_map(header) do
height = Map.get(header, :height, 40)
bg = Map.get(header, :background, @default_header_bg)
title = Map.get(header, :title, "")
subtitle = Map.get(header, :subtitle)
title_color = Map.get(header, :title_color, {0.1, 0.1, 0.1})
subtitle_color = Map.get(header, :subtitle_color, {0.5, 0.5, 0.5})
by = y - height
# Clip header to card's top rounded corners
doc = doc
|> Pdf.save_state()
|> Pdf.set_fill_color(bg)
|> Pdf.rounded_rectangle({x, by}, {w, height}, radius)
|> Pdf.fill()
# Header bottom border
doc = doc
|> Pdf.set_stroke_color(@default_header_border)
|> Pdf.set_line_width(0.5)
|> Pdf.line({x, by}, {x + w, by})
|> Pdf.stroke()
# Title
doc = if title != "" do
title_y = if subtitle, do: y - 16, else: y - height / 2 - 5
doc
|> Pdf.set_font("Helvetica", 12, bold: true)
|> Pdf.set_fill_color(title_color)
|> Pdf.text_at({x + 12, title_y}, title)
else
doc
end
# Subtitle
doc = if subtitle do
doc
|> Pdf.set_font("Helvetica", 9)
|> Pdf.set_fill_color(subtitle_color)
|> Pdf.text_at({x + 12, y - 30}, subtitle)
else
doc
end
doc = Pdf.restore_state(doc)
{doc, height}
end
# ── Footer ─────────────────────────────────────────────────────
defp draw_footer(doc, _pos, _w, _radius, nil), do: {doc, 0}
defp draw_footer(doc, {x, by}, w, radius, footer) when is_map(footer) do
height = Map.get(footer, :height, 32)
bg = Map.get(footer, :background, @default_header_bg)
text = Map.get(footer, :text, "")
text_color = Map.get(footer, :text_color, {0.5, 0.5, 0.5})
doc = doc
|> Pdf.save_state()
|> Pdf.set_fill_color(bg)
|> Pdf.rounded_rectangle({x, by}, {w, height}, radius)
|> Pdf.fill()
# Footer top border
fy = by + height
doc = doc
|> Pdf.set_stroke_color(@default_header_border)
|> Pdf.set_line_width(0.5)
|> Pdf.line({x, fy}, {x + w, fy})
|> Pdf.stroke()
# Footer text
doc = if text != "" do
doc
|> Pdf.set_font("Helvetica", 9)
|> Pdf.set_fill_color(text_color)
|> Pdf.text_at({x + 12, by + height / 2 - 4}, text)
else
doc
end
doc = Pdf.restore_state(doc)
{doc, height}
end
# ── Shadow (reuse Avatar pattern) ──────────────────────────────
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
defp set_fill_opacity(doc, opacity) do
Pdf.set_fill_opacity(doc, opacity)
end
defp shadow_layers(1), do: [{0, -0.5, 1.0, 0.06}, {0, -0.3, 0.5, 0.04}]
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}]
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}]
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}]
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