Packages

In-memory 2D rendering with a Skia (Rustler) backend and declarative DSL

Current section

Files

Jump to
canvas_craft lib canvas_craft.ex
Raw

lib/canvas_craft.ex

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