Skip to main content

lib/skia.ex

defmodule Skia do
  @moduledoc """
  Batched, immutable drawing documents rendered through Skia.

  Build a `%Skia.Document{}` with pipe-friendly commands, then end the pipeline
  with a renderer such as `to_png/1`, `to_raw/1`, or `render/2`.

      {:ok, png} =
        Skia.canvas(800, 600)
        |> Skia.clear(:white)
        |> Skia.rect(x: 40, y: 40, width: 120, height: 80, fill: :red)
        |> Skia.to_png()
  """

  alias Skia.{Command, CommandSpec, Document}

  @type document :: Document.t()

  @doc "Creates an empty drawing document."
  @spec canvas(pos_integer(), pos_integer()) :: Document.t()
  def canvas(width, height), do: Document.new(width, height)

  @doc "Measures text using the native text engine."
  @spec measure_text(String.t(), keyword()) ::
          {:ok, %{width: float(), bounds: {float(), float(), float(), float()}}}
          | {:error, atom()}
  def measure_text(text, opts \\ []) when is_binary(text) do
    font = Keyword.get(opts, :font)
    size = Keyword.get(opts, :size, 16)

    case Skia.Native.measure_text(text, font, size) do
      {:ok, width, left, top, right, bottom} ->
        {:ok, %{width: width, bounds: {left, top, right, bottom}}}

      {:error, reason} ->
        {:error, reason}
    end
  end

  for {name, spec} <- CommandSpec.all(),
      name not in [
        :save,
        :save_layer,
        :restore,
        :translate,
        :rotate,
        :push_style,
        :pop_style,
        :text
      ] do
    args = Keyword.get(spec, :args, [])
    arg_vars = Enum.map(args, fn {arg_name, _type} -> Macro.var(arg_name, __MODULE__) end)

    if args == [] do
      @doc "Adds a `#{name}` command to the document."
      @spec unquote(name)(Document.t(), keyword()) :: Document.t()
      def unquote(name)(%Document{} = document, opts \\ []) do
        append_command(document, unquote(name), [], opts)
      end
    else
      @doc "Adds a `#{name}` command to the document."
      @spec unquote(name)(
              Document.t(),
              unquote_splicing(Enum.map(args, fn _ -> quote(do: term()) end)),
              keyword()
            ) :: Document.t()
      def unquote(name)(%Document{} = document, unquote_splicing(arg_vars), opts \\ []) do
        append_command(document, unquote(name), unquote(arg_vars), opts)
      end
    end
  end

  @doc "Adds text with optional `%Skia.TextStyle{}` and `%Skia.ParagraphStyle{}` values."
  @spec text(Document.t(), String.t(), keyword()) :: Document.t()
  def text(%Document{} = document, text, opts \\ []) when is_binary(text) do
    {text_style, opts} = Keyword.pop(opts, :style)
    {paragraph_style, opts} = Keyword.pop(opts, :paragraph_style)

    opts =
      []
      |> Keyword.merge(style_opts(text_style, Skia.TextStyle))
      |> Keyword.merge(style_opts(paragraph_style, Skia.ParagraphStyle))
      |> Keyword.merge(opts)

    append_command(document, :text, [text], opts)
  end

  defp style_opts(nil, _module), do: []
  defp style_opts(%Skia.TextStyle{} = style, Skia.TextStyle), do: Skia.TextStyle.to_opts(style)

  defp style_opts(%Skia.ParagraphStyle{} = style, Skia.ParagraphStyle),
    do: Skia.ParagraphStyle.to_opts(style)

  @doc "Adds a saved canvas group with optional transforms."
  @spec group(Document.t(), keyword(), (Document.t() -> Document.t())) :: Document.t()
  def group(%Document{} = document, opts, fun) when is_list(opts) and is_function(fun, 1) do
    document
    |> append_command(:save, [], [])
    |> apply_group_opts(opts)
    |> fun.()
    |> append_command(:restore, [], [])
  end

  @doc "Adds a saved layer with optional opacity."
  @spec layer(Document.t(), keyword(), (Document.t() -> Document.t())) :: Document.t()
  def layer(%Document{} = document, opts, fun) when is_list(opts) and is_function(fun, 1) do
    document
    |> append_command(:save_layer, [], opts)
    |> fun.()
    |> append_command(:restore, [], [])
  end

  @doc "Adds a style scope for following commands in the group."
  @spec style(Document.t(), keyword(), (Document.t() -> Document.t())) :: Document.t()
  def style(%Document{} = document, opts, fun) when is_list(opts) and is_function(fun, 1) do
    document
    |> append_command(:push_style, [], style: opts)
    |> fun.()
    |> append_command(:pop_style, [], [])
  end

  @doc "Returns normalized commands in render order."
  @spec commands(Document.t()) :: [Command.t()]
  def commands(%Document{} = document), do: Document.commands(document)

  @doc "Encodes the document to the term batch a native renderer would consume."
  @spec to_batch(Document.t()) :: %{
          width: pos_integer(),
          height: pos_integer(),
          commands: [Command.t()]
        }
  def to_batch(%Document{} = document) do
    %{width: document.width, height: document.height, commands: commands(document)}
  end

  @doc "Records the document into a reusable Skia picture."
  @spec record_picture(Document.t()) :: {:ok, Skia.Picture.t()} | {:error, atom(), map()}
  def record_picture(%Document{} = document) do
    with :ok <- validate(document) do
      batch = to_batch(document)

      case Skia.Native.record_picture(batch) do
        {:ok, ref} ->
          {:ok, %Skia.Picture{ref: ref, width: document.width, height: document.height}}

        {:error, reason} ->
          {:error, reason, batch}
      end
    end
  end

  @doc "Renders the document according to `Skia.RenderOptions`."
  @spec render(Document.t(), keyword() | Skia.RenderOptions.t()) ::
          {:ok, binary() | map()} | {:error, atom(), map()}
  def render(%Document{} = document, opts \\ []) do
    options = if is_list(opts), do: Skia.RenderOptions.new(opts), else: opts

    case options.format do
      :png -> to_png(document)
      :jpeg -> to_jpeg(document, quality: options.quality || 90)
      :webp -> to_webp(document, quality: options.quality || 90)
      :raw -> to_raw(document)
      format -> {:error, :unsupported_format, %{format: format}}
    end
  end

  @doc "Renders the document to PNG through the native renderer."
  @spec to_png(Document.t()) :: {:ok, binary()} | {:error, atom(), map()}
  def to_png(%Document{} = document) do
    with :ok <- validate(document), do: render_native(document, &Skia.Native.render_png/1)
  end

  @doc "Renders the document to JPEG through the native renderer."
  @spec to_jpeg(Document.t(), keyword()) :: {:ok, binary()} | {:error, atom(), map()}
  def to_jpeg(%Document{} = document, opts \\ []) do
    quality = Keyword.get(opts, :quality, 90)

    with :ok <- validate(document),
         do: render_native(document, &Skia.Native.render_jpeg(&1, quality))
  end

  @doc "Renders the document to WEBP through the native renderer."
  @spec to_webp(Document.t(), keyword()) :: {:ok, binary()} | {:error, atom(), map()}
  def to_webp(%Document{} = document, opts \\ []) do
    quality = Keyword.get(opts, :quality, 90)

    with :ok <- validate(document),
         do: render_native(document, &Skia.Native.render_webp(&1, quality))
  end

  @doc "Renders the document to a raw RGBA buffer."
  @spec to_raw(Document.t()) ::
          {:ok,
           %{width: pos_integer(), height: pos_integer(), stride: pos_integer(), data: binary()}}
          | {:error, atom(), map()}
  def to_raw(%Document{} = document) do
    with :ok <- validate(document) do
      batch = to_batch(document)

      case Skia.Native.render_rgba(batch) do
        {:ok, {width, height, stride, data}} ->
          {:ok, %{width: width, height: height, stride: stride, data: data}}

        {:error, reason} ->
          {:error, reason, batch}
      end
    end
  end

  @doc "Validates the document before handing it to native code."
  @spec validate(Document.t()) :: :ok | {:error, atom(), map()}
  def validate(%Document{width: width, height: height}) when width <= 0 or height <= 0 do
    {:error, :invalid_document, %{width: width, height: height}}
  end

  def validate(%Document{} = document) do
    document
    |> commands()
    |> Enum.reduce_while(:ok, &reduce_validation/2)
  end

  defp reduce_validation(command, :ok) do
    case validate_command(command) do
      :ok -> {:cont, :ok}
      {:error, reason, meta} -> {:halt, {:error, reason, meta}}
    end
  end

  defp validate_command(%Command{op: op, opts: opts}) do
    cond do
      Keyword.has_key?(opts, :path_effect) and not Keyword.has_key?(opts, :stroke) ->
        {:error, :path_effect_requires_stroke, %{op: op}}

      Keyword.has_key?(opts, :stroke_width) and Keyword.get(opts, :stroke_width) < 0 ->
        {:error, :invalid_stroke_width, %{op: op, stroke_width: Keyword.get(opts, :stroke_width)}}

      true ->
        :ok
    end
  end

  defp render_native(%Document{} = document, render_fun) when is_function(render_fun, 1) do
    batch = to_batch(document)

    case render_fun.(batch) do
      {:ok, result} -> {:ok, result}
      {:error, reason} -> {:error, reason, batch}
    end
  end

  defp append_command(%Document{} = document, name, args, opts) do
    Document.append(document, Command.build!(name, args, opts))
  end

  defp apply_group_opts(%Document{} = document, opts) do
    Enum.reduce(opts, document, fn
      {:translate, {x, y}}, acc ->
        append_command(acc, :translate, [], x: x, y: y)

      {:scale, {x, y}}, acc ->
        append_command(acc, :scale, [], x: x, y: y)

      {:rotate, degrees}, acc ->
        append_command(acc, :rotate, [], degrees: degrees)

      {:rotate_at, {degrees, x, y}}, acc ->
        append_command(acc, :rotate_at, [], degrees: degrees, x: x, y: y)

      {:concat, matrix}, acc ->
        append_command(acc, :concat, [], matrix: matrix)

      {key, _value}, _acc ->
        raise ArgumentError, "unsupported group option #{inspect(key)}"
    end)
  end
end