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