Skip to main content

lib/pdf/component/toc.ex

defmodule Pdf.Component.TOC do
  @moduledoc """
  Table of Contents component for PDF documents.

  Renders a list of entries with titles, optional dot leaders,
  and right-aligned page numbers.

  ## Examples

      doc |> Pdf.Component.TOC.render({50, 700}, %{width: 450}, [
        %{title: "Introduction", page: 1},
        %{title: "Getting Started", page: 3, level: 1},
        %{title: "Installation", page: 3, level: 2},
        %{title: "Configuration", page: 5, level: 2},
        %{title: "Advanced Usage", page: 10, level: 1}
      ])
  """

  @default_font "Helvetica"
  @default_font_size 10
  @default_color {0.1, 0.1, 0.1}
  @default_page_color {0.4, 0.4, 0.4}
  @default_dot_color {0.7, 0.7, 0.7}
  @default_line_height 18
  @default_indent 20

  @doc """
  Render a table of contents at `{x, y}`.

  ## Style options

  - `:width` — total width (default `450`)
  - `:font` — font name (default `"Helvetica"`)
  - `:font_size` — base text size (default `10`)
  - `:color` — title text color
  - `:page_color` — page number color
  - `:dot_color` — dot leader color
  - `:line_height` — row spacing (default `18`)
  - `:indent` — indentation per level (default `20`)
  - `:dots` — show dot leaders (default `true`)

  ## Entries format

  List of maps: `%{title: "Section", page: 1, level: 1}`
  Level defaults to `1`. Level `0` renders bold (chapter heading).
  """
  def render(doc, {x, y}, style \\ %{}, entries) do
    width = Map.get(style, :width, 450)
    font = Map.get(style, :font, @default_font)
    font_size = Map.get(style, :font_size, @default_font_size)
    color = Map.get(style, :color, @default_color)
    page_color = Map.get(style, :page_color, @default_page_color)
    dot_color = Map.get(style, :dot_color, @default_dot_color)
    line_height = Map.get(style, :line_height, @default_line_height)
    indent = Map.get(style, :indent, @default_indent)
    dots = Map.get(style, :dots, true)

    entries
    |> Enum.with_index()
    |> Enum.reduce(doc, fn {entry, i}, d ->
      level = Map.get(entry, :level, 1)
      title = Map.get(entry, :title, "")
      page = Map.get(entry, :page, "")
      page_str = "#{page}"

      ey = y - i * line_height
      entry_x = x + (level - 0) * indent
      is_heading = level == 0

      fs = if is_heading, do: font_size + 1, else: font_size
      font_opts = if is_heading, do: [bold: true], else: []

      # Title
      d =
        d
        |> Pdf.set_font(font, fs, font_opts)
        |> Pdf.set_fill_color(color)
        |> Pdf.text_at({entry_x, ey}, title)

      # Page number (right-aligned)
      page_w = String.length(page_str) * fs * 0.55
      page_x = x + width - page_w

      d =
        d
        |> Pdf.set_font(font, fs)
        |> Pdf.set_fill_color(page_color)
        |> Pdf.text_at({page_x, ey}, page_str)

      # Dot leaders
      if dots and not is_heading do
        title_w = String.length(title) * fs * 0.52
        dot_start = entry_x + title_w + 4
        dot_end = page_x - 4
        dot_spacing = 4

        if dot_end > dot_start do
          dots_count = trunc((dot_end - dot_start) / dot_spacing)
          dot_text = String.duplicate(". ", dots_count)

          d
          |> Pdf.set_font(font, fs - 2)
          |> Pdf.set_fill_color(dot_color)
          |> Pdf.text_at({dot_start, ey}, dot_text)
        else
          d
        end
      else
        d
      end
    end)
  end
end