Skip to main content

lib/pdf/component/list.ex

defmodule Pdf.Component.List do
  @moduledoc """
  List component for PDF documents.

  Renders bulleted or numbered lists with support for nesting,
  custom markers, and per-level styling.

  ## Examples

      doc |> Pdf.Component.List.render({50, 700}, %{}, [
        "First item",
        "Second item",
        {:nested, ["Sub-item A", "Sub-item B"]},
        "Third item"
      ])

      doc |> Pdf.Component.List.render({50, 700}, %{type: :numbered}, [
        "Step one",
        "Step two",
        "Step three"
      ])
  """

  @default_font "Helvetica"
  @default_font_size 10
  @default_color {0.1, 0.1, 0.1}
  @default_line_height 16
  @default_indent 15
  @default_marker_gap 8

  @bullets ["-", "-", "-"]
  @numbered_formats [:decimal, :alpha, :roman]

  @doc """
  Render a list at `{x, y}`.

  ## Style options

  - `:type` — `:bullet` (default) or `:numbered`
  - `:font` — font name (default `"Helvetica"`)
  - `:font_size` — text size (default `10`)
  - `:color` — text color (default dark)
  - `:line_height` — spacing between items (default `16`)
  - `:indent` — indentation per nesting level (default `15`)
  - `:marker_gap` — space between marker and text (default `8`)
  - `:marker_color` — marker color (defaults to `:color`)

  ## Items format

  Items is a flat list where:
  - `"string"` — a list item
  - `{:nested, [items]}` — a nested sub-list
  """
  def render(doc, {x, y}, style \\ %{}, items) do
    type = Map.get(style, :type, :bullet)
    font = Map.get(style, :font, @default_font)
    font_size = Map.get(style, :font_size, @default_font_size)
    color = Map.get(style, :color, @default_color)
    line_height = Map.get(style, :line_height, @default_line_height)
    indent = Map.get(style, :indent, @default_indent)
    marker_gap = Map.get(style, :marker_gap, @default_marker_gap)
    marker_color = Map.get(style, :marker_color, color)

    ctx = %{
      type: type,
      font: font,
      font_size: font_size,
      color: color,
      line_height: line_height,
      indent: indent,
      marker_gap: marker_gap,
      marker_color: marker_color
    }

    {doc, _y} = render_items(doc, x, y, items, 0, 1, ctx)
    doc
  end

  defp render_items(doc, x, y, items, level, counter, ctx) do
    Enum.reduce(items, {doc, y, counter}, fn item, {d, cy, n} ->
      case item do
        {:nested, sub_items} ->
          {d2, cy2} = render_items(d, x, cy, sub_items, level + 1, 1, ctx)
          {d2, cy2, n}

        text when is_binary(text) ->
          item_x = x + level * ctx.indent
          marker = get_marker(ctx.type, level, n)

          d2 =
            d
            |> Pdf.set_font(ctx.font, ctx.font_size)
            |> Pdf.set_fill_color(ctx.marker_color)
            |> Pdf.text_at({item_x, cy}, marker)
            |> Pdf.set_fill_color(ctx.color)
            |> Pdf.text_at({item_x + marker_width(marker, ctx.font_size) + ctx.marker_gap, cy}, text)

          {d2, cy - ctx.line_height, n + 1}
      end
    end)
    |> then(fn {doc, y, _counter} -> {doc, y} end)
  end

  defp get_marker(:bullet, level, _n) do
    Enum.at(@bullets, rem(level, length(@bullets)))
  end

  defp get_marker(:numbered, level, n) do
    format = Enum.at(@numbered_formats, rem(level, length(@numbered_formats)))
    format_number(n, format) <> "."
  end

  defp format_number(n, :decimal), do: Integer.to_string(n)
  defp format_number(n, :alpha) when n in 1..26, do: <<(n + 96)>>
  defp format_number(n, :alpha), do: Integer.to_string(n)
  defp format_number(n, :roman), do: to_roman(n)

  defp to_roman(n) when n <= 0, do: ""
  defp to_roman(n) when n >= 10, do: String.duplicate("x", div(n, 10)) <> to_roman(rem(n, 10))
  defp to_roman(9), do: "ix"
  defp to_roman(n) when n >= 5, do: "v" <> String.duplicate("i", n - 5)
  defp to_roman(4), do: "iv"
  defp to_roman(n), do: String.duplicate("i", n)

  defp marker_width(marker, font_size) do
    String.length(marker) * font_size * 0.52
  end
end