lib/pdf/builder.ex

defmodule Pdf.Builder do
  @moduledoc """
  Declarative PDF builder from template lists.

  Renders a list of content tuples into a PDF document, applying
  global configuration for page size, margins, fonts, and templates.

  ## Example

      template = [
        {:text, "Title", %{font_size: 24, bold: true}},
        {:spacer, 10},
        {:text, "Body text", %{font_size: 12}},
        {:line, %{color: :gray}},
        {:page_break},
        {:text, "Page 2", %{font_size: 18}}
      ]

      config = %{
        size: :a4,
        margin: 40,
        font: "Helvetica",
        font_size: 12
      }

      doc = Pdf.Builder.render(template, config)
      binary = Pdf.export(doc)
  """

  @doc """
  Render a template list with the given config into a PDF document.

  ## Config keys

  - `:size` — page size (default `:a4`)
  - `:margin` — margin value or map (default `0`)
  - `:font` — default font name (default `"Helvetica"`)
  - `:font_size` — default font size (default `12`)
  - `:compress` — compress streams (default `true`)
  - `:header` — `fn doc, page_info -> doc end` template
  - `:footer` — `fn doc, page_info -> doc end` template
  - `:watermark` — `fn doc, page_info -> doc end` template
  - `:background` — `fn doc, page_info -> doc end` template
  """
  def render(template, config \\ %{}) when is_list(template) do
    config = normalize_config(config)

    opts = [
      size: config.size,
      margin: config.margin,
      compress: config.compress
    ]

    doc = Pdf.new(opts)

    doc = register_templates(doc, config)

    doc =
      doc
      |> Pdf.set_font(config.font, config.font_size)

    Enum.reduce(List.flatten(template), doc, &render_element/2)
  end

  @doc """
  Render a template list into an existing document.
  Nested lists are automatically flattened.
  """
  def render_into(document, template) when is_list(template) do
    Enum.reduce(List.flatten(template), document, &render_element/2)
  end

  defp normalize_config(config) when is_map(config) do
    %{
      size: Map.get(config, :size, :a4),
      margin: Map.get(config, :margin, 0),
      font: Map.get(config, :font, "Helvetica"),
      font_size: Map.get(config, :font_size, 12),
      compress: Map.get(config, :compress, true),
      header: Map.get(config, :header),
      footer: Map.get(config, :footer),
      watermark: Map.get(config, :watermark),
      background: Map.get(config, :background),
      styles: Map.get(config, :styles, %{}),
      debug: Map.get(config, :debug)
    }
  end

  defp register_templates(doc, config) do
    doc
    |> register_styles(config.styles)
    |> maybe_register(:header, config.header)
    |> maybe_register(:footer, config.footer)
    |> maybe_register(:watermark, config.watermark)
    |> maybe_register(:background, config.background)
    |> maybe_debug_grid(config.debug)
  end

  defp maybe_debug_grid(doc, nil), do: doc
  defp maybe_debug_grid(doc, true), do: Pdf.debug_grid(doc)

  defp maybe_debug_grid(doc, debug_opts) when is_map(debug_opts),
    do: Pdf.debug_grid(doc, debug_opts)

  defp register_styles(doc, styles) when map_size(styles) == 0, do: doc
  defp register_styles(doc, styles), do: Pdf.register_styles(doc, styles)

  defp maybe_register(doc, _name, nil), do: doc

  defp maybe_register(doc, name, func) when is_function(func, 2) do
    Pdf.on_page(doc, name, func)
  end

  # ── Map-based element renderers ─────────────────────────────────

  defp render_element(%{text: string} = el, doc) do
    style = Map.drop(el, [:text])
    if map_size(style) == 0, do: Pdf.text(doc, string), else: Pdf.text(doc, string, style)
  end

  defp render_element(%{custom: func}, doc) when is_function(func, 1) do
    func.(doc)
  end

  defp render_element(%{spacer: amount}, doc) do
    Pdf.spacer(doc, amount)
  end

  defp render_element(%{line: style}, doc) when is_map(style) do
    Pdf.horizontal_line(doc, style)
  end

  defp render_element(%{line: true}, doc) do
    Pdf.horizontal_line(doc)
  end

  defp render_element(%{page_break: true}, doc) do
    Pdf.page_break(doc)
  end

  defp render_element(%{page_break: size}, doc) do
    Pdf.page_break(doc, size)
  end

  defp render_element(%{watermark: text} = el, doc) do
    style = Map.drop(el, [:watermark])
    if map_size(style) == 0, do: Pdf.watermark(doc, text), else: Pdf.watermark(doc, text, style)
  end

  defp render_element(%{background: style}, doc) do
    Pdf.background(doc, style)
  end

  defp render_element(%{rect: {x, y}, size: {w, h}} = el, doc) do
    fill = Map.get(el, :fill)
    stroke = Map.get(el, :stroke)
    lw = Map.get(el, :line_width, 0.5)

    doc = Pdf.save_state(doc)
    doc = Pdf.set_line_width(doc, lw)

    doc =
      if fill do
        doc |> Pdf.set_fill_color(fill) |> Pdf.rectangle({x, y}, {w, h}) |> Pdf.fill()
      else
        doc
      end

    doc =
      if stroke do
        doc |> Pdf.set_stroke_color(stroke) |> Pdf.rectangle({x, y}, {w, h}) |> Pdf.stroke()
      else
        doc
      end

    Pdf.restore_state(doc)
  end

  defp render_element(%{line_from: {x1, y1}, line_to: {x2, y2}} = el, doc) do
    stroke = Map.get(el, :stroke, {0, 0, 0})
    lw = Map.get(el, :line_width, 0.5)

    doc
    |> Pdf.save_state()
    |> Pdf.set_stroke_color(stroke)
    |> Pdf.set_line_width(lw)
    |> Pdf.line({x1, y1}, {x2, y2})
    |> Pdf.stroke()
    |> Pdf.restore_state()
  end

  # ── Element renderers ──────────────────────────────────────────────

  defp render_element({:text, string}, doc) do
    Pdf.text(doc, string)
  end

  defp render_element({:text, string, style}, doc) do
    Pdf.text(doc, string, style)
  end

  defp render_element({:spacer, amount}, doc) do
    Pdf.spacer(doc, amount)
  end

  defp render_element({:line}, doc) do
    Pdf.horizontal_line(doc)
  end

  defp render_element({:line, style}, doc) do
    Pdf.horizontal_line(doc, style)
  end

  defp render_element({:page_break}, doc) do
    Pdf.page_break(doc)
  end

  defp render_element({:page_break, size}, doc) do
    Pdf.page_break(doc, size)
  end

  defp render_element({:watermark, text}, doc) do
    Pdf.watermark(doc, text)
  end

  defp render_element({:watermark, text, style}, doc) do
    Pdf.watermark(doc, text, style)
  end

  defp render_element({:background, style}, doc) do
    Pdf.background(doc, style)
  end

  defp render_element({:image, path, style}, doc) do
    pos = Pdf.cursor_xy(doc)
    opts = []
    opts = if Map.has_key?(style, :width), do: [{:width, style.width} | opts], else: opts
    opts = if Map.has_key?(style, :height), do: [{:height, style.height} | opts], else: opts
    Pdf.add_image(doc, {pos.x, pos.y}, path, opts)
  end

  defp render_element({:image, path}, doc) do
    pos = Pdf.cursor_xy(doc)
    Pdf.add_image(doc, {pos.x, pos.y}, path)
  end

  defp render_element({:table, data, opts}, doc) do
    Pdf.styled_table(doc, data, opts)
  end

  defp render_element({:table, data}, doc) do
    Pdf.styled_table(doc, data)
  end

  defp render_element({:set_font, name, size}, doc) do
    Pdf.set_font(doc, name, size)
  end

  defp render_element({:set_font, name, size, opts}, doc) do
    Pdf.set_font(doc, name, size, opts)
  end
end