Skip to main content

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`)
  """
  def render(doc, {x, y}, style \\ %{}, pairs) do
    width = Map.get(style, :width, 300)
    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)

    font_struct = Pdf.Fonts.get_internal_font(font, if(value_bold, do: [bold: true], else: []))

    value_w = width - label_w
    vf = font_struct || 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,
      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

        # Wrap value into lines
        lines = wrap_value(value, vf, font_size, value_w)

        # Render label on first line
        d =
          d
          |> Pdf.text_at({x, row_y}, label, %{
            font: ctx.font,
            bold: ctx.label_bold,
            font_size: ctx.font_size,
            color: ctx.label_color
          })

        # Render value lines
        {d, _ly} =
          Enum.reduce(lines, {d, row_y}, fn line, {d_acc, ly} ->
            d_acc = render_value_line(d_acc, line, ly, ctx)
            {d_acc, ly - line_height}
          end)

        next_y = current_y - line_height * max(length(lines), 1)
        {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) do
    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 = Map.get(style, :width, 300)
    label_w = trunc(width * Map.get(style, :label_width, @default_label_width))
    value_bold = Map.get(style, :value_bold, false)

    font_struct = Pdf.Fonts.get_internal_font(font, if(value_bold, do: [bold: true], else: []))
    vf = font_struct || Pdf.Fonts.get_internal_font(font)
    value_w = width - label_w

    Enum.reduce(pairs, 0, fn {_label, value}, total ->
      lines = wrap_value(value, vf, font_size, value_w)
      total + line_height * max(length(lines), 1)
    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

  # ── Wrap dispatcher ──────────────────────────────────────────────

  # 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