Skip to main content

lib/pdf/component/rating.ex

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

  Renders a star/score rating display with filled and empty indicators.

  ## Examples

      doc |> Pdf.Component.Rating.render({50, 700}, %{value: 4, max: 5})

      doc |> Pdf.Component.Rating.render({50, 700}, %{
        value: 3.5,
        max: 5,
        filled_color: {0.95, 0.7, 0.0},
        size: 16
      })
  """

  @default_size 14
  @default_max 5
  @default_filled_color {0.95, 0.7, 0.0}
  @default_empty_color {0.82, 0.82, 0.82}
  @default_font "Helvetica"
  @default_gap 4
  @default_label_color {0.3, 0.3, 0.3}

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

  ## Style options

  - `:value` — current score (default `0`)
  - `:max` — maximum score (default `5`)
  - `:size` — star size (default `14`)
  - `:filled_color` — filled star color (default gold)
  - `:empty_color` — empty star color (default light gray)
  - `:gap` — space between stars (default `4`)
  - `:show_label` — show "3.5/5" text (default `false`)
  - `:label_color` — label text color
  """
  def render(doc, {x, y}, style \\ %{}) do
    value = Map.get(style, :value, 0)
    max = Map.get(style, :max, @default_max)
    size = Map.get(style, :size, @default_size)
    filled = Map.get(style, :filled_color, @default_filled_color)
    empty = Map.get(style, :empty_color, @default_empty_color)
    gap = Map.get(style, :gap, @default_gap)
    font = Map.get(style, :font, @default_font)
    show_label = Map.get(style, :show_label, false)
    label_color = Map.get(style, :label_color, @default_label_color)

    r = size / 2

    doc =
      Enum.reduce(0..(max - 1), doc, fn i, d ->
        sx = x + i * (size + gap)
        color = if i < trunc(value), do: filled, else: empty

        d
        |> Pdf.save_state()
        |> Pdf.set_fill_color(color)
        |> Pdf.rounded_rectangle({sx, y - size}, {size, size}, r)
        |> Pdf.fill()
        |> Pdf.restore_state()
      end)

    # Half star: overlay a partial fill if value has decimal
    frac = value - trunc(value)
    doc =
      if frac > 0 do
        half_i = trunc(value)
        sx = x + half_i * (size + gap)
        half_w = size * frac

        doc
        |> Pdf.save_state()
        |> Pdf.set_fill_color(filled)
        |> Pdf.rectangle({sx, y - size}, {half_w, size})
        |> Pdf.fill()
        |> Pdf.restore_state()
      else
        doc
      end

    if show_label do
      label_x = x + max * (size + gap) + 4
      label = if frac > 0, do: "#{value}/#{max}", else: "#{trunc(value)}/#{max}"

      doc
      |> Pdf.set_font(font, size - 2)
      |> Pdf.set_fill_color(label_color)
      |> Pdf.text_at({label_x, y - size + 2}, label)
    else
      doc
    end
  end
end