defmodule Image.Color do @moduledoc """ Functions to manage image color and color conversion. """ alias Vix.Vips.Image, as: Vimage alias Vix.Vips.Operation @priv_dir :code.priv_dir(:image) |> List.to_string() @css_color_path Path.join(@priv_dir, "color/css_colors.csv") @additional_color_path Path.join(@priv_dir, "color/additional_colors.csv") @external_resource @additional_color_path @max_opacity 255 @min_opacity 0 @css_colors File.read!(@css_color_path) @additional_colors File.read!(@additional_color_path) @color_map (@css_colors <> "\n" <> @additional_colors) |> String.split("\n", trim: true) |> Enum.reject(&String.starts_with?(&1, "#")) |> Enum.map(&String.split(&1, ", ")) |> Enum.map(fn [name, hex] -> <<"#", r::bytes-2, g::bytes-2, b::bytes-2>> = hex rgb = [String.to_integer(r, 16), String.to_integer(g, 16), String.to_integer(b, 16)] {String.downcase(name), hex: hex, rgb: rgb} end) |> Map.new() @css_color Map.keys(@color_map) |> Enum.map(&String.to_atom/1) @greyscale_color_map @color_map |> Enum.filter(fn {_name, [hex: _hex, rgb: [r, g, b]]} -> if r == b && r == g, do: true, else: false end) |> Map.new() # TODO Put back when we validate greyscale colors # @greyscale_color Map.keys(@greyscale_color_map) # |> Enum.map(&String.to_atom/1) @typedoc """ An rbg color expressed as a list of numbers. The number of list elements and the type varies depending on the image format, colorspace and dimensions. For a common `sRGB` image it will be a list of three of four images. If the fourth number is provided it will be considered as an alpha transparency band. """ @type rgb_color :: [number()] | number() @typedoc """ A color can be expressed as a list of numbers or as a CSS color name in atom or string format. """ @type t :: rgb_color | atom() | String.t() @typedoc """ A transparency value which is one of the atoms `:none`, `:transparent` or `:opaque`. Or an integer between `0` and `255` where 0 is transparent and 255 is opaque. Or a float in the range `0.0` to `1.0` that is converted to the range `0` to `255`. """ @type transparency :: :none | :transparent | :opaque | non_neg_integer() | float() @typedoc """ Reference to an ICC color profile * `:none` means no profile * `:cmyk`, `:srgb` and `:p3` refer to the built-in color profiles * `Path.t()` means any file system path. If the path is a relative path then is will be loaded from the systems profile directory. """ @type icc_profile :: :none | :cmyk | :srgb | :p3 | Path.t() @inbuilt_profiles [:none, :srgb, :cmyk, :p3] @doc """ Guards whether a given value can be interpreted as a color value. """ defguard is_color(color) when (is_number(color) and color >= 0) or (is_list(color) and length(color) in 3..5) or color in @css_color @doc """ Guards whether a given profile is one of the inbuilt profiles. """ defguard is_inbuilt_profile(profile) when profile in @inbuilt_profiles @doc """ Returns the list of color profiles built into `libvips`. """ def inbuilt_profiles, do: @inbuilt_profiles @doc """ Returns a boolean indicating if the given profile is known and can be used for image operations. """ def known_icc_profile?(profile) when profile in @inbuilt_profiles do true end def known_icc_profile?(path) do case Vix.Vips.Operation.profile_load(path) do {:ok, _} -> true _other -> false end end @doc """ Returns a mapping from CSS color names to CSS hex values and RGB triplets as a list. """ def color_map do @color_map end @doc """ Returns a mapping from CSS color names to CSS hex values and RGB triplets as a list - but only for greyscal colors. """ def greyscale_color_map do @greyscale_color_map end @doc """ Converts a color name or RGB value to a hex string. ### Arguments * `color` which can be specified as a single integer or a list of integers representing the color. The color can also be supplied as a CSS color name as a string or atom. For example: `:misty_rose`. See `Image.Color.color_map/0` and `Image.Color.rgb_color/1`. ### Returns * `{:ok, #RRGGBB}` string or * `{:error, reason}`. ### Examples iex> Image.Color.rgb_to_hex(:green) {:ok, "#008000"} iex> Image.Color.rgb_to_hex([10,20,30]) {:ok, "#A141E"} """ def rgb_to_hex(color) do with {:ok, color} <- validate_color(color) do case color do [color] -> {:ok, convert_to_hex(color, color, color)} [r, g, b] -> {:ok, convert_to_hex(r, g, b)} end end end @doc """ Converts a hex color string to an RGB list. ### Arguments `color` is a hex string representing an RGB color. It has the form `#RRGGBB`. ### Returns * `{:ok, [r, g, b]}` or * `{:error, reason}`. ### Examples iex> Image.Color.validate_color "#0000FF" {:ok, [0, 0, 255]} """ def hex_to_rgb(<<"#", r::bytes-2, g::bytes-2, b::bytes-2>>) do {:ok, [String.to_integer(r, 16), String.to_integer(g, 16), String.to_integer(b, 16)]} end def hex_to_rgb(invalid_color) do {:error, "Invalid hex color. Found #{inspect(invalid_color)}"} end defp convert_to_hex(r, g, b) do "#" <> to_hex(r) <> to_hex(g) <> to_hex(b) end defp to_hex(i) when i >= 10 do Integer.to_string(i, 16) end defp to_hex(i) do "0" <> Integer.to_string(i, 16) end @doc """ Validates a color returning an `[r, g, b]` triplet or error. ### Arguments * `color` which can be specified as a single integer or a list of integers representing the color. The color can also be supplied as a CSS color name as a string or atom. For example: `:misty_rose`. Lastly, the color can be supplied as a hex string like `"#ffe4e1"`. See `Image.Color.color_map/0` and `Image.Color.rgb_color/1`. ### Returns * `{:ok, [r, g, b]}` or * `{:error, reason}` """ def validate_color(color) do case rgb_color(color) do {:ok, [hex: _hex, rgb: rgb]} -> {:ok, rgb} {:ok, color} when is_list(color) -> {:ok, color} {:ok, color} when is_integer(color) -> {:ok, [color, color, color]} other -> other end end @doc """ Returns a transparency value in the range 0 to 255 where 0 means transparent and 255 means opqque. """ def validate_transparency(float) when float >= 0.0 and float <= 1.0 do {:ok, round(@max_opacity * float)} end def validate_transparency(int) when int in 0..255 do {:ok, int} end def validate_transparency(:transparent), do: {:ok, @min_opacity} def validate_transparency(:none), do: {:ok, @min_opacity} def validate_transparency(:opaque), do: {:ok, @max_opacity} def validate_transparency(other), do: {:error, "Invalid transparency value. Found #{inspect(other)}"} def rgb_color(color) when is_binary(color) or is_atom(color) do case color do <<"#", r::bytes-2, g::bytes-2, b::bytes-2>> -> {:ok, [String.to_integer(r, 16), String.to_integer(g, 16), String.to_integer(b, 16)]} color -> case Map.fetch(color_map(), normalize(color)) do {:ok, color} -> {:ok, color} :error -> {:error, "Invalid color #{inspect(color)}"} end end end def rgb_color(color) when is_color(color) do {:ok, color} end def rgb_color!(color) do case rgb_color(color) do {:ok, color} -> color {:error, reason} -> raise ArgumentError, reason end end @doc false def max_opacity do @max_opacity end @doc false def min_opacity do @min_opacity end @doc false def rgba_color!(color, a \\ @max_opacity) def rgba_color!(color, _a) when color in [:none, :transparent] do [0, 0, 0, @min_opacity] end def rgba_color!(color, a) when is_binary(color) and is_integer(a) do [r, g, b] = case rgb_color!(color) do [hex: _hex, rgb: rgb_color] -> rgb_color [_r, _g, _b] = rgb_color -> rgb_color end [r, g, b, a] end def rgba_color!(color, a) when (is_binary(color) or is_atom(color)) and is_float(a) and a >= 0.0 and a <= 1.0 do a = round(@max_opacity * a) rgba_color!(color, a) end def rgba_color!(color, a) when (is_binary(color) or is_atom(color)) and is_integer(a) and a >= 0 do [r, g, b] = Keyword.get(rgb_color!(color), :rgb, color) [r, g, b, a] end def rgba_color!(color, a) when is_integer(color) and color >= 0 do rgba_color!([color, color, color], a) end def rgba_color!([r, g, b], a) when is_integer(a) and a >= 0 do [r, g, b, a] end def rgba_color!([_r, _g, _b, _a] = color, _alpha) do color end @doc false def normalize(color) do color |> to_string() |> String.downcase() |> String.replace(["_", "-", " "], "") end @doc """ Sorts a list of colors. The color sorting is based upon https://www.alanzucconi.com/2015/09/30/colour-sorting/ ### Arguments * `color` which can be specified as a single integer or a list of integers representing the color. The color can also be supplied as a CSS color name as a string or atom. For example: `:misty_rose`. Lastly, the color can be supplied as a hex string like `#ffe4e1`. See `Image.Color.color_map/0` and `Image.Color.rgb_color/1`. * `options` is a keyword list of options. ### Options There are currently no defined options. ### Returns * A sorted list of colors. """ @doc since: "0.49.0" def sort(colors, options \\ []) do Enum.sort(colors, &compare_colors(&1, &2, options)) end @doc false def compare_colors(color_1, color_2, options) do convert!(color_1, :srgb_to_hlv, options) < convert!(color_2, :srgb_to_hlv, options) end @default_repetitions 8 @doc """ Converts a color from one color space to another. ### Arguments * `color` which can be specified as a single integer or a list of integers representing the color. The color can also be supplied as a CSS color name as a string or atom. For example: `:misty_rose`. Lastly, the color can be supplied as a hex string like `"#ffe4e1"`. See `Image.Color.color_map/0` and `Image.Color.rgb_color/1`. * `from` is the colorspace from which `color` is converted. * `to` is the colorspace into which the `color` is converted. * `options` is a keyword list of options. ### Options There are currently no defined options. ### Returns * `{:ok, color}` or * `{:error, reason}` ### Example iex> Image.Color.convert(:misty_rose, :srgb, :hsv) {:ok, [4, 30, 255]} iex> Image.Color.convert([255, 255, 255], :srgb, :lab) {:ok, [100.0, 0.005245208740234375, -0.010609626770019531]} """ @doc since: "0.49.0" def convert(color, from, to, options \\ []) def convert([r, g, b], :srgb, :srgb, _options) do {:ok, [round(r), round(g), round(b)]} end def convert(color, from, from, _options) do {:ok, color} end def convert(color, :srgb, :hsv, _options) do with {:ok, color} <- validate_color(color) do Image.new!(1, 1, color: color) |> Operation.srgb2hsv!() |> Image.get_pixel(0, 0) end end def convert(color, :lab, :srgb, _options) do with {:ok, color} <- validate_color(color) do Image.new!(1, 1, color: color) |> Operation.lab2labq!() |> Operation.labq2srgb!() |> Image.get_pixel(0, 0) end end def convert(color, :srgb, :lab, _options) do with {:ok, color} <- validate_color(color) do Image.new!(1, 1, color: color) |> Operation.srgb2scrgb!() |> Operation.scrgb2xyz!() |> Operation.xyz2lab!() |> Operation.getpoint(0, 0) end end def convert(color, :srgb, :hlv, options) do with {:ok, [r, g, b]} <- validate_color(color), {:ok, [h, _s, v]} = convert(color, :srgb, :hsv, options) do repetitions = Keyword.get(options, :repetitions, @default_repetitions) lum = :math.sqrt(0.241 * r + 0.691 * g + 0.068 * b) h2 = trunc(h * repetitions) lum2 = trunc(lum * repetitions) v2 = trunc(v * repetitions) {:ok, [h2, lum2, v2]} end end @doc false def convert(%Vimage{} = image, to) do from = Image.colorspace(image) color = Image.get_pixel!(image, 0, 0) convert(color, from, to) end @doc """ Converts an color from one color space to another or raises an exception. ### Arguments * `color` which can be specified as a single integer which or a list of integers representing the color. The color can also be supplied as a CSS color name as a string or atom. For example: `:misty_rose`. See `Image.Color.color_map/0` and `Image.Color.rgb_color/1`. * `from` is the colorspace from which `color` is converted. * `to` is the colorspace into which the `color` is converted. * `options` is a keyword list of options. ### Options There are currently no defined options. ### Returns * `converted_color` or * raises an exception. ### Example iex> Image.Color.convert!(:misty_rose, :srgb, :hsv) [4, 30, 255] iex> Image.Color.convert!([255, 255, 255], :srgb, :lab) [100.0, 0.005245208740234375, -0.010609626770019531] """ @doc since: "0.49.0" def convert!(color, from, to, options \\ []) do case convert(color, from, to, options) do {:ok, color} -> color {:error, reason} -> raise Image.Error, reason end end end