defmodule Pdf.Component.Alert do
@moduledoc """
Alert/Callout component for PDF documents.
Renders a colored notification box with an icon character, title,
and message text. Supports info, warning, error, and success variants.
## Examples
doc |> Pdf.Component.Alert.render({50, 700}, %{
type: :info,
title: "Note",
message: "This is an informational message.",
width: 400
})
doc |> Pdf.Component.Alert.render({50, 700}, %{
type: :error,
title: "Error",
message: "Something went wrong. Please try again.",
width: 400
})
"""
@presets %{
info: %{
icon: "i",
bg: {0.93, 0.95, 1.0},
bar: {0.25, 0.47, 0.85},
icon_bg: {0.25, 0.47, 0.85},
title_color: {0.15, 0.30, 0.60}
},
success: %{
icon: "!",
bg: {0.93, 0.98, 0.93},
bar: {0.20, 0.65, 0.30},
icon_bg: {0.20, 0.65, 0.30},
title_color: {0.12, 0.45, 0.18}
},
warning: %{
icon: "!",
bg: {1.0, 0.97, 0.90},
bar: {0.85, 0.65, 0.10},
icon_bg: {0.85, 0.65, 0.10},
title_color: {0.60, 0.45, 0.05}
},
error: %{
icon: "x",
bg: {1.0, 0.93, 0.93},
bar: {0.80, 0.20, 0.20},
icon_bg: {0.80, 0.20, 0.20},
title_color: {0.60, 0.12, 0.12}
}
}
@default_font "Helvetica"
@default_padding 12
@default_border_radius 5
@doc """
Render an alert at `{x, y}`.
## Style options
- `:type` — `:info` (default), `:success`, `:warning`, or `:error`
- `:title` — bold title text
- `:message` — body message text (required)
- `:width` — alert width (required)
- `:font` — font name (default `"Helvetica"`)
- `:padding` — inner padding (default `12`)
- `:border_radius` — corner radius (default `5`)
- `:icon` — override icon character
"""
def render(doc, {x, y}, style \\ %{}) do
type = Map.get(style, :type, :info)
preset = Map.get(@presets, type, @presets.info)
title = Map.get(style, :title)
message = Map.get(style, :message, "")
width = Map.get(style, :width, 400)
font = Map.get(style, :font, @default_font)
padding = Map.get(style, :padding, @default_padding)
radius = Map.get(style, :border_radius, @default_border_radius)
icon = Map.get(style, :icon, preset.icon)
bg = Map.get(style, :background, preset.bg)
bar_color = Map.get(style, :bar_color, preset.bar)
icon_bg = Map.get(style, :icon_bg, preset.icon_bg)
title_color = Map.get(style, :title_color, preset.title_color)
message_color = Map.get(style, :message_color, {0.25, 0.25, 0.25})
icon_size = 18
icon_area = icon_size + 8
text_x = x + padding + icon_area + 8
avail_w = width - padding * 2 - icon_area - 8
# Calculate height
line_h = 14
title_h = if title, do: 16, else: 0
chars_per_line = max(trunc(avail_w / (10 * 0.52)), 10)
msg_lines = wrap_text(message, chars_per_line)
msg_h = length(msg_lines) * line_h
total_h = padding + max(title_h + msg_h, icon_area) + padding
# Background
doc =
doc
|> Pdf.save_state()
|> Pdf.set_fill_color(bg)
|> Pdf.rounded_rectangle({x, y - total_h}, {width, total_h}, radius)
|> Pdf.fill()
|> Pdf.restore_state()
# Left accent bar
bar_w = 4
doc =
doc
|> Pdf.save_state()
|> Pdf.set_fill_color(bar_color)
|> Pdf.rectangle({x, y - total_h}, {bar_w, total_h})
|> Pdf.fill()
|> Pdf.restore_state()
# Icon circle
icon_cx = x + padding + icon_size / 2 + 2
icon_cy = y - padding - icon_size / 2
icon_r = icon_size / 2
icon_box_x = icon_cx - icon_r
icon_box_y = icon_cy - icon_r
doc =
doc
|> Pdf.save_state()
|> Pdf.set_fill_color(icon_bg)
|> Pdf.rounded_rectangle({icon_box_x, icon_box_y}, {icon_size, icon_size}, icon_r)
|> Pdf.fill()
|> Pdf.restore_state()
|> Pdf.set_font(font, 11, bold: true)
|> Pdf.set_fill_color({1.0, 1.0, 1.0})
|> Pdf.text_at({icon_cx - 3, icon_cy - 4}, icon)
# Title
{doc, text_y} =
if title do
doc =
doc
|> Pdf.set_font(font, 11, bold: true)
|> Pdf.set_fill_color(title_color)
|> Pdf.text_at({text_x, y - padding - 11}, title)
{doc, y - padding - title_h - 11}
else
{doc, y - padding - 11}
end
# Message lines
Enum.with_index(msg_lines)
|> Enum.reduce(doc, fn {line, i}, d ->
d
|> Pdf.set_font(font, 10)
|> Pdf.set_fill_color(message_color)
|> Pdf.text_at({text_x, text_y - i * line_h}, line)
end)
end
defp wrap_text(text, chars_per_line) do
words = String.split(text, " ")
{lines, current} =
Enum.reduce(words, {[], ""}, fn word, {lines, current} ->
candidate = if current == "", do: word, else: current <> " " <> word
if String.length(candidate) > chars_per_line and current != "" do
{[current | lines], word}
else
{lines, candidate}
end
end)
Enum.reverse(if current != "", do: [current | lines], else: lines)
end
end