Packages

Reusable PDF components — Avatar, Badge, Card, Chip, Divider, Progress, Builder, StyledTable.

Current section

Files

Jump to
ex_pdf_components lib pdf component key_value.ex
Raw

lib/pdf/component/key_value.ex

defmodule Pdf.Component.KeyValue do
@moduledoc """
Key-value pair component for PDF documents.
Renders aligned label-value rows, like invoice details or profile info.
## Examples
doc |> Pdf.Component.KeyValue.render({50, 700}, %{width: 300}, [
{"Name:", "John Doe"},
{"Email:", "john@example.com"},
{"Role:", "Admin"}
])
"""
@default_font "Helvetica"
@default_font_size 10
@default_label_color {0.35, 0.35, 0.35}
@default_value_color {0.1, 0.1, 0.1}
@default_line_height 18
@default_label_width 0.35
@doc """
Render key-value pairs at `{x, y}`.
## Style options
- `:width` — total width (default `300`)
- `:font` — font name (default `"Helvetica"`)
- `:font_size` — text size (default `10`)
- `:label_color` — label text color
- `:value_color` — value text color
- `:line_height` — row spacing (default `18`)
- `:label_width` — fraction of width for labels (default `0.35`)
- `:divider` — show divider between rows (default `false`)
- `:divider_color` — divider line color
- `:striped` — alternate row backgrounds (default `false`)
- `:stripe_color` — background for even rows
- `:value_align``:left` (default) or `:right` to right-align values
- `:label_bold` — bold labels (default `true`)
- `:value_bold` — bold values (default `false`)
"""
@doc """
Resolve the content width for a key-value block inside a containing block.
`container_width` is the inner width of the parent. `x_offset` is the
component's horizontal inset from that edge (same semantics as `width: :full`
on text inside a box).
"""
@spec content_width(map, number | nil, number) :: number
def content_width(style, container_width, x_offset \\ 0)
def content_width(style, nil, _x_offset) do
fallback_width(Map.get(style, :width, 300))
end
def content_width(style, container_width, x_offset) do
avail_w = Pdf.Layout.ContainingBlock.text_width(%{width: container_width}, x_offset)
Pdf.Dimension.resolve(Map.get(style, :width, 300), avail_w)
end
def render(doc, {x, y}, style \\ %{}, pairs, container_width \\ nil) do
width = effective_width(style, container_width)
font = Map.get(style, :font, @default_font)
font_size = Map.get(style, :font_size, @default_font_size)
label_color = Map.get(style, :label_color, @default_label_color)
value_color = Map.get(style, :value_color, @default_value_color)
line_height = Map.get(style, :line_height, @default_line_height)
label_w = trunc(width * Map.get(style, :label_width, @default_label_width))
divider = Map.get(style, :divider, false)
divider_color = Map.get(style, :divider_color, {0.9, 0.9, 0.9})
striped = Map.get(style, :striped, false)
stripe_color = Map.get(style, :stripe_color, {0.97, 0.97, 0.97})
value_align = Map.get(style, :value_align, :left)
label_bold = Map.get(style, :label_bold, true)
value_bold = Map.get(style, :value_bold, false)
label_font = Pdf.Fonts.get_internal_font(font, if(label_bold, do: [bold: true], else: []))
value_font = Pdf.Fonts.get_internal_font(font, if(value_bold, do: [bold: true], else: []))
label_font = label_font || Pdf.Fonts.get_internal_font(font)
value_w = width - label_w
vf = value_font || Pdf.Fonts.get_internal_font(font)
ctx = %{
x: x, width: width, font: font, font_size: font_size,
label_color: label_color, value_color: value_color,
line_height: line_height, label_w: label_w, value_w: value_w,
divider: divider, divider_color: divider_color,
striped: striped, stripe_color: stripe_color,
value_align: value_align, label_bold: label_bold, value_bold: value_bold,
label_font: label_font, vf: vf
}
{doc, _cy} =
pairs
|> Enum.with_index()
|> Enum.reduce({doc, y}, fn {{label, value}, i}, {d, current_y} ->
row_y = current_y
# Stripe background
d =
if ctx.striped and rem(i, 2) == 0 do
d
|> Pdf.save_state()
|> Pdf.set_fill_color(ctx.stripe_color)
|> Pdf.rectangle({x, row_y - line_height + font_size + 2}, {width, line_height})
|> Pdf.fill()
|> Pdf.restore_state()
else
d
end
# Divider
d =
if ctx.divider and i > 0 do
d
|> Pdf.save_state()
|> Pdf.set_stroke_color(ctx.divider_color)
|> Pdf.set_line_width(0.3)
|> Pdf.line({x, row_y + line_height - font_size - 2}, {x + width, row_y + line_height - font_size - 2})
|> Pdf.stroke()
|> Pdf.restore_state()
else
d
end
label_lines = wrap_label(label, ctx.label_font, font_size, label_w)
value_lines = wrap_value(value, vf, font_size, value_w)
row_count = max(max(length(label_lines), length(value_lines)), 1)
{d, _ly} =
Enum.reduce(0..(row_count - 1), {d, row_y}, fn i, {d_acc, ly} ->
label_line = Enum.at(label_lines, i, "")
value_line = Enum.at(value_lines, i, "")
d_acc =
if label_line != "" do
Pdf.text_at(d_acc, {x, ly}, label_line, %{
font: ctx.font,
bold: ctx.label_bold,
font_size: ctx.font_size,
color: ctx.label_color
})
else
d_acc
end
d_acc =
if value_line != "" do
render_value_line(d_acc, value_line, ly, ctx)
else
d_acc
end
{d_acc, ly - line_height}
end)
next_y = current_y - line_height * row_count
{d, next_y}
end)
doc
end
@doc """
Calculate the total height this key-value list will occupy,
accounting for word-wrap on long values.
Takes the same `style` map as `render/4` plus the `pairs` list.
Returns the height in points.
"""
def measure_height(style \\ %{}, pairs, container_width \\ nil, opts \\ []) do
x_offset = Keyword.get(opts, :x_offset, 0)
font = Map.get(style, :font, @default_font)
font_size = Map.get(style, :font_size, @default_font_size)
line_height = Map.get(style, :line_height, @default_line_height)
width = effective_width(style, container_width, x_offset)
label_w = trunc(width * Map.get(style, :label_width, @default_label_width))
label_bold = Map.get(style, :label_bold, true)
value_bold = Map.get(style, :value_bold, false)
label_font = Pdf.Fonts.get_internal_font(font, if(label_bold, do: [bold: true], else: []))
value_font = Pdf.Fonts.get_internal_font(font, if(value_bold, do: [bold: true], else: []))
label_font = label_font || Pdf.Fonts.get_internal_font(font)
vf = value_font || Pdf.Fonts.get_internal_font(font)
value_w = width - label_w
Enum.reduce(pairs, 0, fn {label, value}, total ->
label_lines = wrap_label(label, label_font, font_size, label_w)
value_lines = wrap_value(value, vf, font_size, value_w)
row_count = max(max(length(label_lines), length(value_lines)), 1)
total + line_height * row_count
end)
end
# ── Value line rendering ─────────────────────────────────────────
# Plain string line
defp render_value_line(doc, text, ly, ctx) when is_binary(text) do
doc = Pdf.set_font(doc, ctx.font, ctx.font_size, bold: ctx.value_bold)
doc = Pdf.set_fill_color(doc, ctx.value_color)
lx =
case ctx.value_align do
:right ->
font_module = doc.current.current_font.module
tw = Pdf.Font.text_width(font_module, text, ctx.font_size)
ctx.x + ctx.width - tw
_ ->
ctx.x + ctx.label_w
end
Pdf.text_at(doc, {lx, ly}, text)
end
# Rich text line — list of %{text, color} segments
defp render_value_line(doc, segments, ly, ctx) when is_list(segments) do
doc = Pdf.set_font(doc, ctx.font, ctx.font_size, bold: ctx.value_bold)
case ctx.value_align do
:right ->
font_module = doc.current.current_font.module
total_w =
Enum.reduce(segments, 0, fn seg, acc ->
acc + Pdf.Font.text_width(font_module, seg.text, ctx.font_size)
end)
start_x = ctx.x + ctx.width - total_w
Enum.reduce(segments, {doc, start_x}, fn seg, {d, sx} ->
color = Map.get(seg, :color, ctx.value_color)
tw = Pdf.Font.text_width(font_module, seg.text, ctx.font_size)
d =
d
|> Pdf.set_fill_color(color)
|> Pdf.text_at({sx, ly}, seg.text)
{d, sx + tw}
end)
|> elem(0)
_ ->
font_module = doc.current.current_font.module
Enum.reduce(segments, {doc, ctx.x + ctx.label_w}, fn seg, {d, sx} ->
color = Map.get(seg, :color, ctx.value_color)
tw = Pdf.Font.text_width(font_module, seg.text, ctx.font_size)
d =
d
|> Pdf.set_fill_color(color)
|> Pdf.text_at({sx, ly}, seg.text)
{d, sx + tw}
end)
|> elem(0)
end
end
# ── Width helpers ────────────────────────────────────────────────
defp effective_width(style, container_width, x_offset \\ 0) do
case Map.get(style, :width) do
w when is_number(w) ->
w
_ when is_number(container_width) ->
content_width(style, container_width, x_offset)
_ ->
fallback_width(Map.get(style, :width, 300))
end
end
defp fallback_width(value) when is_number(value), do: value
defp fallback_width(:full), do: 300
defp fallback_width("100%"), do: 300
defp fallback_width(value) do
if Pdf.Dimension.relative?(value), do: 300, else: value
end
# ── Wrap dispatcher ──────────────────────────────────────────────
defp wrap_label(text, font_struct, font_size, max_width) do
wrap_plain_text(to_string(text || ""), font_struct, font_size, max_width)
end
# Plain string → list of strings
defp wrap_value(text, font_struct, font_size, max_width) when is_binary(text) do
wrap_plain_text(text, font_struct, font_size, max_width)
end
defp wrap_value(nil, font_struct, font_size, max_width) do
wrap_value("", font_struct, font_size, max_width)
end
defp wrap_value(value, font_struct, font_size, max_width)
when is_integer(value) or is_float(value) or is_atom(value) do
wrap_value(to_string(value), font_struct, font_size, max_width)
end
# Rich text → list of [%{text, color}, ...] per line
defp wrap_value(spans, font_struct, font_size, max_width) when is_list(spans) do
spans
|> flatten_to_colored_words()
|> wrap_colored_words(font_struct, font_size, max_width)
end
# ── Plain text wrap ──────────────────────────────────────────────
defp wrap_plain_text(text, font_struct, font_size, max_width) do
words = String.split(text)
{lines, current_line} =
Enum.reduce(words, {[], ""}, fn word, {lines, current} ->
candidate = if current == "", do: word, else: current <> " " <> word
w =
if font_struct,
do: Pdf.Font.text_width(font_struct, candidate, font_size),
else: String.length(candidate) * font_size * 0.5
if w > max_width and current != "" do
{lines ++ [current], word}
else
{lines, candidate}
end
end)
if current_line != "", do: lines ++ [current_line], else: lines
end
# ── Rich text wrap ───────────────────────────────────────────────
defp flatten_to_colored_words(spans) do
Enum.flat_map(spans, fn span ->
color = Map.get(span, :color)
span.text
|> String.split()
|> Enum.map(&{&1, color})
end)
end
defp wrap_colored_words(words, font_struct, font_size, max_width) do
{lines, current_words, _current_text} =
Enum.reduce(words, {[], [], ""}, fn {word, color}, {lines, cw, ct} ->
candidate = if ct == "", do: word, else: ct <> " " <> word
w =
if font_struct,
do: Pdf.Font.text_width(font_struct, candidate, font_size),
else: String.length(candidate) * font_size * 0.5
if w > max_width and ct != "" do
{lines ++ [collapse_colored_words(cw)], [{word, color}], word}
else
{lines, cw ++ [{word, color}], candidate}
end
end)
if current_words != [],
do: lines ++ [collapse_colored_words(current_words)],
else: lines
end
defp collapse_colored_words(words) do
words
|> Enum.reduce([], fn {word, color}, acc ->
case acc do
[%{text: text, color: ^color} | rest] ->
[%{text: text <> " " <> word, color: color} | rest]
_ ->
[%{text: word, color: color} | acc]
end
end)
|> Enum.reverse()
end
end