Skip to main content

lib/pdf/component/blockquote.ex

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

  Renders an indented text block with a colored left bar,
  optional background, and citation line.

  ## Examples

      doc |> Pdf.Component.Blockquote.render({50, 700}, %{width: 400},
        "The best way to predict the future is to invent it.")

      doc |> Pdf.Component.Blockquote.render({50, 700}, %{
        width: 400,
        bar_color: {0.2, 0.5, 0.8},
        cite: "— Alan Kay"
      }, "The best way to predict the future is to invent it.")
  """

  @default_bar_color {0.75, 0.75, 0.75}
  @default_bar_width 3
  @default_padding 12
  @default_font "Helvetica"
  @default_font_size 10
  @default_color {0.25, 0.25, 0.25}
  @default_cite_color {0.5, 0.5, 0.5}
  @default_bg nil

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

  ## Style options

  - `:width` — total width of the blockquote (required)
  - `:bar_color` — left accent bar color (default gray)
  - `:bar_width` — bar thickness (default `3`)
  - `:background` — optional background color
  - `:padding` — inner padding (default `12`)
  - `:font` — font name (default `"Helvetica"`)
  - `:font_size` — text size (default `10`)
  - `:color` — text color (default dark gray)
  - `:italic` — render text in italic (default `true`)
  - `:cite` — optional citation line below the quote
  - `:cite_color` — citation text color (default lighter gray)
  """
  def render(doc, {x, y}, style \\ %{}, text) do
    width = Map.get(style, :width, 400)
    bar_color = Map.get(style, :bar_color, @default_bar_color)
    bar_width = Map.get(style, :bar_width, @default_bar_width)
    bg = Map.get(style, :background, @default_bg)
    padding = Map.get(style, :padding, @default_padding)
    font = Map.get(style, :font, @default_font)
    font_size = Map.get(style, :font_size, @default_font_size)
    color = Map.get(style, :color, @default_color)
    italic = Map.get(style, :italic, true)
    cite = Map.get(style, :cite)
    cite_color = Map.get(style, :cite_color, @default_cite_color)

    line_height = font_size * 1.5
    text_x = x + bar_width + padding

    # Estimate height: wrap text manually by character count
    avail_w = width - bar_width - padding * 2
    chars_per_line = max(trunc(avail_w / (font_size * 0.52)), 10)
    lines = wrap_text(text, chars_per_line)
    text_h = length(lines) * line_height
    cite_h = if cite, do: line_height + 4, else: 0
    total_h = padding + text_h + cite_h + padding

    # Background
    doc =
      if bg do
        doc
        |> Pdf.save_state()
        |> Pdf.set_fill_color(bg)
        |> Pdf.rectangle({x, y - total_h}, {width, total_h})
        |> Pdf.fill()
        |> Pdf.restore_state()
      else
        doc
      end

    # Left bar
    doc =
      doc
      |> Pdf.save_state()
      |> Pdf.set_fill_color(bar_color)
      |> Pdf.rectangle({x, y - total_h}, {bar_width, total_h})
      |> Pdf.fill()
      |> Pdf.restore_state()

    # Text lines
    font_opts = if italic, do: [italic: true], else: []

    doc =
      doc
      |> Pdf.set_font(font, font_size, font_opts)
      |> Pdf.set_fill_color(color)

    {doc, last_y} =
      Enum.reduce(lines, {doc, y - padding - font_size}, fn line, {d, ly} ->
        d2 = Pdf.text_at(d, {text_x, ly}, line)
        {d2, ly - line_height}
      end)

    # Citation
    doc =
      if cite do
        doc
        |> Pdf.set_font(font, font_size - 1)
        |> Pdf.set_fill_color(cite_color)
        |> Pdf.text_at({text_x, last_y - 4}, cite)
      else
        doc
      end

    doc
  end

  defp wrap_text(text, chars_per_line) do
    words = String.split(text, " ")

    {lines, current} =
      Enum.reduce(words, {[], ""}, fn word, {lines, current} ->
        candidate = if current == "", do: word, else: current <> " " <> word

        if String.length(candidate) > chars_per_line and current != "" do
          {[current | lines], word}
        else
          {lines, candidate}
        end
      end)

    Enum.reverse(if current != "", do: [current | lines], else: lines)
  end
end