defmodule Pngex do @moduledoc """ Generates PNG images. """ alias Pngex.{Chunk, Bitmap, Zip} @scanline_filter_none 0 defstruct type: :rgb, depth: :depth8, width: 0, height: 0, palette: [], scanline_filter: @scanline_filter_none @typedoc """ PNG color type. - `:gray` - grayscale - `:rgb` - RGB color - `:indexed` - palette color - `:gray_and_alpha` - grayscale and alpha - `:rgba` - RGB color and alpha """ @type color_type :: :gray | :rgb | :indexed | :gray_and_alpha | :rgba @typedoc """ Bit depth. - `:depth1` - 1 bits - `:depth2` - 2 bits - `:depth4` - 4 bits - `:depth8` - 8 bits - `:depth16` - 16 bits """ @type bit_depth :: :depth1 | :depth2 | :depth4 | :depth8 | :depth16 @typedoc """ Positive 32-bit integer. It's for width and height. """ @type pos_int32 :: 1..0xFF_FF_FF_FF @typedoc """ Data type of RGB color. A tuple of red, green and blue values. """ @type rgb_color :: {r :: pos_integer(), g :: pos_integer(), b :: pos_integer()} @typedoc """ Data type of RGB color and alpha. A tuple of red, green, blue and alpha values. """ @type rgba_color :: {r :: pos_integer(), g :: pos_integer(), b :: pos_integer(), a :: pos_integer()} @typedoc """ Image data. ## RGB | type | example | |------------------|-------------------------------------| | binary | `<>` | | list of integers | `[r0, g0, b0, r1, g1, b1, ...]` | | list of tuples | `[{r0, g0, b0}, {r1, g1, b1}, ...]` | ## RGB and Alpha | type | example | |------------------|---------------------------------------------| | binary | `<>` | | list of integers | `[r0, g0, b0, a0, r1, g1, b1, a1, ...]` | | list of tuples | `[{r0, g0, b0, a0}, {r1, g1, b1, a1}, ...]` | ## Grayscale | type | example | |------------------|---------------------| | binary | `<>` | | list of integers | `[c0, c1, ...]` | ## Grayscale and Alpha | type | example | |------------------|-----------------------------| | binary | `<>` | | list of integers | `[c0, a0, c1, a1, ...]` | ## Indexed | type | example | |------------------|---------------------| | binary | `<>` | | list of integers | `[c0, c1, ...]` | ## Depth of binary If you use binaries, you may need to use size options. | depth | example | |------------|----------------------------------------------------------| | `:depth1` | `<>` | | `:depth2` | `<>` | | `:depth4` | `<>` | | `:depth8` | `<>` or `<>` | | `:depth16` | `<>` | """ @type data :: binary() | [pos_integer()] | [rgb_color()] | [rgba_color()] @typedoc """ Type of filtering. - `0` - None - `1` - Sub - `2` - Up - `3` - Average - `4` - Paeth see: https://en.wikipedia.org/wiki/Portable_Network_Graphics#Filtering """ @type scanline_filter :: 0 | 1 | 2 | 3 | 4 @typedoc """ Configurations for PNG image. """ @type t :: %__MODULE__{ type: color_type(), depth: bit_depth(), width: pos_int32(), height: pos_int32(), palette: [rgb_color()], scanline_filter: scanline_filter() } defguardp is_color_type(type) when type in [:gray, :rgb, :indexed, :gray_and_alpha, :rgba] defguardp is_bit_depth(depth) when depth in [:depth1, :depth2, :depth4, :depth8, :depth16] defguardp is_pos_int32(value) when is_integer(value) and value > 0 and value < 0x1_00_00_00_00 defguardp is_uint(n) when is_integer(n) and n >= 0 @doc false @spec color_type_to_value(t()) :: 0 | 2 | 3 | 4 | 6 def color_type_to_value(%Pngex{type: type}) when is_color_type(type) do case type do :gray -> 0 :rgb -> 2 :indexed -> 3 :gray_and_alpha -> 4 :rgba -> 6 end end @doc false @spec bit_depth_to_value(t()) :: 1 | 2 | 4 | 8 | 16 def bit_depth_to_value(%Pngex{depth: depth}) do case depth do :depth1 -> 1 :depth2 -> 2 :depth4 -> 4 :depth8 -> 8 :depth16 -> 16 end end @doc """ Creates a new Pngex structure. ## Options - `:type` - color type; - `:gray` - grayscale - `:rgb` - RGB (default) - `:indexed` - palette color - `:gray_and_alpha` - grayscale and alpha - `:rgba` - RGB and alpha - `:depth` - color depth; `:depth2`, `:depth4`, `:depth8` (default) or `:depth16` - `:width` - image width; 32-bit integer (1..4,294,967,295) - `:height` - image height; 32-bit integer (1..4,294,967,295) - `:palette` - palette table; list of RGB color tuples ## Examples ```elixir pngex = Pngex.new( type: :indexed, depth: :depth8, width: 640, height: 480, palette: [{0, 0, 0}, {255, 255, 255}] ) ``` """ @spec new(keyword()) :: t() | {:error, keyword()} def new(opts \\ []) when is_list(opts) do Enum.reduce(opts, %{pngex: %Pngex{}, errors: []}, fn {:type, type}, acc when is_color_type(type) -> %{acc | pngex: %{acc.pngex | type: type}} {:depth, depth}, acc when is_bit_depth(depth) -> %{acc | pngex: %{acc.pngex | depth: depth}} {:width, width}, acc when is_pos_int32(width) -> %{acc | pngex: %{acc.pngex | width: width}} {:height, height}, acc when is_pos_int32(height) -> %{acc | pngex: %{acc.pngex | height: height}} {:palette, palette} = item, acc -> if is_valid_palette(palette) do %{acc | pngex: %{acc.pngex | palette: palette}} else %{acc | errors: [item | acc.errors]} end error, acc -> %{acc | errors: [error | acc.errors]} end) |> case do %{pngex: pngex, errors: []} -> pngex %{errors: errors} -> {:error, Enum.reverse(errors)} end end @doc """ Sets color type. ## Examples ```elixir iex> Pngex.new() |> Pngex.set_type(:gray) %Pngex{type: :gray} ``` ```elixir iex> Pngex.new() |> Pngex.set_type(:monotone) {:error, invalid_type: :monotone} ``` """ @spec set_type(Pngex.t(), color_type()) :: Pngex.t() | {:error, invalid_type: any()} def set_type(%Pngex{} = pngex, type) when is_color_type(type) do %{pngex | type: type} end def set_type(%Pngex{}, type) do {:error, invalid_type: type} end @doc """ Sets bit depth. ## Examples ```elixir iex> Pngex.new() |> Pngex.set_depth(:depth16) %Pngex{depth: :depth16} ``` ```elixir iex> Pngex.new() |> Pngex.set_depth(:depth15) {:error, invalid_depth: :depth15} ``` """ @spec set_depth(Pngex.t(), bit_depth()) :: Pngex.t() | {:error, invalid_depth: any()} def set_depth(%Pngex{} = pngex, depth) when is_bit_depth(depth) do %{pngex | depth: depth} end def set_depth(%Pngex{}, depth) do {:error, invalid_depth: depth} end @doc """ Sets image width. ## Examples ```elixir iex> Pngex.new() |> Pngex.set_width(128) %Pngex{width: 128} ``` ```elixir iex> Pngex.new() |> Pngex.set_width(0) {:error, invalid_width: 0} ``` """ @spec set_width(t(), pos_int32()) :: t() | {:error, invalid_width: any()} def set_width(%Pngex{} = pngex, width) when is_pos_int32(width) do %{pngex | width: width} end def set_width(%Pngex{}, width) do {:error, invalid_width: width} end @doc """ Sets image hieght. ## Examples ```elixir iex> Pngex.new() |> Pngex.set_height(128) %Pngex{height: 128} ``` ```elixir iex> Pngex.new() |> Pngex.set_height(0) {:error, invalid_height: 0} ``` """ @spec set_height(t(), pos_int32()) :: t() | {:error, invalid_height: any()} def set_height(%Pngex{} = pngex, height) when is_pos_int32(height) do %{pngex | height: height} end def set_height(%Pngex{}, hieght) do {:error, invalid_height: hieght} end @doc """ Sets image width and height. ## Examples ```elixir iex> Pngex.new() |> Pngex.set_size(640, 480) %Pngex{width: 640, height: 480} ``` ```elixir iex> Pngex.new() |> Pngex.set_size(0, 480) {:error, invalid_size: %{width: 0}} ``` """ @spec set_size(t(), pos_int32(), pos_int32()) :: t() | {:error, invalid_size: map()} def set_size(%Pngex{} = pngex, width, height) do case {is_pos_int32(width), is_pos_int32(height)} do {true, true} -> %{pngex | width: width, height: height} {false, true} -> {:error, invalid_size: %{width: width}} {true, false} -> {:error, invalid_size: %{height: height}} {false, false} -> {:error, invalid_size: %{width: width, height: height}} end end @doc """ Sets a palette. ## Examples ```elixir iex> Pngex.new() |> Pngex.set_palette([{0, 0, 0}, {255, 0, 0}, {0, 255, 0}, {0, 0, 255}]) %Pngex{palette: [{0, 0, 0}, {255, 0, 0}, {0, 255, 0}, {0, 0, 255}]} ``` ```elixir iex> Pngex.new() |> Pngex.set_palette([0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255]) {:error, invalid_palette: [0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255]} ``` """ @spec set_palette(t(), [rgb_color()]) :: t() | {:error, invalid_palette: any()} def set_palette(%Pngex{} = pngex, palette) do if is_valid_palette(palette) do %{pngex | palette: palette} else {:error, invalid_palette: palette} end end @magic_number [0x89, "PNG", 0x0D, 0x0A, 0x1A, 0x0A] @compression_method 0 @filter_method 0 @interlace_method 0 @doc """ Generates a PNG image. ## Examples ```elixir image = Pngex.new(type: :rgb, depth: :depth8, width: 16, height: 16) |> Pngex.generate(for(c <- 0..255, do: {c, 255 - c, 0})) File.write("image.png", image) ``` """ @spec generate(t(), data()) :: iolist() def generate(%Pngex{} = pngex, data) do header = << pngex.width::32, pngex.height::32, bit_depth_to_value(pngex), color_type_to_value(pngex), @compression_method, @filter_method, @interlace_method >> bitmap = Bitmap.build(pngex, data) ihdr = Chunk.build("IHDR", header) idat = Chunk.build("IDAT", Zip.compress(bitmap)) iend = Chunk.build("IEND", "") case pngex do %Pngex{type: :indexed} -> plte = Chunk.build("PLTE", for({r, g, b} <- pngex.palette, do: <>)) [@magic_number, ihdr, plte, idat, iend] _ -> [@magic_number, ihdr, idat, iend] end end defp is_valid_palette(palette) do case palette do [] -> true [{r, g, b} | rest] when is_uint(r) and is_uint(g) and is_uint(b) -> is_valid_palette(rest) _ -> false end end end