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