Skip to main content

lib/image/components.ex

defmodule Image.Components do
  @moduledoc """
  Phoenix.Component wrappers for the `image` / `image_plug`
  ecosystem.

  Exposes two LiveView-friendly components — `<.image>` and
  `<.picture>` — that take per-transform attributes (width,
  height, fit, format, blur, brightness, …), build a canonical
  `Image.Plug.Pipeline`, and project that pipeline onto the URL
  grammar of one of five supported providers via
  `Image.Components.URL`: four commercial image CDNs (Cloudflare
  Images, Cloudinary, imgix, ImageKit) plus
  [IIIF Image API 3.0](https://iiif.io/api/image/3.0/).

  Use this module like any other Phoenix.Component:

      defmodule MyAppWeb.PageLive do
        use MyAppWeb, :live_view
        import Image.Components

        def render(assigns) do
          ~H\"\"\"
          <.image src="/uploads/cat.jpg" provider={:cloudflare} width={600} fit={:cover} />
          \"\"\"
        end
      end

  Each component renders a plain HTML element (`<img>` or
  `<picture>`); the only "magic" is constructing the URL. There
  is no JavaScript and no LiveView-specific behavior.

  ## Provider feature gaps

  Not every transform is expressible in every CDN's URL grammar.
  Operations that don't have a faithful equivalent are silently
  dropped from the URL projection — see
  `Image.Components.URL` for the per-provider coverage table.

  """

  use Phoenix.Component

  alias Image.Plug.Pipeline
  alias Image.Plug.Pipeline.Ops
  alias Image.Components.URL

  @providers ~w(cloudflare cloudinary imgix imagekit iiif)a

  @doc """
  Renders an `<img>` whose `src` is built by projecting the
  attribute set onto the configured CDN provider's URL grammar.

  ### Attributes

  * `src` — the canonical, *untransformed* source path or URL
    (e.g. `/uploads/cat.jpg`). Required.

  * `provider` — `:cloudflare`, `:cloudinary`, `:imgix`, or
    `:imagekit`. Required.

  * `host` — optional URL prefix (e.g. `"https://cdn.example.com"`
    or `"/img"`). Defaults to `""` (relative URL).

  * `width`, `height` — pixel dimensions. Optional.

  * `fit` — one of `:contain`, `:cover`, `:crop`, `:pad`,
    `:scale_down`, `:squeeze`. Optional.

  * `gravity` — `:center`, `:auto`, `:face`, `:north`, `:south`,
    `:east`, `:west`, `:north_east`, `:north_west`,
    `:south_east`, `:south_west`, or `{:xy, x, y}`. Optional.

  * `dpr` — device-pixel-ratio multiplier. Optional.

  * `face_zoom` — float in `[0.0, 1.0]` controlling how tightly a face-aware crop hugs the detected face. `0.0` is a loose crop with lots of context, `1.0` hugs the bounding box. Only meaningful with `gravity={:face}`. Optional.

  * `format` — `:auto`, `:jpeg`, `:png`, `:webp`, `:avif`. Optional.

  * `quality` — `1..100`. Optional.

  * `blur`, `sharpen` — sigma (float ≥ 0). Optional.

  * `brightness`, `contrast`, `saturation`, `gamma` — multipliers where `1.0` means no change. Optional.

  * `vignette` — strength in `[0.0, 1.0]`. Only Cloudinary's URL grammar carries vignette; other providers drop it. Optional.

  * `tint` — colour as a hex string (`"#aabbcc"` or `"aabbcc"`) or an `[r, g, b]` integer list. Only imgix's `monochrome=` carries this; other providers drop it. Optional.

  * `cloudinary_account` — the Cloudinary `<cloud-name>` segment. Defaults to `"demo"`.

  * `imagekit_endpoint` — the ImageKit per-account endpoint segment. Defaults to `"demo"`.

  * `class`, `alt`, plus any other arbitrary HTML attributes — passed through to the rendered `<img>` via `:rest`.

  ### Returns

  * Renders a single `<img>` element.

  ### Examples

      <.image src="/cat.jpg" provider={:cloudflare} width={600} fit={:cover} />

      <.image
        src="/cat.jpg"
        provider={:imgix}
        host="https://my-source.imgix.net"
        width={800}
        format={:webp}
        quality={80}
        blur={5.0}
      />

  """
  attr(:src, :string, required: true)
  attr(:provider, :atom, values: @providers, required: true)
  attr(:host, :string, default: "")
  attr(:width, :integer, default: nil)
  attr(:height, :integer, default: nil)
  attr(:fit, :atom, default: nil)
  attr(:gravity, :any, default: nil)
  attr(:dpr, :integer, default: nil)
  attr(:face_zoom, :float, default: nil)
  attr(:format, :atom, default: nil)
  attr(:quality, :integer, default: nil)
  attr(:blur, :float, default: nil)
  attr(:sharpen, :float, default: nil)
  attr(:brightness, :float, default: nil)
  attr(:contrast, :float, default: nil)
  attr(:saturation, :float, default: nil)
  attr(:gamma, :float, default: nil)
  attr(:vignette, :float, default: nil)
  attr(:tint, :any, default: nil)
  # IIIF-specific. `:region` defines a sub-rectangle of the source
  # image (`:full`, `{:pixels, x, y, w, h}`, or `{:percent, x, y, w, h}`).
  # `:iiif_quality` is one of `:default`, `:color`, `:gray`,
  # `:bitonal` per the IIIF spec — distinct from `:quality` (which
  # is the compression quality `1..100`). Both are honoured by the
  # `:iiif` provider; other providers ignore them.
  attr(:region, :any, default: nil)
  attr(:iiif_quality, :atom, values: [:default, :color, :gray, :bitonal, nil], default: nil)
  attr(:cloudinary_account, :string, default: "demo")
  attr(:imagekit_endpoint, :string, default: "demo")
  # IIIF server prefix segment; e.g. `"/iiif/3"` for an Image API
  # 3.0 server. Only used when `provider: :iiif`.
  attr(:iiif_prefix, :string, default: "/iiif/3")
  # Fallback format extension when `Format.type` is `:auto`. IIIF
  # requires an explicit format in the URL.
  attr(:iiif_format, :atom, default: :jpeg)
  attr(:rest, :global, include: ~w(alt class srcset sizes loading decoding fetchpriority))

  def image(assigns) do
    pipeline = build_pipeline(assigns)

    url =
      build_url(assigns.provider, pipeline,
        source_path: assigns.src,
        host: assigns.host,
        cloudinary_account: assigns.cloudinary_account,
        imagekit_endpoint: assigns.imagekit_endpoint,
        iiif_prefix: assigns[:iiif_prefix] || "/iiif/3",
        iiif_format: assigns[:iiif_format] || :jpeg
      )

    assigns = assign(assigns, :__src, url)

    ~H"""
    <img src={@__src} {@rest} />
    """
  end

  @doc """
  Renders a `<picture>` element with format-specific
  `<source srcset>` rows that share the rest of the transform
  set, plus a fallback `<img>`.

  ### Attributes

  Same as `image/1`, with one extra:

  * `formats` — list of formats to emit as `<source>` rows.
    Defaults to `[:avif, :webp]`. The fallback `<img>` uses
    `format` if given, otherwise the original format.

  ### Returns

  * Renders a `<picture>` element with one `<source>` per
    requested format and a single `<img>` fallback.

  ### Examples

      <.picture src="/cat.jpg" provider={:cloudflare} width={600} formats={[:avif, :webp]} />

  """
  attr(:src, :string, required: true)
  attr(:provider, :atom, values: @providers, required: true)
  attr(:host, :string, default: "")
  attr(:formats, :list, default: [:avif, :webp])
  attr(:width, :integer, default: nil)
  attr(:height, :integer, default: nil)
  attr(:fit, :atom, default: nil)
  attr(:gravity, :any, default: nil)
  attr(:dpr, :integer, default: nil)
  attr(:face_zoom, :float, default: nil)
  attr(:format, :atom, default: nil)
  attr(:quality, :integer, default: nil)
  attr(:blur, :float, default: nil)
  attr(:sharpen, :float, default: nil)
  attr(:brightness, :float, default: nil)
  attr(:contrast, :float, default: nil)
  attr(:saturation, :float, default: nil)
  attr(:gamma, :float, default: nil)
  attr(:vignette, :float, default: nil)
  attr(:tint, :any, default: nil)
  attr(:region, :any, default: nil)
  attr(:iiif_quality, :atom, values: [:default, :color, :gray, :bitonal, nil], default: nil)
  attr(:cloudinary_account, :string, default: "demo")
  attr(:imagekit_endpoint, :string, default: "demo")
  attr(:iiif_prefix, :string, default: "/iiif/3")
  attr(:iiif_format, :atom, default: :jpeg)
  attr(:rest, :global, include: ~w(alt class loading decoding fetchpriority))

  def picture(assigns) do
    base = Map.drop(assigns, [:formats, :rest])
    common_url_options = picture_url_options(assigns)

    sources =
      Enum.map(assigns.formats, fn fmt ->
        url =
          build_url(
            assigns.provider,
            build_pipeline(%{base | format: fmt}),
            common_url_options
          )

        %{format: fmt, url: url, mime: mime(fmt)}
      end)

    fallback_url =
      build_url(assigns.provider, build_pipeline(base), common_url_options)

    assigns = assign(assigns, sources: sources, fallback_url: fallback_url)

    ~H"""
    <picture>
      <source :for={s <- @sources} type={s.mime} srcset={s.url} />
      <img src={@fallback_url} {@rest} />
    </picture>
    """
  end

  # ─── shared internals ─────────────────────────────────────────────

  @doc false
  # Build the canonical IR from the component's flat attribute
  # set. Public-but-undocumented for the playground; not part of
  # the stable API.
  def build_pipeline(assigns) do
    resize_fields =
      [
        width: assigns[:width],
        height: assigns[:height],
        fit: assigns[:fit],
        gravity: assigns[:gravity],
        dpr: assigns[:dpr],
        face_zoom: assigns[:face_zoom]
      ]
      |> Enum.reject(fn {_k, v} -> is_nil(v) end)

    adjust_fields =
      [
        brightness: assigns[:brightness],
        contrast: assigns[:contrast],
        saturation: assigns[:saturation],
        gamma: assigns[:gamma]
      ]
      |> Enum.reject(fn {_k, v} -> is_nil(v) end)

    format_fields =
      [type: assigns[:format], quality: assigns[:quality]]
      |> Enum.reject(fn {_k, v} -> is_nil(v) end)

    ops =
      []
      |> maybe_prepend(resize_fields, fn fs -> struct(Ops.Resize, fs) end)
      |> maybe_prepend(adjust_fields, fn fs -> struct(Ops.Adjust, fs) end)
      |> maybe_prepend_blur(assigns[:blur])
      |> maybe_prepend_sharpen(assigns[:sharpen])
      |> maybe_prepend_vignette(assigns[:vignette])
      |> maybe_prepend_tint(assigns[:tint])
      |> maybe_apply_iiif_quality(assigns[:iiif_quality])
      |> maybe_prepend_region(assigns[:region])

    output =
      case format_fields do
        [] -> nil
        fs -> struct(Ops.Format, fs)
      end

    %Pipeline{ops: ops, output: output}
  end

  defp maybe_prepend(ops, [], _builder), do: ops
  defp maybe_prepend(ops, fields, builder), do: [builder.(fields) | ops]

  defp maybe_prepend_blur(ops, nil), do: ops
  defp maybe_prepend_blur(ops, sigma) when sigma > 0, do: [%Ops.Blur{sigma: sigma * 1.0} | ops]
  defp maybe_prepend_blur(ops, _), do: ops

  defp maybe_prepend_sharpen(ops, nil), do: ops

  defp maybe_prepend_sharpen(ops, sigma) when sigma > 0,
    do: [%Ops.Sharpen{sigma: sigma * 1.0} | ops]

  defp maybe_prepend_sharpen(ops, _), do: ops

  defp maybe_prepend_vignette(ops, nil), do: ops

  defp maybe_prepend_vignette(ops, strength) when strength > 0,
    do: [%Ops.Vignette{strength: strength * 1.0} | ops]

  defp maybe_prepend_vignette(ops, _), do: ops

  defp maybe_prepend_tint(ops, nil), do: ops

  defp maybe_prepend_tint(ops, color) do
    case normalise_color(color) do
      nil -> ops
      [_, _, _] = rgb -> [%Ops.Tint{color: rgb} | ops]
    end
  end

  # `Ops.Tint`'s type is `[non_neg_integer()]`. Accept the
  # convenience forms — hex string ("#aabbcc" or "aabbcc"),
  # already-an-RGB-list — and normalise to a 3-element int
  # list. Anything else is silently dropped.
  defp normalise_color([r, g, b]) when is_integer(r) and is_integer(g) and is_integer(b),
    do: [r, g, b]

  defp normalise_color("#" <> rest), do: normalise_color(rest)

  defp normalise_color(<<r::binary-2, g::binary-2, b::binary-2>>) do
    with {:ok, ri} <- parse_hex_byte(r),
         {:ok, gi} <- parse_hex_byte(g),
         {:ok, bi} <- parse_hex_byte(b) do
      [ri, gi, bi]
    else
      _ -> nil
    end
  end

  defp normalise_color(_), do: nil

  defp parse_hex_byte(<<_::binary-2>> = pair) do
    case Integer.parse(pair, 16) do
      {n, ""} when n in 0..255 -> {:ok, n}
      _ -> :error
    end
  end

  # IIIF quality — `:gray` ⇒ ensure an `Ops.Adjust{saturation: 0.0}`
  # is in the pipeline; `:bitonal` ⇒ ensure an `Ops.Posterize{levels:
  # 2}` is. `:default` / `:color` / `nil` are no-ops. Other
  # providers ignore the resulting ops if their URL grammar can't
  # carry them; for `:iiif`, this is what
  # `Image.Components.URL.iiif/2`'s quality detector reads.
  defp maybe_apply_iiif_quality(ops, nil), do: ops
  defp maybe_apply_iiif_quality(ops, :default), do: ops
  defp maybe_apply_iiif_quality(ops, :color), do: ops

  defp maybe_apply_iiif_quality(ops, :gray) do
    {existing_adjust, others} = pop_op(ops, Ops.Adjust)
    base = existing_adjust || %Ops.Adjust{}
    [%{base | saturation: 0.0} | others]
  end

  defp maybe_apply_iiif_quality(ops, :bitonal) do
    {_existing_posterize, others} = pop_op(ops, Ops.Posterize)
    [%Ops.Posterize{levels: 2} | others]
  end

  defp pop_op(ops, module) do
    {match, rest} = Enum.split_with(ops, fn o -> o.__struct__ == module end)
    {List.first(match), rest}
  end

  # IIIF region — `:full` (or nil) is a no-op. `{:pixels, x, y, w, h}`
  # / `{:percent, x, y, w, h}` build an `Ops.Crop` op, IF that
  # struct exists in the loaded `image_plug`. When `Ops.Crop` is
  # not yet defined (Phase 2a of the IIIF rollout) this silently
  # drops the region — `Image.Components.URL.iiif/2`'s `iiif_region/1`
  # then emits `full`.
  defp maybe_prepend_region(ops, nil), do: ops
  defp maybe_prepend_region(ops, :full), do: ops

  defp maybe_prepend_region(ops, {units, x, y, w, h}) when units in [:pixels, :percent] do
    if Code.ensure_loaded?(Ops.Crop) do
      [struct(Ops.Crop, x: x, y: y, width: w, height: h, units: units) | ops]
    else
      ops
    end
  end

  defp maybe_prepend_region(ops, _), do: ops

  defp picture_url_options(assigns) do
    [
      source_path: assigns.src,
      host: assigns.host,
      cloudinary_account: assigns.cloudinary_account,
      imagekit_endpoint: assigns.imagekit_endpoint,
      iiif_prefix: assigns[:iiif_prefix] || "/iiif/3",
      iiif_format: assigns[:iiif_format] || :jpeg
    ]
  end

  defp build_url(:cloudflare, pipeline, options), do: URL.cloudflare(pipeline, options)
  defp build_url(:cloudinary, pipeline, options), do: URL.cloudinary(pipeline, options)
  defp build_url(:imgix, pipeline, options), do: URL.imgix(pipeline, options)
  defp build_url(:imagekit, pipeline, options), do: URL.imagekit(pipeline, options)
  defp build_url(:iiif, pipeline, options), do: URL.iiif(pipeline, options)

  defp mime(:avif), do: "image/avif"
  defp mime(:webp), do: "image/webp"
  defp mime(:jpeg), do: "image/jpeg"
  defp mime(:png), do: "image/png"
  defp mime(_), do: nil
end