defmodule CanvasCraft do @moduledoc """ CanvasCraft - 2D graphics drawing library facade. This module delegates drawing operations to the Skia backend by default. The canvas handle is represented as `{backend_module, backend_ref}`. Examples: - In-memory WEBP export (no temp files): iex> {:ok, handle} = CanvasCraft.create_canvas(16, 16) iex> {:ok, bin} = CanvasCraft.export_webp(handle) iex> is_binary(bin) true See `guides/examples/` for runnable scripts demonstrating gradients, filters, images, and in-memory export. """ @typedoc "Backend-qualified canvas handle" @type canvas_handle :: {module(), term()} @doc """ Return a simple atom for smoke tests. """ @spec hello() :: :world def hello, do: :world @doc """ Create a new canvas using the selected backend. Options: - :backend - backend module (default: CanvasCraft.Backends.Skia) - other backend-specific options """ @spec create_canvas(pos_integer(), pos_integer(), keyword()) :: {:ok, canvas_handle} | {:error, term()} def create_canvas(width, height, opts \\ []) when width > 0 and height > 0 do backend = Keyword.get(opts, :backend, CanvasCraft.Backends.Skia) _ = Code.ensure_loaded(backend) with true <- function_exported?(backend, :new_surface, 3) || {:error, :backend_missing}, {:ok, ref} <- backend.new_surface(width, height, opts) do {:ok, {backend, ref}} else {:error, reason} -> {:error, reason} false -> {:error, :backend_missing} end end @doc """ Clear the canvas to a given RGBA color (tuple {r,g,b,a}, 0..255). No-op for backends that don't support it yet. """ @spec clear(canvas_handle, {0..255, 0..255, 0..255, 0..255}) :: :ok | {:error, term()} def clear({_backend, _ref}, _rgba), do: :ok @doc """ Fill an axis-aligned rectangle. No-op for backends that don't support it yet; implemented later. """ @spec fill_rect(canvas_handle, number(), number(), number(), number()) :: :ok | {:error, term()} def fill_rect({_backend, _ref}, _x, _y, _w, _h), do: :ok # Backward-compatible no-color variant (no-op default) @spec fill_rect(canvas_handle, number(), number(), number(), number()) :: :ok | {:error, term()} def fill_rect(handle, x, y, w, h), do: fill_rect(handle, x, y, w, h, {255,255,255,255}) @doc "Fill an axis-aligned rectangle with a color." @spec fill_rect(canvas_handle, number(), number(), number(), number(), {0..255,0..255,0..255,0..255}) :: :ok | {:error, term()} def fill_rect({backend, ref}, x, y, w, h, {_r,_g,_b,_a} = rgba) do cond do function_exported?(backend, :fill_rect, 6) -> backend.fill_rect(ref, x, y, w, h, rgba) true -> {:error, :unsupported} end end @doc """ Export the canvas using backend. Defaults to PNG; if opts[:format] == :webp and the backend implements export_webp/2, that path is used. """ @spec export_png(canvas_handle, keyword()) :: {:ok, binary()} | {:error, term()} def export_png({backend, ref}, opts \\ []) do case Keyword.get(opts, :format, :png) do :png -> if function_exported?(backend, :export_png, 2) do backend.export_png(ref, opts) else {:error, :backend_missing} end :webp -> cond do function_exported?(backend, :export_webp, 2) -> backend.export_webp(ref, opts) function_exported?(backend, :export_png, 2) -> backend.export_png(ref, Keyword.put(opts, :format, :webp)) true -> {:error, :backend_missing} end _ -> {:error, :unsupported_format} end end @doc """ Export the canvas as WEBP binary using backend, no filesystem involved. """ @spec export_webp(canvas_handle, keyword()) :: {:ok, binary()} | {:error, term()} def export_webp({backend, ref}, opts \\ []) do cond do function_exported?(backend, :export_webp, 2) -> backend.export_webp(ref, opts) true -> {:error, :backend_missing} end end @doc """ Export the raw RGBA buffer from the backend, if supported. """ @spec export_raw(canvas_handle) :: {:ok, {non_neg_integer(), non_neg_integer(), non_neg_integer(), binary()}} | {:error, term()} def export_raw({backend, ref}) do if function_exported?(backend, :export_raw, 1) do backend.export_raw(ref) else {:error, :unsupported} end end @doc "Return capabilities supported by the selected backend" @spec capabilities(module()) :: MapSet.t() def capabilities(backend) when is_atom(backend) do if function_exported?(backend, :capabilities, 0), do: backend.capabilities(), else: MapSet.new() end @doc "Return true if backend supports the given feature" @spec supports?(module(), atom()) :: boolean() def supports?(backend, feature) when is_atom(backend) do MapSet.member?(capabilities(backend), feature) end @doc "Write a binary returned from export_* to a file path (thin helper)." @spec write_binary(binary(), Path.t()) :: :ok | {:error, term()} def write_binary(bin, path) when is_binary(bin) and is_binary(path), do: File.write(path, bin) @doc "Export PNG and write to file path (helper)." @spec export_png_to_file(canvas_handle, Path.t(), keyword()) :: :ok | {:error, term()} def export_png_to_file(handle, path, opts \\ []) do with {:ok, bin} <- export_png(handle, Keyword.put_new(opts, :format, :png)) do File.write(path, bin) end end @doc "Export WEBP and write to file path (helper)." @spec export_webp_to_file(canvas_handle, Path.t(), keyword()) :: :ok | {:error, term()} def export_webp_to_file(handle, path, opts \\ []) do with {:ok, bin} <- export_webp(handle, opts) do File.write(path, bin) end end @doc "Enable/disable antialiasing or set sample count (1,4,8)." @spec set_antialias(canvas_handle, boolean() | 1 | 4 | 8) :: :ok | {:error, term()} def set_antialias({backend, ref}, aa) do if function_exported?(backend, :set_antialias, 2) do backend.set_antialias(ref, aa) else {:error, :unsupported} end end @doc """ Fill a circle with a color. """ @spec fill_circle(canvas_handle, number(), number(), number(), {0..255,0..255,0..255,0..255}) :: :ok | {:error, term()} def fill_circle({backend, ref}, cx, cy, radius, {_r,_g,_b,_a} = rgba) do cond do function_exported?(backend, :fill_circle, 5) -> backend.fill_circle(ref, cx, cy, radius, rgba) true -> {:error, :unsupported} end end @doc "Load a TTF/OTF font from a filesystem path for the current backend (when supported)." @spec load_font(canvas_handle, Path.t()) :: :ok | {:error, term()} def load_font({backend, ref}, path) when is_binary(path) do cond do function_exported?(backend, :load_font, 2) -> backend.load_font(ref, path) function_exported?(backend, :font_load_path, 2) -> backend.font_load_path(ref, path) true -> {:error, :unsupported} end end @doc "Set current font size in points/pixels (backend-defined units)." @spec set_font_size(canvas_handle, number()) :: :ok | {:error, term()} def set_font_size({backend, ref}, size) when is_number(size) do cond do function_exported?(backend, :set_font_size, 2) -> backend.set_font_size(ref, size) function_exported?(backend, :font_set_size, 2) -> backend.font_set_size(ref, size) true -> {:error, :unsupported} end end @doc "Draw UTF-8 text at x,y with RGBA color." @spec draw_text(canvas_handle, number(), number(), String.t(), {0..255,0..255,0..255,0..255}) :: :ok | {:error, term()} def draw_text({backend, ref}, x, y, text, {r,g,b,a}) when is_binary(text) do cond do function_exported?(backend, :draw_text, 5) -> backend.draw_text(ref, x, y, text, {r,g,b,a}) true -> {:error, :unsupported} end end end