From 120b2cc7bf3e27f8d50be70ea5012ba330aef230 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5vard=20Lindset?= Date: Mon, 10 Aug 2026 23:16:08 +0200 Subject: [PATCH] Unify the opacity vocabulary and fix alpha scaling --- CHANGELOG.md | 32 +++ lib/image.ex | 151 +++++----- lib/image/background_color.ex | 93 ++---- lib/image/options/embed.ex | 8 +- lib/image/options/join.ex | 2 +- lib/image/options/text.ex | 14 +- lib/image/pixel.ex | 345 ++++++++++++++++++----- lib/image/shape.ex | 70 +++-- lib/image/text.ex | 62 ++-- mix/for_dialyzer.ex | 4 +- test/background_color_test.exs | 50 ++-- test/distortion_test.exs | 2 +- test/embed_test.exs | 6 +- test/group_b_test.exs | 7 + test/image_adjustments_coverage_test.exs | 38 ++- test/image_test.exs | 2 +- test/pixel_color_gap_test.exs | 14 +- test/pixel_test.exs | 111 ++++++-- test/shape_coverage_test.exs | 8 + 19 files changed, 668 insertions(+), 351 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8617a2d9..18d690aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,12 +10,36 @@ * Adds `Image.Pixel.strip_alpha/2` which returns a resolved pixel without its alpha component. It consolidates the truncation that `Image.flatten/2`, `Image.chroma_mask/2`, `Image.Options.Trim` and `Image.Options.Write` each did separately before. ([#222](https://github.com/elixir-image/image/pull/222)) +* Adds `Image.Pixel.put_alpha/3` and `Image.Pixel.put_alpha!/3`, which return a pixel with its alpha component set to a given opacity, scaled to the image's alpha band. The pixel is returned unchanged when the image has no alpha band. ([#231](https://github.com/elixir-image/image/pull/231)) + +* Adds `Image.Pixel.opacity_fraction/1` and `Image.Pixel.opacity_fraction!/1`, which return an opacity as a fraction of full opacity. Unlike `Image.Pixel.alpha_for/2` the result belongs to no particular image. ([#231](https://github.com/elixir-image/image/pull/231)) + +* Adds `Image.Pixel.alpha_for/2` and `Image.Pixel.alpha_for!/2`, which scale an opacity to the alpha band of a given image, whose range depends on the interpretation. ([#231](https://github.com/elixir-image/image/pull/231)) + ### Changed +* `Image.Pixel.to_pixel/3` now applies the `:opacity` option to a color given as a list of numbers. It was previously ignored for those, so `to_pixel(image, [255, 0, 0, 255], opacity: 0.5)` returned a fully opaque pixel. ([#231](https://github.com/elixir-image/image/pull/231)) + +* The `:opacity` options of `Image.Shape` and the `:background_fill_opacity` / `:background_stroke_opacity` options of `Image.Text.text/2` accept any `t:Image.Pixel.opacity/0` rather than only a float, so every function taking an opacity now accepts the same values. ([#231](https://github.com/elixir-image/image/pull/231)) + +* **Breaking:** The `:opacity` option of `Image.drop_shadow/2` accepts any `t:Image.Pixel.opacity/0` rather than any number in `0.0..1.0`. `opacity: 1` previously meant fully opaque and is now 8-bit notation for `1/255`, as it is everywhere else. Use `1.0` instead. Only `1` changes meaning, since `0` remains fully transparent. ([#231](https://github.com/elixir-image/image/pull/231)) + +* **Breaking:** `Image.Pixel` returns an `Image.Error` on every error path. An unsupported interpretation was a bare string and is now `reason: :unsupported_interpretation`, and an invalid color was the `Color` library's own exception (`Color.UnknownColorNameError`, `Color.InvalidHexError`, `Color.InvalidColorError`) and is now `reason: :invalid_color` with the color in `value` and the original text as the message. This reaches every function that resolves a color, including the `:background` options. ([#231](https://github.com/elixir-image/image/pull/231)) + +* **Breaking:** An invalid opacity reports `reason: :invalid_opacity` instead of `:invalid_transparency`. ([#231](https://github.com/elixir-image/image/pull/231)) + * **Breaking:** `Image.Pixel.to_pixel/3` returns linear light values for an `:scrgb` image. It previously returned gamma encoded sRGB, so a color drawn onto an scRGB image came out too bright and desaturated. This affects every function that resolves a color against an scRGB image. ([#230](https://github.com/elixir-image/image/pull/230)) * **Breaking:** `Image.Pixel.to_pixel/3` returns relative luminance (CIE `Y`) for a single band `:scrgb` image. It previously returned a `0..255` integer, so `:white` resolved to `255` rather than `1.0`. ([#230](https://github.com/elixir-image/image/pull/230)) +* **Breaking:** `Image.Pixel.to_pixel/3` no longer rounds a float `:opacity` to a byte before scaling it. `opacity: 0.5` resolves to `0.5` on an `:scrgb` image and `32768` on a 16-bit image, where both previously went via `128` and came out as `0.50196` and `32896`. Interpretations with an 8-bit alpha band are unaffected, including Lab and LCH. ([#231](https://github.com/elixir-image/image/pull/231)) + +* `Image.add_alpha/2` accepts a float in `0.0..1.0` as well as the integer and atom forms it already took. An integer is a fraction of 255, so a float is the only way to add exactly half opacity to a 16-bit or `:scrgb` image. ([#231](https://github.com/elixir-image/image/pull/231)) + +* `Image.add_alpha/2` returns `{:error, %Image.Error{reason: :invalid_opacity}}` for an invalid opacity, where it previously raised `FunctionClauseError`. ([#231](https://github.com/elixir-image/image/pull/231)) + +* **Breaking:** `Image.add_alpha/2` scales the alpha band it adds to the image's interpretation. `:opaque` resolves to `65535` on a 16-bit image and `1.0` on an `:scrgb` image, where it previously wrote `255` into both, leaving 16-bit images 0.4% opaque and scRGB images out of range. An integer is now a fraction of `255`, so `128` means the same opacity everywhere. Interpretations with an 8-bit alpha band are unaffected, including Lab and LCH. ([#231](https://github.com/elixir-image/image/pull/231)) + * **Breaking:** `Image.average/1` and `Image.chroma_color/1` now return `{:ok, [number()]} | {:error, Image.Error.t()}` instead of a bare list on success. The previous success type was documented as `Pixel.t()` but was always a list of numbers. ([#219](https://github.com/elixir-image/image/pull/219)) * **Breaking:** `Image.warp_perspective/4` and `Image.straighten_perspective/3` now preserve image alpha instead of always flattening, so the band count and pixels of the result may change. An omitted `:background` defers to libvips' fill rather than defaulting to `:black`, matching the other background-taking functions. Images without alpha are unaffected, since libvips fills those with black. ([#216](https://github.com/elixir-image/image/pull/216)) @@ -66,6 +90,14 @@ ### Removed +* **Breaking:** Removes `Image.Pixel.transparency/1`, `Image.Pixel.max_opacity/0` and `Image.Pixel.min_opacity/0`. Use `Image.Pixel.alpha_for/2` for a value to write into an image's alpha band, or `Image.Pixel.opacity_fraction/1` for the opacity itself. ([#231](https://github.com/elixir-image/image/pull/231)) + +* **Breaking:** Removes the `t:Image.transparency/0` and `t:Image.Pixel.transparency/0` types. Use `t:Image.Pixel.opacity/0`, which is now the only opacity type. ([#231](https://github.com/elixir-image/image/pull/231)) + +* **Breaking:** Removes the `:alpha` option of `Image.Pixel.to_pixel/3` and the `{color, alpha: alpha}` background form. Use `:opacity` and `{color, opacity: opacity}`, which are the same options under new names. ([#231](https://github.com/elixir-image/image/pull/231)) + +* **Breaking:** Removes `:none` as an opacity, though not as a color. Use `:transparent` instead, which it was a synonym for. ([#231](https://github.com/elixir-image/image/pull/231)) + * **Breaking:** Removes `Image.Options.WarpPerspective`, replaced by `Image.Options.Mapim`. ([#216](https://github.com/elixir-image/image/pull/216)) * **Breaking:** Removes `Image.Options.Meme.validate_options/1` and `Image.Draw.maybe_add_alpha/2`. ([#220](https://github.com/elixir-image/image/pull/220)) diff --git a/lib/image.ex b/lib/image.ex index 98f09d0d..569b063a 100644 --- a/lib/image.ex +++ b/lib/image.ex @@ -167,14 +167,6 @@ defmodule Image do """ @type aspect :: :landscape | :portrait | :square - @typedoc """ - The level of transparency for an alpha band - where `0` means fully opaque and `255` means - fully transparent. - - """ - @type transparency :: 0..255 | :opaque | :transparent - @typedoc """ An image bounding box being a four element list of 2-tuples representing the points of a rectangle @@ -258,11 +250,6 @@ defmodule Image do @min_luminance 1.0 @max_luminance 99.0 - # Standard libvips/RGBA convention: alpha 255 = fully - # opaque (visible), alpha 0 = fully transparent (invisible). - @opaque_ 255 - @transparent 0 - # How many bins to use to calculate an approximate # dominant color. The maximum is 256. Larger numbers # significantly slow calculation. @@ -3272,11 +3259,11 @@ defmodule Image do OR - * an integer in the range `0..255` representing the - alpha-band fill value, using the standard libvips / RGBA - convention: `255` is fully opaque, `0` is fully - transparent. The atoms `:opaque` and `:transparent` may - also be provided in place of `255` and `0` respectively. + * an opacity, which is any `t:Image.Pixel.opacity/0`: a float in `0.0..1.0`, + an integer in `0..255` as the same value in 8-bit notation, or + `:transparent` / `:opaque`. The band added is scaled to `image`, so + `:opaque` is `65535` on a 16-bit image, `1.0` on an scRGB one, and + `255` everywhere else. ### Returns @@ -3291,10 +3278,18 @@ defmodule Image do iex> Image.get_pixel(with_alpha, 0, 0) {:ok, [10, 20, 30, 128]} + iex> image = Image.new!(3, 3, color: [10, 20, 30]) + iex> {:ok, with_alpha} = Image.add_alpha(image, 0.5) + iex> Image.get_pixel(with_alpha, 0, 0) + {:ok, [10, 20, 30, 128]} + """ @doc subject: "Operation", since: "0.13.0" - @spec add_alpha(image :: Vimage.t(), alpha_image :: Vimage.t() | transparency()) :: + @spec add_alpha(image :: Vimage.t(), alpha_image :: Vimage.t()) :: + {:ok, Vimage.t()} | {:error, error()} + + @spec add_alpha(image :: Vimage.t(), opacity :: Image.Pixel.opacity()) :: {:ok, Vimage.t()} | {:error, error()} def add_alpha(%Vimage{} = image, %Vimage{} = alpha_image) do @@ -3318,20 +3313,14 @@ defmodule Image do end end - def add_alpha(%Vimage{} = image, transparency) when transparency in 0..255 do - with {:ok, alpha_image} <- Image.new(image, bands: 1, color: transparency) do + def add_alpha(%Vimage{} = image, opacity) do + with {:ok, alpha} <- Image.Pixel.alpha_for(image, opacity), + {:ok, alpha_image} <- + Image.new(image, bands: 1, color: [alpha], format: band_format(image)) do add_alpha(image, alpha_image) end end - def add_alpha(%Vimage{} = image, :transparent) do - add_alpha(image, @transparent) - end - - def add_alpha(%Vimage{} = image, :opaque) do - add_alpha(image, @opaque_) - end - @doc """ Add an alpha band to an image. @@ -3380,8 +3369,9 @@ defmodule Image do """ @doc subject: "Operation", since: "0.13.0" - @spec add_alpha!(image :: Vimage.t(), alpha_image :: Vimage.t() | Image.Pixel.t()) :: - Vimage.t() | no_return() + @spec add_alpha!(image :: Vimage.t(), alpha_image :: Vimage.t()) :: Vimage.t() | no_return() + + @spec add_alpha!(image :: Vimage.t(), opacity :: Image.Pixel.opacity()) :: Vimage.t() | no_return() def add_alpha!(%Vimage{} = image, alpha_image) do case add_alpha(image, alpha_image) do @@ -3838,7 +3828,7 @@ defmodule Image do representing the color for each band. The color can also be supplied as a CSS color name as a string or atom (for example `:misty_rose`), a hex string, or `:average`. Wrap it as - `{color, alpha: a}` for a transparent or semi-transparent fill. + `{color, opacity: o}` for a transparent or semi-transparent fill. See `Image.Pixel.to_pixel/2` for the full range of accepted color forms. @@ -3941,7 +3931,7 @@ defmodule Image do representing the color for each band. The color can also be supplied as a CSS color name as a string or atom (for example `:misty_rose`), a hex string, or `:average`. Wrap it as - `{color, alpha: a}` for a transparent or semi-transparent fill. + `{color, opacity: o}` for a transparent or semi-transparent fill. See `Image.Pixel.to_pixel/2` for the full range of accepted color forms. @@ -5752,10 +5742,10 @@ defmodule Image do otherwise. To make the background semi-transparent (only meaningful when - `image` has an alpha band), wrap the color as `{color, alpha: a}`, - where `a` is an integer `0..255`, a float `0.0..1.0`, or the atom - `:opaque` / `:transparent`. For example: `{:misty_rose, alpha: 0.5}` - or `{:average, alpha: 128}`. + `image` has an alpha band), wrap the color as `{color, opacity: o}`, + where `o` is an integer `0..255`, a float `0.0..1.0`, or the atom + `:opaque` / `:transparent`. For example: `{:misty_rose, opacity: 0.5}` + or `{:average, opacity: 128}`. * `:extend_mode` synthesizes the generated border from the *image content* instead of a `:background` color. A content mode consumes @@ -6746,7 +6736,7 @@ defmodule Image do ## Transparent backgrounds A partially transparent `:background` is reproduced exactly. The one - exception is a *fully* transparent fill (`alpha: 0`) with non-zero + exception is a *fully* transparent fill (`opacity: 0`) with non-zero color bands: color cannot be recovered from under zero alpha, so it is rendered as transparent black rather than the declared color. @@ -7093,7 +7083,7 @@ defmodule Image do integer applied to all bands, or a list of integers representing the color for each band. The color can also be supplied as a CSS color name as a string or atom (for example `:misty_rose`), a hex - string, or `:average`. Wrap it as `{color, alpha: a}` for a + string, or `:average`. Wrap it as `{color, opacity: o}` for a transparent or semi-transparent fill. See `Image.Pixel.to_pixel/2` for the full range of accepted color forms. @@ -7105,7 +7095,7 @@ defmodule Image do An alpha band passes through the transformation. A partially transparent `:background` is reproduced exactly. The one exception - is a *fully* transparent fill (`alpha: 0`) with non-zero color bands: + is a *fully* transparent fill (`opacity: 0`) with non-zero color bands: color cannot be recovered from under zero alpha, so it is rendered as transparent black rather than the declared color. @@ -9609,7 +9599,7 @@ defmodule Image do integer applied to all bands, or a list of integers representing the color for each band. The color can also be supplied as a CSS color name as a string or atom (for example `:misty_rose`), a hex - string, or `:average`. Wrap it as `{color, alpha: a}` for a + string, or `:average`. Wrap it as `{color, opacity: o}` for a transparent or semi-transparent fill. See `Image.Pixel.to_pixel/2` for the full range of accepted color forms. @@ -9621,7 +9611,7 @@ defmodule Image do An alpha band passes through the transformation. A partially transparent `:background` is reproduced exactly. The one exception - is a *fully* transparent fill (`alpha: 0`) with non-zero color bands: + is a *fully* transparent fill (`opacity: 0`) with non-zero color bands: color cannot be recovered from under zero alpha, so it is rendered as transparent black rather than the declared color. @@ -11450,7 +11440,7 @@ defmodule Image do * `:color` is the shadow colour. Any value `Image.Pixel.to_pixel/3` accepts. Default `:black`. - * `:opacity` is a float in `[0.0, 1.0]` controlling the + * `:opacity` is any `t:Image.Pixel.opacity/0` controlling the shadow's overall intensity. Default `0.5`. * `:sigma` is the Gaussian blur sigma applied to the @@ -11539,26 +11529,40 @@ defmodule Image do opacity = Keyword.get(options, :opacity, 0.5) sigma = Keyword.get(options, :sigma, 5.0) - cond do - not (is_number(opacity) and opacity >= 0.0 and opacity <= 1.0) -> + with {:ok, opacity} <- validate_drop_shadow_opacity(opacity), + :ok <- validate_drop_shadow_sigma(sigma) do + {:ok, {opacity, sigma}} + end + end + + defp validate_drop_shadow_opacity(opacity) do + case Image.Pixel.opacity_fraction(opacity) do + {:ok, normalized} -> + {:ok, normalized} + + {:error, _reason} -> {:error, %Image.Error{ reason: :invalid_option, value: {:opacity, opacity}, - message: ":opacity must be a number in [0.0, 1.0]. Found #{inspect(opacity)}" + message: + ":opacity must be a float in 0.0..1.0, an integer in 0..255, " <> + ":transparent or :opaque. Found #{inspect(opacity)}" }} + end + end - not (is_number(sigma) and sigma > 0.0) -> - {:error, - %Image.Error{ - reason: :invalid_option, - value: {:sigma, sigma}, - message: ":sigma must be a positive number. Found #{inspect(sigma)}" - }} + defp validate_drop_shadow_sigma(sigma) when is_number(sigma) and sigma > 0.0 do + :ok + end - true -> - {:ok, {opacity, sigma}} - end + defp validate_drop_shadow_sigma(sigma) do + {:error, + %Image.Error{ + reason: :invalid_option, + value: {:sigma, sigma}, + message: ":sigma must be a positive number. Found #{inspect(sigma)}" + }} end defp combine_masks_min([single]), do: single @@ -12136,7 +12140,7 @@ defmodule Image do be applied to all bands, or a list of integers representing the color for each band. The color can also be supplied as a CSS color name as a string or atom (for example `:misty_rose`), a hex - string, or `:average`. Wrap it as `{color, alpha: a}` for a + string, or `:average`. Wrap it as `{color, opacity: o}` for a transparent or semi-transparent fill. See `Image.Pixel.to_pixel/2` for the full range of accepted color forms. @@ -12175,7 +12179,7 @@ defmodule Image do ## Transparent backgrounds A partially transparent `:background` is reproduced exactly. The one - exception is a *fully* transparent fill (`alpha: 0`) with non-zero + exception is a *fully* transparent fill (`opacity: 0`) with non-zero color bands: color cannot be recovered from under zero alpha, so it is rendered as transparent black rather than the declared color. @@ -12298,27 +12302,24 @@ defmodule Image do defp premultiply_explicitly?(image, options) do case Keyword.fetch(options, :background) do - {:ok, background} -> has_alpha?(image) and List.last(background) != opaque_alpha(image) + {:ok, background} -> has_alpha?(image) and not opaque?(background, image) :error -> false end end + defp opaque?(pixel, image) do + List.last(pixel) == Pixel.alpha_for!(image, :opaque) + end + defp premultiply_pixel(image, pixel) do # The background premultiplication must use the same alpha scale as the - # operations above. `Image.Pixel` encodes that scale as the opaque alpha - # value, so read it from there. - opaque_alpha = opaque_alpha(image) + # operations above. + opaque_alpha = Pixel.alpha_for!(image, :opaque) {color_bands, [alpha]} = Enum.split(pixel, -1) Enum.map(color_bands, &(&1 * alpha / opaque_alpha)) ++ [alpha] end - defp opaque_alpha(image) do - image - |> Pixel.to_pixel!(:black, alpha: :opaque) - |> List.last() - end - @doc """ Applies an affine transformation to an image or raises an exception. @@ -12891,7 +12892,7 @@ defmodule Image do the color for each band. The color can also be supplied as a CSS color name as a string or atom (for example `:misty_rose`), a hex string, or `:average`. Wrap it as - `{color, alpha: a}` for a transparent or semi-transparent fill. + `{color, opacity: o}` for a transparent or semi-transparent fill. See `Image.Pixel.to_pixel/2` for the full range of accepted color forms. @@ -12911,7 +12912,7 @@ defmodule Image do An alpha band passes through the warp. A partially transparent `:background` is reproduced exactly. The one exception is a - *fully* transparent fill (`alpha: 0`) with non-zero color bands: + *fully* transparent fill (`opacity: 0`) with non-zero color bands: color cannot be recovered from under zero alpha, so it is rendered as transparent black rather than the declared color. @@ -13030,7 +13031,7 @@ defmodule Image do the color for each band. The color can also be supplied as a CSS color name as a string or atom (for example `:misty_rose`), a hex string, or `:average`. Wrap it as - `{color, alpha: a}` for a transparent or semi-transparent fill. + `{color, opacity: o}` for a transparent or semi-transparent fill. If omitted, `libvips`' native all-zeros fill is used: transparent for images with an alpha band, black otherwise. See `Image.Pixel.to_pixel/2` for the full range of accepted @@ -13187,7 +13188,7 @@ defmodule Image do integer applied to all bands, or a list of integers representing the color for each band. The color can also be supplied as a CSS color name as a string or atom (for example `:misty_rose`), a hex - string, or `:average`. Wrap it as `{color, alpha: a}` for a + string, or `:average`. Wrap it as `{color, opacity: o}` for a transparent or semi-transparent fill. See `Image.Pixel.to_pixel/2` for the full range of accepted color forms. @@ -13206,7 +13207,7 @@ defmodule Image do An alpha band passes through the transformation. A partially transparent `:background` is reproduced exactly. The one exception - is a *fully* transparent fill (`alpha: 0`) with non-zero color bands: + is a *fully* transparent fill (`opacity: 0`) with non-zero color bands: color cannot be recovered from under zero alpha, so it is rendered as transparent black rather than the declared color. @@ -13543,7 +13544,7 @@ defmodule Image do integer applied to all bands, or a list of integers representing the color for each band. The color can also be supplied as a CSS color name as a string or atom (for example `:misty_rose`), a hex - string, or `:average`. Wrap it as `{color, alpha: a}` for a + string, or `:average`. Wrap it as `{color, opacity: o}` for a transparent or semi-transparent fill. See `Image.Pixel.to_pixel/2` for the full range of accepted color forms. @@ -13562,7 +13563,7 @@ defmodule Image do An alpha band passes through the transformation. A partially transparent `:background` is reproduced exactly. The one exception - is a *fully* transparent fill (`alpha: 0`) with non-zero color bands: + is a *fully* transparent fill (`opacity: 0`) with non-zero color bands: color cannot be recovered from under zero alpha, so it is rendered as transparent black rather than the declared color. diff --git a/lib/image/background_color.ex b/lib/image/background_color.ex index 05c38d7a..f54f65fe 100644 --- a/lib/image/background_color.ex +++ b/lib/image/background_color.ex @@ -8,10 +8,11 @@ defmodule Image.BackgroundColor do `Image.Pixel.to_pixel/2` (a `Color` struct, a hex string, a CSS named color, an atom or a list of numbers). - Either form may also be given as `{spec, alpha: transparency}` to attach an - explicit alpha (an integer `0..255`, a float `0.0..1.0`, or `:opaque` / - `:transparent`). The alpha is applied only when `image` has an alpha band, - otherwise it is dropped, since there is no band to carry it. + Either form may also be given as `{spec, opacity: opacity}` to attach an + explicit opacity (an integer `0..255`, a float `0.0..1.0`, or `:opaque` / + `:transparent`). It is applied only when `image` has an alpha band, + otherwise it is dropped, since there is no band to carry it. An + invalid opacity is an error either way. In all cases the resolved pixel matches `image`'s number of bands. """ @@ -19,8 +20,8 @@ defmodule Image.BackgroundColor do alias Image.Pixel alias Vix.Vips.Image, as: Vimage - @typedoc "A background color specification: the image's average color, or any color, optionally with an explicit alpha." - @type spec :: Pixel.t() | :average | {Pixel.t() | :average, [alpha: Pixel.transparency()]} + @typedoc "A background color specification: the image's average color, or any color, optionally with an explicit opacity." + @type spec :: Pixel.t() | :average | {Pixel.t() | :average, [opacity: Pixel.opacity()]} @doc """ Resolves a background color `spec` into a pixel matching `image`'s @@ -32,13 +33,13 @@ defmodule Image.BackgroundColor do * `spec` is `:average` (the image's average color), any color accepted by `Image.Pixel.to_pixel/2`, or either of those wrapped - as `{spec, alpha: transparency}` to attach an explicit alpha. + as `{spec, opacity: opacity}` to attach an explicit opacity. ### Returns * `{:ok, [number()]}` - the resolved pixel, whose band count matches - `image` (an opaque alpha band is appended for `:average` when the - image has alpha), or + `image` (an opaque alpha component is appended for `:average` when + the image has alpha), or * `{:error, t:Image.Error.t/0}` @@ -56,22 +57,22 @@ defmodule Image.BackgroundColor do @spec resolve(Vimage.t(), spec()) :: {:ok, [number()]} | {:error, Image.Error.t()} def resolve(%Vimage{} = image, :average) do case Image.average(image) do - # The average has no alpha band, so an opaque one is appended when the - # image has alpha. - {:ok, color} -> - put_alpha_band(image, color, :opaque) + # The average has no alpha component, so an opaque one is appended when + # the image has alpha. + {:ok, pixel} -> + Pixel.put_alpha(pixel, image, :opaque) {:error, reason} -> - {:error, error("Could not compute the image average", reason)} + {:error, reason} end end - # The wrapped `{spec, alpha: alpha}` form: resolve the color part like any - # other spec, then set the alpha band directly. + # The wrapped `{spec, opacity: opacity}` form: resolve the color part like any + # other spec, then set the alpha component directly. def resolve(%Vimage{} = image, {spec, opts}) when is_list(opts) do - with {:ok, alpha} <- fetch_alpha(spec, opts), + with {:ok, opacity} <- fetch_opacity(spec, opts), {:ok, pixel} <- resolve(image, spec) do - apply_alpha(image, pixel, alpha) + Pixel.put_alpha(pixel, image, opacity) end end @@ -80,63 +81,27 @@ defmodule Image.BackgroundColor do {:ok, pixel} -> {:ok, pixel} - {:error, reason} -> - {:error, error("Invalid background color #{inspect(color)}", reason)} + # An invalid color does not say which option it came from. + {:error, %Image.Error{reason: :invalid_color} = error} -> + {:error, %{error | message: "Invalid background color #{inspect(color)}: #{error.message}"}} + + {:error, %Image.Error{} = error} -> + {:error, error} end end - # `:alpha` is the only supported key in the wrapped form. A missing or + # `:opacity` is the only supported key in the wrapped form. A missing or # misspelled key is reported as an error rather than raised. - defp fetch_alpha(_spec, [alpha: alpha]), do: {:ok, alpha} + defp fetch_opacity(_spec, opacity: opacity), do: {:ok, opacity} - defp fetch_alpha(spec, opts) do + defp fetch_opacity(spec, opts) do {:error, %Image.Error{ reason: :invalid_background, value: {spec, opts}, message: "Invalid background color #{inspect({spec, opts})}: " <> - "expected {color, alpha: transparency}" + "expected {color, opacity: opacity}" }} end - - # Set the alpha band of an already-resolved pixel from an alpha spec. The - # alpha is validated up front so an invalid value errors even on an image - # without an alpha band. - defp apply_alpha(image, pixel, alpha) do - with {:ok, _byte} <- validate_alpha(alpha) do - put_alpha_band(image, pixel, alpha) - end - end - - defp validate_alpha(alpha) do - case Pixel.transparency(alpha) do - {:ok, byte} -> {:ok, byte} - {:error, reason} -> {:error, error("Invalid alpha #{inspect(alpha)}", reason)} - end - end - - # Make the pixel's alpha band the given alpha, scaled to the image's - # interpretation via `to_pixel/3`. The pixel may arrive with or without an - # alpha band (taking the color bands normalizes both). On an image without - # an alpha band the alpha is unrepresentable and the pixel is returned - # unchanged. - defp put_alpha_band(image, pixel, alpha) do - if Image.has_alpha?(image) do - case Pixel.to_pixel(image, :black, alpha: alpha) do - {:ok, scaled} -> - color_bands = Image.bands(image) - 1 - {:ok, Enum.take(pixel, color_bands) ++ [List.last(scaled)]} - - {:error, reason} -> - {:error, error("Could not construct alpha #{inspect(pixel)}", reason)} - end - else - {:ok, pixel} - end - end - - defp error(message, reason) do - %Image.Error{message: "#{message}: #{inspect(reason)}", reason: reason} - end end diff --git a/lib/image/options/embed.ex b/lib/image/options/embed.ex index 8c612062..a28cbab0 100644 --- a/lib/image/options/embed.ex +++ b/lib/image/options/embed.ex @@ -40,7 +40,11 @@ defmodule Image.Options.Embed do # The extend modes that synthesize the border from the image content. All # other (color/transparency) fills go through the `:background` option. - @content_extends [copy: :VIPS_EXTEND_COPY, repeat: :VIPS_EXTEND_REPEAT, mirror: :VIPS_EXTEND_MIRROR] + @content_extends [ + copy: :VIPS_EXTEND_COPY, + repeat: :VIPS_EXTEND_REPEAT, + mirror: :VIPS_EXTEND_MIRROR + ] @content_extend_modes Keyword.keys(@content_extends) @vips_content_extends Keyword.values(@content_extends) @@ -67,7 +71,7 @@ defmodule Image.Options.Embed do end end - # `:average`, colors, and the `{color, alpha: a}` form are all resolved by + # `:average`, colors, and the `{color, opacity: o}` form are all resolved by # `Image.BackgroundColor.resolve/2`. The resolved pixel keeps its alpha band # (unlike `write`/`flatten`) so a transparent border can be requested. defp validate_option({:background, background}, image, _width, _height, options) do diff --git a/lib/image/options/join.ex b/lib/image/options/join.ex index 669f2e46..6bb77c60 100644 --- a/lib/image/options/join.ex +++ b/lib/image/options/join.ex @@ -108,7 +108,7 @@ defmodule Image.Options.Join do end # Resolved via `Image.BackgroundColor.resolve/2` (colors, `:average`, and the - # `{color, alpha: a}` form). The alpha band is kept so a gap can be filled with + # `{color, opacity: o}` form). The alpha band is kept so a gap can be filled with # a transparent or semi-transparent color. `arrayjoin` reproduces it exactly. defp validate_option({:background, background}, image, options) do case BackgroundColor.resolve(image, background) do diff --git a/lib/image/options/text.ex b/lib/image/options/text.ex index aff6caff..cd0940b1 100644 --- a/lib/image/options/text.ex +++ b/lib/image/options/text.ex @@ -290,13 +290,13 @@ defmodule Image.Options.Text do end @doc false - def validate_opacity(_option, opacity, options) - when is_float(opacity) and opacity >= 0.0 and opacity <= 1.0 do - {:cont, options} - end - - def validate_opacity(option, opacity, _options) do - {:halt, {:error, invalid_option(option, opacity)}} + # Any `t:Image.Pixel.opacity/0`, normalized to the 0.0..1.0 fraction + # the drawing operations multiply by. + def validate_opacity(option, opacity, options) do + case Image.Pixel.opacity_fraction(opacity) do + {:ok, normalized} -> {:cont, Keyword.put(options, option, normalized)} + {:error, _reason} -> {:halt, {:error, invalid_option(option, opacity)}} + end end @doc false diff --git a/lib/image/pixel.ex b/lib/image/pixel.ex index 8acc5583..dc18fc55 100644 --- a/lib/image/pixel.ex +++ b/lib/image/pixel.ex @@ -11,6 +11,24 @@ defmodule Image.Pixel do numeric lists) without worrying about whether the target image is sRGB, Lab, scRGB, CMYK, or 16-bit. + ## Opacity, alpha and color + + An **opacity** (`t:opacity/0`) is the value a caller supplies, + expressed relative to full opacity rather than to any image: + `:transparent`, `:opaque`, a float in `0.0..1.0`, or an integer + in `0..255` as the same value in 8-bit notation. + + An **alpha** is what an opacity becomes, the content of an image's + alpha band. `alpha_for/2` scales an opacity to a given image's + band, which runs to `65535` for 16-bit and `1.0` for scRGB, and + `put_alpha/3` sets it on an existing pixel. Prefer a float when + the alpha band is not 8-bit: an integer is a fraction of 255, so + it cannot reach every value of a 16-bit or scRGB band. + + A **color** may be `:none`, `:transparent` or `:opaque` on top of + everything `Color.new/2` accepts. All three resolve to black, the + first two fully transparent. + ## Example iex> {:ok, image} = Image.new(2, 2, color: :black) @@ -33,8 +51,8 @@ defmodule Image.Pixel do This includes any input accepted by `Color.new/2` (a `Color.*` struct, a numeric list of length 3..5, a hex string, a CSS named - color string or atom), plus the Image-specific transparency aliases - `:none`, `:transparent`, and `:opaque`. + color string or atom), plus `:none`, `:transparent` and + `:opaque`. """ @type t :: @@ -44,18 +62,29 @@ defmodule Image.Pixel do | atom() @typedoc """ - A transparency value. + How opaque something should be, expressed relative to full + opacity rather than to any particular image's alpha band. - * `:none` and `:transparent` are equivalent to `0` (fully transparent). - * `:opaque` is equivalent to `255` (fully opaque). - * An integer in `0..255` is used as-is. - * A float in `0.0..1.0` is scaled to `0..255`. + A float in `0.0..1.0` is the canonical form, being a fraction of + full opacity. An integer in `0..255` is that same value in 8-bit + notation, normalized as `n / 255`. The integer form does not + imply that the target's alpha band is 8-bit: `128` means `128/255` + on a 16-bit image too, not `128/65535`. - """ - @type transparency :: :none | :transparent | :opaque | 0..255 | float() + > #### `1` and `1.0` differ {: .warning} + > + > `1` is 8-bit notation for `1/255`, which is very nearly + > transparent. Fully opaque is `1.0` or `:opaque`. + + `:transparent` is `0.0` and `:opaque` is `1.0`. `:none` is not an + opacity, only a color meaning no color at all. + + `alpha_for/2` scales an opacity to the alpha band of a given image, + which is the range `0..65535` for a 16-bit image and `0.0..1.0` for + an scRGB one. - @max_opacity 255 - @min_opacity 0 + """ + @type opacity :: :transparent | :opaque | 0..255 | float() # Map Image.Interpretation atoms to the Color module that best # represents that space, and the encoder used by encode/3. @@ -105,17 +134,15 @@ defmodule Image.Pixel do * `color` is anything `Color.new/2` accepts: a `Color.*` struct, a list of 3/4/5 numbers, a hex string (`"#ff0000"`, `"#f80"`, `"#ff000080"`), a CSS named color (`"rebeccapurple"`, - `:misty_rose`), or one of Image's transparency aliases (`:none`, - `:transparent`, `:opaque`). + `:misty_rose`), or `:none`, `:transparent` or `:opaque`. * `options` is a keyword list — see below. ### Options - * `:alpha` — if the target image has an alpha band, force this - transparency. Accepts any value `transparency/1` accepts. If - unset, the input color's own alpha is used (or full opacity if - none). + * `:opacity` — if the target image has an alpha band, force this + opacity. Accepts any `t:opacity/0`. If unset, the input color's + own alpha is used, or full opacity if it has none. * `:intent` — passed through to `Color.convert/3`. One of `:relative_colorimetric` (default), `:absolute_colorimetric`, @@ -136,8 +163,10 @@ defmodule Image.Pixel do * For 16-bit interpretations (`:rgb16`, `:grey16`) the output is integers in `0..65535`. - * For float interpretations (`:scrgb`, `:lab`, `:lch`, etc.) the - output is floats in the natural range of that space. + * For `:scrgb`, `:lab` and `:lch` the color bands are floats in the + natural range of that space, and `:labs` uses 16-bit integers. + The alpha component is `0.0..1.0` for `:scrgb` and `0..255` for + the other three. * `:scrgb` is linear light: mid grey `"#808080"` encodes as `0.216`, not `0.502`. @@ -164,7 +193,7 @@ defmodule Image.Pixel do {:ok, [255, 0, 0, 255]} iex> {:ok, image} = Image.new(2, 2, color: [0, 0, 0, 255]) - iex> Image.Pixel.to_pixel(image, :red, alpha: 0.5) + iex> Image.Pixel.to_pixel(image, :red, opacity: 0.5) {:ok, [255, 0, 0, 128]} iex> {:ok, image} = Image.new(2, 2, color: :black) @@ -176,7 +205,7 @@ defmodule Image.Pixel do image :: Vimage.t() | MutableImage.t(), color :: t(), options :: Keyword.t() - ) :: {:ok, [number()]} | {:error, String.t()} + ) :: {:ok, [number()]} | {:error, Image.Error.t()} def to_pixel(image, color, options \\ []) # If the input is already a list of numbers whose length matches the @@ -186,15 +215,18 @@ defmodule Image.Pixel do # for callers that already speak the image's interpretation # natively (Image.if_then_else, k-means clusters, gradient defaults, # etc). - def to_pixel(%Vimage{} = image, color, _options) + def to_pixel(%Vimage{} = image, color, options) when is_list(color) and color != [] do bands = Vimage.bands(image) interpretation = Image.colorspace(image) if length(color) == bands and pre_encoded?(color, interpretation) do - {:ok, color} + case fetch_opacity(options) do + nil -> {:ok, color} + opacity -> put_alpha(color, image, opacity) + end else - do_to_pixel_vimage(image, color, []) + do_to_pixel_vimage(image, color, options) end end @@ -260,15 +292,19 @@ defmodule Image.Pixel do defp do_to_pixel(interpretation, bands, has_alpha, color, options) do intent = Keyword.get(options, :intent, :relative_colorimetric) - explicit_alpha = Keyword.get(options, :alpha) + explicit_opacity = fetch_opacity(options) color_bands = if has_alpha, do: bands - 1, else: bands with {:ok, source_struct} <- resolve(color), {:ok, {target_module, encoder}} <- target_for(interpretation, color_bands), {:ok, converted} <- convert(source_struct, target_module, intent), {:ok, base_pixel} <- encode(encoder, converted), - {:ok, alpha_value} <- alpha_for(encoder, explicit_alpha, source_struct, has_alpha) do + {:ok, alpha_value} <- resolve_alpha(encoder, explicit_opacity, source_struct, has_alpha) do {:ok, fit_bands(base_pixel, alpha_value, bands, has_alpha)} + else + # `Color.convert/3` reports failures with its own exceptions too. + {:error, %Image.Error{} = error} -> {:error, error} + {:error, reason} -> {:error, invalid_color(color, reason)} end end @@ -282,7 +318,7 @@ defmodule Image.Pixel do [255, 0, 0] iex> image = Image.new!(2, 2, color: [0, 0, 0, 255]) - iex> Image.Pixel.to_pixel!(image, :red, alpha: 0.5) + iex> Image.Pixel.to_pixel!(image, :red, opacity: 0.5) [255, 0, 0, 128] """ @@ -341,6 +377,73 @@ defmodule Image.Pixel do end end + @doc """ + Returns `pixel` with its alpha component set to `opacity`, scaled + to the alpha band of `image`. + + The inverse of `strip_alpha/2`, and like it a no-op on an image + with no alpha band, since there is no component to set. `opacity` + is validated either way. + + ### Arguments + + * `pixel` is a list of numbers already in `image`'s interpretation. + + * `image` is any `t:Vix.Vips.Image.t/0`. Its interpretation + determines the alpha the opacity scales to. + + * `opacity` is any `t:opacity/0`. + + ### Returns + + * `{:ok, pixel}` with its last component replaced, or with `pixel` + unchanged if `image` has no alpha band, or + + * `{:error, reason}`. + + ### Examples + + iex> {:ok, image} = Image.new(2, 2, color: [0, 0, 0, 255]) + iex> Image.Pixel.put_alpha([255, 0, 0, 255], image, 0.5) + {:ok, [255, 0, 0, 128]} + + iex> {:ok, image} = Image.new(2, 2, color: :black) + iex> Image.Pixel.put_alpha([255, 0, 0], image, 0.5) + {:ok, [255, 0, 0]} + + """ + @spec put_alpha(pixel :: [number()], image :: Vimage.t(), opacity :: opacity()) :: + {:ok, [number()]} | {:error, Image.Error.t()} + + def put_alpha(pixel, %Vimage{} = image, opacity) when is_list(pixel) do + if Vimage.has_alpha?(image) do + with {:ok, alpha} <- alpha_for(image, opacity) do + {:ok, Enum.take(pixel, Vimage.bands(image) - 1) ++ [alpha]} + end + else + # Checked even when there is no band to write it to. + with {:ok, _unit} <- opacity_fraction(opacity), do: {:ok, pixel} + end + end + + @doc """ + Same as `put_alpha/3`, but raises on error. + + ### Examples + + iex> image = Image.new!(2, 2, color: [0, 0, 0, 255]) + iex> Image.Pixel.put_alpha!([255, 0, 0, 255], image, 0.5) + [255, 0, 0, 128] + + """ + @spec put_alpha!(pixel :: [number()], image :: Vimage.t(), opacity :: opacity()) :: [number()] + def put_alpha!(pixel, %Vimage{} = image, opacity) do + case put_alpha(pixel, image, opacity) do + {:ok, pixel} -> pixel + {:error, reason} -> raise Image.Error, reason + end + end + @doc """ Resolves a color input to an sRGB pixel `[r, g, b]` (or `[r, g, b, a]`) with channels in `0..255`, regardless of any @@ -352,8 +455,8 @@ defmodule Image.Pixel do ### Arguments - * `color` is anything `Color.new/2` accepts, plus the - transparency aliases. + * `color` is anything `Color.new/2` accepts, plus `:none`, + `:transparent` and `:opaque`. ### Returns @@ -375,7 +478,7 @@ defmodule Image.Pixel do {:ok, [255, 0, 0]} """ - @spec to_srgb(color :: t()) :: {:ok, [0..255]} | {:error, Image.Error.t() | term()} + @spec to_srgb(color :: t()) :: {:ok, [0..255]} | {:error, Image.Error.t()} def to_srgb(color) do with {:ok, source_struct} <- resolve(color), {:ok, %Color.SRGB{r: r, g: g, b: b, alpha: alpha}} <- @@ -387,6 +490,9 @@ defmodule Image.Pixel do else {:ok, base ++ [scale(alpha, 255)]} end + else + {:error, %Image.Error{} = error} -> {:error, error} + {:error, reason} -> {:error, invalid_color(color, reason)} end end @@ -411,81 +517,133 @@ defmodule Image.Pixel do end @doc """ - Returns a transparency value in `0..255` where `0` is transparent - and `255` is opaque. + Returns an alpha value scaled to the alpha band of `image`. - ### Arguments + The result is in the range `image` actually uses: `0..65535` for + 16-bit images, `0.0..1.0` for scRGB, and `0..255` for everything + else, including Lab and LCH despite their float color bands. - * `transparency` is one of: + The result is a band value, to be written into a pixel. It is + not an opacity and must not be passed back where one is expected, + such as `Image.add_alpha/2` or the `:opacity` option of + `to_pixel/3`, which would scale it a second time. - * The atoms `:none`, `:transparent`, or `:opaque`. + ### Arguments - * An integer in `0..255`. + * `image` is any `t:Vix.Vips.Image.t/0`. - * A float in `0.0..1.0`. + * `opacity` is any `t:opacity/0`. ### Returns - * `{:ok, 0..255}` or + * `{:ok, number}` or * `{:error, reason}`. ### Examples - iex> Image.Pixel.transparency(:opaque) + iex> image = Image.new!(2, 2, color: :black) + iex> Image.Pixel.alpha_for(image, :opaque) {:ok, 255} - iex> Image.Pixel.transparency(:transparent) - {:ok, 0} - - iex> Image.Pixel.transparency(0.5) - {:ok, 128} + iex> image = Image.new!(2, 2, color: :black) + iex> Image.Pixel.alpha_for(Image.to_colorspace!(image, :rgb16), 0.5) + {:ok, 32768} - iex> Image.Pixel.transparency(200) - {:ok, 200} + iex> image = Image.new!(2, 2, color: :black) + iex> Image.Pixel.alpha_for(Image.to_colorspace!(image, :scrgb), :opaque) + {:ok, 1.0} """ - @spec transparency(value :: transparency()) :: {:ok, 0..255} | {:error, Image.Error.t()} - def transparency(:none), do: {:ok, @min_opacity} - def transparency(:transparent), do: {:ok, @min_opacity} - def transparency(:opaque), do: {:ok, @max_opacity} - def transparency(int) when is_integer(int) and int in 0..255, do: {:ok, int} + @spec alpha_for(image :: Vimage.t(), opacity :: opacity()) :: + {:ok, number()} | {:error, Image.Error.t()} - def transparency(float) when is_float(float) and float >= 0.0 and float <= 1.0, - do: {:ok, round(@max_opacity * float)} + def alpha_for(%Vimage{} = image, opacity) do + bands = Vimage.bands(image) + color_bands = if Vimage.has_alpha?(image), do: bands - 1, else: bands - def transparency(other) do - {:error, - %Image.Error{ - reason: :invalid_transparency, - value: other, - message: "Invalid transparency value: #{inspect(other)}" - }} + with {:ok, unit} <- opacity_fraction(opacity), + {:ok, {_target_module, encoder}} <- target_for(Image.colorspace(image), color_bands) do + {:ok, scale_alpha_to_encoder(unit, encoder)} + end end @doc """ - The maximum opacity value (255). + Same as `alpha_for/2`, but raises on error. ### Examples - iex> Image.Pixel.max_opacity() + iex> image = Image.new!(2, 2, color: :black) + iex> Image.Pixel.alpha_for!(image, :opaque) 255 """ - @spec max_opacity() :: 255 - def max_opacity, do: @max_opacity + @spec alpha_for!(image :: Vimage.t(), opacity :: opacity()) :: number() + def alpha_for!(%Vimage{} = image, opacity) do + case alpha_for(image, opacity) do + {:ok, alpha} -> alpha + {:error, reason} -> raise Image.Error, reason + end + end + + @doc """ + Returns an opacity as a fraction of full opacity. + + The `0.0..1.0` float is the canonical form of an opacity, so this + is the identity for a float and `n / 255` for an integer. Unlike + `alpha_for/2`, the result belongs to no particular image. + + ### Arguments + + * `opacity` is any `t:opacity/0`. + + ### Returns + + * `{:ok, float}` in `0.0..1.0`, or + + * `{:error, t:Image.Error.t/0}`. + + ### Examples + + iex> Image.Pixel.opacity_fraction(:opaque) + {:ok, 1.0} + + iex> Image.Pixel.opacity_fraction(0.5) + {:ok, 0.5} + + iex> Image.Pixel.opacity_fraction(128) + {:ok, 0.5019607843137255} + + """ + @spec opacity_fraction(opacity :: opacity()) :: {:ok, float()} | {:error, Image.Error.t()} + + def opacity_fraction(:transparent), do: {:ok, 0.0} + def opacity_fraction(:opaque), do: {:ok, 1.0} + + def opacity_fraction(int) when is_integer(int) and int in 0..255, + do: {:ok, int / 255} + + def opacity_fraction(float) when is_float(float) and float >= 0.0 and float <= 1.0, + do: {:ok, float} + + def opacity_fraction(other), do: invalid_opacity(other) @doc """ - The minimum opacity value (0). + Same as `opacity_fraction/1`, but raises on error. ### Examples - iex> Image.Pixel.min_opacity() - 0 + iex> Image.Pixel.opacity_fraction!(:transparent) + 0.0 """ - @spec min_opacity() :: 0 - def min_opacity, do: @min_opacity + @spec opacity_fraction!(opacity :: opacity()) :: float() + def opacity_fraction!(opacity) do + case opacity_fraction(opacity) do + {:ok, fraction} -> fraction + {:error, reason} -> raise Image.Error, reason + end + end ## Internals -------------------------------------------------------------- @@ -503,7 +661,20 @@ defmodule Image.Pixel do defp resolve(float) when is_float(float) and float >= 0.0 and float <= 1.0, do: {:ok, %Color.SRGB{r: float, g: float, b: float, alpha: nil}} - defp resolve(other), do: Color.new(other) + # `Color` reports invalid input with its own exceptions. Translated so no + # foreign error shape escapes this module. + defp resolve(other) do + case Color.new(other) do + {:ok, color} -> {:ok, color} + {:error, reason} -> {:error, invalid_color(other, reason)} + end + end + + defp invalid_color(value, reason) do + message = if is_exception(reason), do: Exception.message(reason), else: to_string(reason) + + %Image.Error{reason: :invalid_color, value: value, message: message} + end # Color.RGB is the only target that needs a working space # libvips scRGB is linear light on the sRGB primaries. @@ -534,8 +705,13 @@ defmodule Image.Pixel do :error -> {:error, - "Image.Pixel does not yet support the #{inspect(interpretation)} interpretation. " <> - "Pass a numeric pixel list directly, or open an issue."} + %Image.Error{ + reason: :unsupported_interpretation, + value: interpretation, + message: + "Image.Pixel does not yet support the #{inspect(interpretation)} interpretation. " <> + "Pass a numeric pixel list directly, or open an issue." + }} end end @@ -607,13 +783,17 @@ defmodule Image.Pixel do ## Alpha handling ------------------------------------------------------- - defp alpha_for(_encoder, _explicit, _source, false), do: {:ok, nil} + defp fetch_opacity(options) do + Keyword.get(options, :opacity) + end - defp alpha_for(encoder, explicit, source, true) do + defp resolve_alpha(_encoder, _explicit, _source, false), do: {:ok, nil} + + defp resolve_alpha(encoder, explicit_opacity, source, true) do cond do - not is_nil(explicit) -> - with {:ok, byte} <- transparency(explicit) do - {:ok, scale_alpha_to_encoder(byte / 255.0, encoder)} + not is_nil(explicit_opacity) -> + with {:ok, normalized} <- opacity_fraction(explicit_opacity) do + {:ok, scale_alpha_to_encoder(normalized, encoder)} end is_struct(source) and Map.get(source, :alpha) != nil -> @@ -624,6 +804,17 @@ defmodule Image.Pixel do end end + defp invalid_opacity(value) do + {:error, + %Image.Error{ + reason: :invalid_opacity, + value: value, + message: + "Invalid opacity #{inspect(value)}. Must be a float in 0.0..1.0, " <> + "an integer in 0..255, :transparent or :opaque" + }} + end + # Encoders grouped by their alpha band's max value @alpha_max_255 [ :uchar_rgb, diff --git a/lib/image/shape.ex b/lib/image/shape.ex index 9e471d99..e99d3fd1 100644 --- a/lib/image/shape.ex +++ b/lib/image/shape.ex @@ -64,9 +64,10 @@ defmodule Image.Shape do * `:stroke_color` is the color used for the outline of the rectangle. The default is `:white`. - * `:opacity` is the opacity as a float between - `0.0` and `1.0` where `0.0` is completely transparent - and `1.0` is completely opaque. The default is `0.7`. + * `:opacity` is any `t:Image.Pixel.opacity/0`: a + float in `0.0..1.0`, an integer in `0..255` as the + same value in 8-bit notation, or `:transparent` / + `:opaque`. The default is `0.7`. * `:rotation` is the number of degrees to rotate the axis of a generated rectangle. @@ -131,9 +132,10 @@ defmodule Image.Shape do * `:stroke_color` is the color used for the outline of the rectangle. The default is `:white`. - * `:opacity` is the opacity as a float between - `0.0` and `1.0` where `0.0` is completely transparent - and `1.0` is completely opaque. The default is `0.7`. + * `:opacity` is any `t:Image.Pixel.opacity/0`: a + float in `0.0..1.0`, an integer in `0..255` as the + same value in 8-bit notation, or `:transparent` / + `:opaque`. The default is `0.7`. * `:rotation` is the number of degrees to rotate the axis of a generated rectangle. @@ -197,9 +199,10 @@ defmodule Image.Shape do * `:stroke_color` is the color used for the outline of the polygon. The default is `:white` - * `:opacity` is the opacity as a float between - `0.0` and `1.0` where `0.0` is completely transparent - and `1.0` is completely opaque. The default is `0.7`. + * `:opacity` is any `t:Image.Pixel.opacity/0`: a + float in `0.0..1.0`, an integer in `0..255` as the + same value in 8-bit notation, or `:transparent` / + `:opaque`. The default is `0.7`. * `:rotation` is the number of degrees to rotate the axis of a generated n-sided polygon. This option is @@ -355,9 +358,10 @@ defmodule Image.Shape do * `:stroke_color` is the color used for the outline of the polygon. The default is `:white`. - * `:opacity` is the opacity as a float between - `0.0` and `1.0` where `0.0` is completely transparent - and `1.0` is completely opaque. The default is `0.7`. + * `:opacity` is any `t:Image.Pixel.opacity/0`: a + float in `0.0..1.0`, an integer in `0..255` as the + same value in 8-bit notation, or `:transparent` / + `:opaque`. The default is `0.7`. ### Notes @@ -524,9 +528,10 @@ defmodule Image.Shape do * `:stroke_color` is the color used for the outline of the circle. The default is `:white`. - * `:opacity` is the opacity as a float between - `0.0` and `1.0` where `0.0` is completely transparent - and `1.0` is completely opaque. The default is `0.7`. + * `:opacity` is any `t:Image.Pixel.opacity/0`: a + float in `0.0..1.0`, an integer in `0..255` as the + same value in 8-bit notation, or `:transparent` / + `:opaque`. The default is `0.7`. ### Returns @@ -588,9 +593,10 @@ defmodule Image.Shape do * `:stroke_color` is the color used for the outline of the circle. The default is `:white`. - * `:opacity` is the opacity as a float between - `0.0` and `1.0` where `0.0` is completely transparent - and `1.0` is completely opaque. The default is `0.7`. + * `:opacity` is any `t:Image.Pixel.opacity/0`: a + float in `0.0..1.0`, an integer in `0..255` as the + same value in 8-bit notation, or `:transparent` / + `:opaque`. The default is `0.7`. ### Returns @@ -637,9 +643,10 @@ defmodule Image.Shape do * `:stroke_color` is the color used for the outline of the ellipse. The default is `:white`. - * `:opacity` is the opacity as a float between - `0.0` and `1.0` where `0.0` is completely transparent - and `1.0` is completely opaque. The default is `0.7`. + * `:opacity` is any `t:Image.Pixel.opacity/0`: a + float in `0.0..1.0`, an integer in `0..255` as the + same value in 8-bit notation, or `:transparent` / + `:opaque`. The default is `0.7`. ### Returns @@ -706,9 +713,10 @@ defmodule Image.Shape do * `:stroke_color` is the color used for the outline of the polygon. The default is `:white` - * `:opacity` is the opacity as a float between - `0.0` and `1.0` where `0.0` is completely transparent - and `1.0` is completely opaque. The default is `0.7`. + * `:opacity` is any `t:Image.Pixel.opacity/0`: a + float in `0.0..1.0`, an integer in `0..255` as the + same value in 8-bit notation, or `:transparent` / + `:opaque`. The default is `0.7`. ### Returns @@ -761,9 +769,10 @@ defmodule Image.Shape do * `:stroke_color` is the color used for the outline of the line. The default is `:white`. - * `:opacity` is the opacity as a float between - `0.0` and `1.0` where `0.0` is completely transparent - and `1.0` is completely opaque. The default is `0.7`. + * `:opacity` is any `t:Image.Pixel.opacity/0`: a + float in `0.0..1.0`, an integer in `0..255` as the + same value in 8-bit notation, or `:transparent` / + `:opaque`. The default is `0.7`. ### Returns @@ -839,9 +848,10 @@ defmodule Image.Shape do * `:stroke_color` is the color used for the outline of the polygon. The default is `:white` - * `:opacity` is the opacity as a float between - `0.0` and `1.0` where `0.0` is completely transparent - and `1.0` is completely opaque. The default is `0.7`. + * `:opacity` is any `t:Image.Pixel.opacity/0`: a + float in `0.0..1.0`, an integer in `0..255` as the + same value in 8-bit notation, or `:transparent` / + `:opaque`. The default is `0.7`. ### Returns diff --git a/lib/image/text.ex b/lib/image/text.ex index e339a250..8d0c7c58 100644 --- a/lib/image/text.ex +++ b/lib/image/text.ex @@ -84,9 +84,10 @@ defmodule Image.Text do a background. A black background will be forced if a `:background_fill_color` is not provided - * `:background_fill_opacity` is the opacity of the background fill. - It is a float between `0.0` and `1.0` where `0.0` means transparent - and `1.0` means opaque. The default is `0.7`. + * `:background_fill_opacity` is the opacity of the background fill, as + any `t:Image.Pixel.opacity/0`: a float in `0.0..1.0`, an integer in + `0..255` as the same value in 8-bit notation, or `:transparent` / + `:opaque`. The default is `0.7`. * `:padding` is the padding in pixels between the text and the edge of the background. It can be expressed @@ -118,8 +119,9 @@ defmodule Image.Text do of the background border. The default is `1`. * `:background_stroke_opacity` is the opacity of the background - string. It is a float between `0.0` and `1.0` where `0.0` means - transparent and `1.0` means opaque. The default is `0.7`. + string, as any `t:Image.Pixel.opacity/0`: a float in `0.0..1.0`, an + integer in `0..255` as the same value in 8-bit notation, or + `:transparent` / `:opaque`. The default is `0.7`. * `:width` is the maximum width of the generated text image in pixels. The default is calculated by the rendering engine based upon the font @@ -259,9 +261,10 @@ defmodule Image.Text do a background. A black background will be forced if a `:background_fill_color` is not provided. - * `:background_fill_opacity` is the opacity of the background fill. - It is a float between `0.0` and `1.0` where `0.0` means transparent - and `1.0` means opaque. The default is `0.7`. + * `:background_fill_opacity` is the opacity of the background fill, as + any `t:Image.Pixel.opacity/0`: a float in `0.0..1.0`, an integer in + `0..255` as the same value in 8-bit notation, or `:transparent` / + `:opaque`. The default is `0.7`. * `:padding` is the padding in pixels between the text and the edge of the background. It can be expressed @@ -293,8 +296,9 @@ defmodule Image.Text do of the background border. The default is `1`. * `:background_stroke_opacity` is the opacity of the background - string. It is a float between `0.0` and `1.0` where `0.0` means - transparent and `1.0` means opaque. The default is `0.7`. + string, as any `t:Image.Pixel.opacity/0`: a float in `0.0..1.0`, an + integer in `0..255` as the same value in 8-bit notation, or + `:transparent` / `:opaque`. The default is `0.7`. * `:width` is the maximum width of the generated text image in pixels. The default is calculated by the rendering engine based upon the font @@ -548,9 +552,10 @@ defmodule Image.Text do the text. The default is `:none` which indicates no background. Note that if - * `:background_fill_opacity` is the opacity of the background. It is a - float between `0.0` and `1.0` where `0.0` means transparent - and `1.0` means opaque. The default is `0.7`. + * `:background_fill_opacity` is the opacity of the background, as any + `t:Image.Pixel.opacity/0`: a float in `0.0..1.0`, an integer in + `0..255` as the same value in 8-bit notation, or `:transparent` / + `:opaque`. The default is `0.7`. ### Returns @@ -619,9 +624,10 @@ defmodule Image.Text do the text. The default is `:none` which indicates no background. - * `:background_fill_opacity` is the opacity of the background. It is a - float between `0.0` and `1.0` where `0.0` means transparent - and `1.0` means opaque. The default is `0.7`. + * `:background_fill_opacity` is the opacity of the background, as any + `t:Image.Pixel.opacity/0`: a float in `0.0..1.0`, an integer in + `0..255` as the same value in 8-bit notation, or `:transparent` / + `:opaque`. The default is `0.7`. ### Returns @@ -669,9 +675,10 @@ defmodule Image.Text do the text. The default is `:none` which indicates no background. - * `:background_fill_opacity` is the opacity of the background. It is a - float between `0.0` and `1.0` where `0.0` means transparent - and `1.0` means opaque. The default is `0.7`. + * `:background_fill_opacity` is the opacity of the background, as any + `t:Image.Pixel.opacity/0`: a float in `0.0..1.0`, an integer in + `0..255` as the same value in 8-bit notation, or `:transparent` / + `:opaque`. The default is `0.7`. * `:padding` is the padding in pixels between the text and the edge of the background. It can be expressed @@ -774,9 +781,10 @@ defmodule Image.Text do the text. The default is `:none` which indicates no background. - * `:background_fill_opacity` is the opacity of the background. It is a - float between `0.0` and `1.0` where `0.0` means transparent - and `1.0` means opaque. The default is `0.7`. + * `:background_fill_opacity` is the opacity of the background, as any + `t:Image.Pixel.opacity/0`: a float in `0.0..1.0`, an integer in + `0..255` as the same value in 8-bit notation, or `:transparent` / + `:opaque`. The default is `0.7`. * `:padding` is the padding in pixels between the text and the edge of the background. It can be expressed @@ -848,8 +856,9 @@ defmodule Image.Text do of the background border. The default is `1`. * `:background_stroke_opacity` is the opacity of the background - string. It is a float between `0.0` and `1.0` where `0.0` means - transparent and `1.0` means opaque. The default is `0.7`. + string, as any `t:Image.Pixel.opacity/0`: a float in `0.0..1.0`, an + integer in `0..255` as the same value in 8-bit notation, or + `:transparent` / `:opaque`. The default is `0.7`. ### Returns @@ -916,8 +925,9 @@ defmodule Image.Text do of the background border. The default is `1`. * `:background_stroke_opacity` is the opacity of the background - string. It is a float between `0.0` and `1.0` where `0.0` means - transparent and `1.0` means opaque. The default is `0.7`. + string, as any `t:Image.Pixel.opacity/0`: a float in `0.0..1.0`, an + integer in `0..255` as the same value in 8-bit notation, or + `:transparent` / `:opaque`. The default is `0.7`. ### Returns diff --git a/mix/for_dialyzer.ex b/mix/for_dialyzer.ex index 89674575..1daa746c 100644 --- a/mix/for_dialyzer.ex +++ b/mix/for_dialyzer.ex @@ -20,8 +20,8 @@ defmodule ForDialyzer do {width, height, _bands} = Image.shape(penguin) {:ok, _embed_1} = Image.embed(penguin, width, height + 50, background: :black) - {:ok, _embed_1} = Image.embed(penguin, width, height + 50, x: 0, y: 0, background: {:black, alpha: :transparent}) - {:ok, _embed_1} = Image.embed(penguin, width, height + 50, x: 0, y: 0, background: {:black, alpha: :opaque}) + {:ok, _embed_1} = Image.embed(penguin, width, height + 50, x: 0, y: 0, background: {:black, opacity: :transparent}) + {:ok, _embed_1} = Image.embed(penguin, width, height + 50, x: 0, y: 0, background: {:black, opacity: :opaque}) {:ok, _embed_1} = Image.embed(penguin, width, height + 50, x: 0, y: 0, extend_mode: :mirror) {:ok, _embed_1} = Image.embed(penguin, width, height + 50, x: 0, y: 0, extend_mode: :background, background: :green) diff --git a/test/background_color_test.exs b/test/background_color_test.exs index eb9d594a..509e395f 100644 --- a/test/background_color_test.exs +++ b/test/background_color_test.exs @@ -106,72 +106,72 @@ defmodule Image.BackgroundColorTest do end end - describe "resolve/2 with a {color, alpha: a} spec" do - test "applies the alpha to the color on an image with alpha" do + describe "resolve/2 with a {color, opacity: o} spec" do + test "applies the opacity to the color on an image with alpha" do image = solid([0, 0, 0, 255]) - assert BackgroundColor.resolve(image, {:red, alpha: 0.5}) == {:ok, [255, 0, 0, 128]} + assert BackgroundColor.resolve(image, {:red, opacity: 0.5}) == {:ok, [255, 0, 0, 128]} end - test "resolves :average first, then applies the alpha, on an image with alpha" do + test "resolves :average first, then applies the opacity, on an image with alpha" do image = solid([10, 20, 30, 255]) - assert BackgroundColor.resolve(image, {:average, alpha: 0.5}) == {:ok, [10, 20, 30, 128]} + assert BackgroundColor.resolve(image, {:average, opacity: 0.5}) == {:ok, [10, 20, 30, 128]} end test "supports fully-transparent via the :transparent atom" do image = solid([0, 0, 0, 255]) - assert BackgroundColor.resolve(image, {:black, alpha: :transparent}) == {:ok, [0, 0, 0, 0]} + assert BackgroundColor.resolve(image, {:black, opacity: :transparent}) == {:ok, [0, 0, 0, 0]} end - test "strips the alpha on an image without an alpha band (color)" do + test "strips the opacity on an image without an alpha band (color)" do image = solid([0, 0, 0]) - assert BackgroundColor.resolve(image, {:red, alpha: 0.5}) == {:ok, [255, 0, 0]} + assert BackgroundColor.resolve(image, {:red, opacity: 0.5}) == {:ok, [255, 0, 0]} end - test "strips the alpha on an image without an alpha band (:average)" do + test "strips the opacity on an image without an alpha band (:average)" do image = solid([10, 20, 30]) - assert BackgroundColor.resolve(image, {:average, alpha: 0.5}) == {:ok, [10, 20, 30]} + assert BackgroundColor.resolve(image, {:average, opacity: 0.5}) == {:ok, [10, 20, 30]} end test "wraps an invalid color in an Image.Error" do image = solid([0, 0, 0, 255]) assert {:error, %Image.Error{} = error} = - BackgroundColor.resolve(image, {:definitely_not_a_color, alpha: 0.5}) + BackgroundColor.resolve(image, {:definitely_not_a_color, opacity: 0.5}) assert error.message =~ "Invalid background color :definitely_not_a_color" end - test "reports an invalid alpha value as an alpha error, not a color error" do + test "reports an invalid opacity as an opacity error, not a color error" do image = solid([0, 0, 0, 255]) - assert {:error, %Image.Error{} = error} = BackgroundColor.resolve(image, {:red, alpha: 5.0}) - assert error.message =~ "Invalid alpha 5.0" + assert {:error, %Image.Error{reason: :invalid_opacity, value: 5.0}} = + BackgroundColor.resolve(image, {:red, opacity: 5.0}) end - test "validates the alpha even on an image without an alpha band" do + test "validates the opacity even on an image without an alpha band" do # The alpha would be dropped, but an invalid value still errors so that # validity does not depend on the image's band layout. image = solid([0, 0, 0]) - assert {:error, %Image.Error{} = error} = BackgroundColor.resolve(image, {:red, alpha: 5.0}) - assert error.message =~ "Invalid alpha 5.0" + assert {:error, %Image.Error{reason: :invalid_opacity, value: 5.0}} = + BackgroundColor.resolve(image, {:red, opacity: 5.0}) end - test "a misspelled or missing :alpha key is an error, not a raise" do + test "a misspelled or missing :opacity key is an error, not a raise" do image = solid([0, 0, 0, 255]) assert {:error, %Image.Error{} = error} = - BackgroundColor.resolve(image, {:red, opacity: 0.5}) + BackgroundColor.resolve(image, {:red, transparency: 0.5}) - assert error.message =~ "expected {color, alpha: transparency}" + assert error.message =~ "expected {color, opacity: opacity}" assert {:error, %Image.Error{}} = BackgroundColor.resolve(image, {:red, []}) end - test "extra keys alongside :alpha are rejected" do + test "extra keys alongside :opacity are rejected" do image = solid([0, 0, 0, 255]) assert {:error, %Image.Error{}} = - BackgroundColor.resolve(image, {:red, [alpha: 0.5, bogus: 1]}) + BackgroundColor.resolve(image, {:red, [opacity: 0.5, bogus: 1]}) end end @@ -185,13 +185,11 @@ defmodule Image.BackgroundColorTest do assert error.message =~ "Invalid background color :definitely_not_a_color" end - test "preserves the underlying reason from Image.Pixel" do + test "reports an invalid color with a structured reason" do image = solid([0, 0, 0]) - assert {:error, %Image.Error{reason: reason}} = + assert {:error, %Image.Error{reason: :invalid_color, value: "not-a-hex"}} = BackgroundColor.resolve(image, "not-a-hex") - - assert reason == %Color.UnknownColorNameError{name: "not-a-hex"} end end end diff --git a/test/distortion_test.exs b/test/distortion_test.exs index f181e889..6dea1a1b 100644 --- a/test/distortion_test.exs +++ b/test/distortion_test.exs @@ -2,7 +2,7 @@ defmodule Image.Distortion.Test do use ExUnit.Case, async: true import Image.TestSupport - @background {[10, 20, 30], alpha: 40} + @background {[10, 20, 30], opacity: 40} test "Image.distort/3" do image_file = "koala.gif" diff --git a/test/embed_test.exs b/test/embed_test.exs index 14d6ea29..209fa679 100644 --- a/test/embed_test.exs +++ b/test/embed_test.exs @@ -5,7 +5,7 @@ defmodule Image.Embed.Test do test "Image.embed/4 with the default background gives a transparent border on an alpha image" do # The default `background: :transparent` fills the border. On an alpha image # that is transparent black, the same output as the explicit - # `background: {:black, alpha: :transparent}`. + # `background: {:black, opacity: :transparent}`. image_file = "penguin_with_alpha.png" validate_file = "embed/penguin_with_alpha_embed_black_transparent.png" @@ -35,7 +35,7 @@ defmodule Image.Embed.Test do assert_images_equal(embedded, validate_path) end - test "Image.embed/4 with background: {:black, alpha: :transparent}" do + test "Image.embed/4 with background: {:black, opacity: :transparent}" do image_file = "penguin_with_alpha.png" validate_file = "embed/penguin_with_alpha_embed_black_transparent.png" @@ -49,7 +49,7 @@ defmodule Image.Embed.Test do Image.embed(image, width, height + 50, x: 0, y: 0, - background: {:black, alpha: :transparent} + background: {:black, opacity: :transparent} ) # {:ok, _image} = Image.write(embedded, validate_path) diff --git a/test/group_b_test.exs b/test/group_b_test.exs index 991d8c04..42ea144a 100644 --- a/test/group_b_test.exs +++ b/test/group_b_test.exs @@ -131,6 +131,13 @@ defmodule Image.GroupB.Test do Image.drop_shadow(image, opacity: -0.1) end + test "accepts an 8-bit or atom :opacity" do + image = Image.new!(20, 20, color: :red) + + assert {:ok, %Vix.Vips.Image{}} = Image.drop_shadow(image, opacity: 128) + assert {:ok, %Vix.Vips.Image{}} = Image.drop_shadow(image, opacity: :opaque) + end + test "rejects non-positive :sigma", %{cat: image} do assert {:error, %Image.Error{reason: :invalid_option, value: {:sigma, 0}}} = Image.drop_shadow(image, sigma: 0) diff --git a/test/image_adjustments_coverage_test.exs b/test/image_adjustments_coverage_test.exs index a9694806..532eda40 100644 --- a/test/image_adjustments_coverage_test.exs +++ b/test/image_adjustments_coverage_test.exs @@ -309,13 +309,49 @@ defmodule Image.AdjustmentsCoverageTest do assert Image.get_pixel!(with_alpha, 5, 5) == [10, 20, 30, 0] end - test "adds an alpha band with an integer transparency" do + test "adds an alpha band with an integer opacity" do image = Image.new!(10, 10, color: [10, 20, 30]) assert {:ok, with_alpha} = Image.add_alpha(image, 128) assert Image.get_pixel!(with_alpha, 5, 5) == [10, 20, 30, 128] end + test "scales an integer opacity to a 16-bit alpha band" do + image = Image.to_colorspace!(Image.new!(10, 10, color: [10, 20, 30]), :rgb16) + + assert {:ok, with_alpha} = Image.add_alpha(image, 128) + assert List.last(Image.get_pixel!(with_alpha, 5, 5)) == 32_896 + end + + test "scales an integer opacity to an scRGB alpha band" do + image = Image.to_colorspace!(Image.new!(10, 10, color: [10, 20, 30]), :scrgb) + + assert {:ok, with_alpha} = Image.add_alpha(image, 128) + assert_in_delta List.last(Image.get_pixel!(with_alpha, 5, 5)), 0.50196, 1.0e-5 + end + + test "an opaque alpha band is fully opaque in every interpretation" do + for {colorspace, opaque} <- [srgb: 255, rgb16: 65_535, grey16: 65_535, scrgb: 1.0] do + image = Image.to_colorspace!(Image.new!(10, 10, color: [10, 20, 30]), colorspace) + + assert {:ok, with_alpha} = Image.add_alpha(image, :opaque) + + assert List.last(Image.get_pixel!(with_alpha, 5, 5)) == opaque, + "#{colorspace} opaque alpha was #{List.last(Image.get_pixel!(with_alpha, 5, 5))}" + end + end + + test "adds an alpha band with a float opacity" do + for {colorspace, half} <- [srgb: 128, rgb16: 32_768, scrgb: 0.5] do + image = Image.to_colorspace!(Image.new!(10, 10, color: [10, 20, 30]), colorspace) + + assert {:ok, with_alpha} = Image.add_alpha(image, 0.5) + + assert List.last(Image.get_pixel!(with_alpha, 5, 5)) == half, + "#{colorspace} half alpha was #{List.last(Image.get_pixel!(with_alpha, 5, 5))}" + end + end + test "adds an alpha band from a single-band image" do image = Image.new!(10, 10, color: [10, 20, 30]) alpha = Image.new!(10, 10, color: 200, bands: 1) diff --git a/test/image_test.exs b/test/image_test.exs index 0da00101..a29bba40 100644 --- a/test/image_test.exs +++ b/test/image_test.exs @@ -4,7 +4,7 @@ defmodule Image.Test do alias Vix.Vips.Operation alias Vix.Vips.Image, as: Vimage - @mapim_background {[10, 20, 30], alpha: 40} + @mapim_background {[10, 20, 30], opacity: 40} doctest Image doctest Image.BandFormat diff --git a/test/pixel_color_gap_test.exs b/test/pixel_color_gap_test.exs index bd00babe..4cadbe81 100644 --- a/test/pixel_color_gap_test.exs +++ b/test/pixel_color_gap_test.exs @@ -21,8 +21,8 @@ defmodule Image.PixelColorGapTest do image = Image.new!(2, 2, color: :black) {:ok, yxy} = Operation.copy(image, interpretation: :VIPS_INTERPRETATION_YXY) - assert {:error, message} = Pixel.to_pixel(yxy, [1, 2, 3]) - assert message =~ ":yxy interpretation" + assert {:error, %Image.Error{reason: :unsupported_interpretation, value: :yxy}} = + Pixel.to_pixel(yxy, [1, 2, 3]) end test "a non-pre-encoded float list on a mutable image is converted" do @@ -84,11 +84,6 @@ defmodule Image.PixelColorGapTest do end end - test "max_opacity and min_opacity" do - assert Pixel.max_opacity() == 255 - assert Pixel.min_opacity() == 0 - end - test "out of gamut colors are clamped to the channel range" do assert Pixel.to_srgb(%Color.Lab{l: 150.0, a: 0.0, b: 0.0}) == {:ok, [255, 255, 255]} assert Pixel.to_srgb(%Color.Lab{l: -10.0, a: 0.0, b: 0.0}) == {:ok, [0, 0, 0]} @@ -126,10 +121,7 @@ defmodule Image.PixelColorGapTest do alpha_image = Image.new!(2, 2, color: [10, 20, 30, 255]) {:ok, yxy_alpha} = Operation.copy(alpha_image, interpretation: :VIPS_INTERPRETATION_YXY) - assert {:error, %Image.Error{message: message}} = - Image.BackgroundColor.resolve(yxy_alpha, :average) - - assert message =~ "Could not construct alpha" + assert {:error, %Image.Error{}} = Image.BackgroundColor.resolve(yxy_alpha, :average) end test "resolving an invalid color errors" do diff --git a/test/pixel_test.exs b/test/pixel_test.exs index c8d66e0e..65074f19 100644 --- a/test/pixel_test.exs +++ b/test/pixel_test.exs @@ -52,7 +52,7 @@ defmodule Image.PixelTest do assert {:ok, [255, 0, 0]} = Pixel.to_pixel(image, "#ff000080") end - test "transparency aliases collapse to black", %{image: image} do + test "the color atoms collapse to black", %{image: image} do assert {:ok, [0, 0, 0]} = Pixel.to_pixel(image, :transparent) assert {:ok, [0, 0, 0]} = Pixel.to_pixel(image, :none) assert {:ok, [0, 0, 0]} = Pixel.to_pixel(image, :opaque) @@ -69,11 +69,11 @@ defmodule Image.PixelTest do assert {:ok, [255, 0, 0, 255]} = Pixel.to_pixel(image, :red) end - test "explicit :alpha option overrides", %{image: image} do - assert {:ok, [255, 0, 0, 128]} = Pixel.to_pixel(image, :red, alpha: 0.5) - assert {:ok, [255, 0, 0, 0]} = Pixel.to_pixel(image, :red, alpha: :transparent) - assert {:ok, [255, 0, 0, 255]} = Pixel.to_pixel(image, :red, alpha: :opaque) - assert {:ok, [255, 0, 0, 100]} = Pixel.to_pixel(image, :red, alpha: 100) + test "explicit :opacity option overrides", %{image: image} do + assert {:ok, [255, 0, 0, 128]} = Pixel.to_pixel(image, :red, opacity: 0.5) + assert {:ok, [255, 0, 0, 0]} = Pixel.to_pixel(image, :red, opacity: :transparent) + assert {:ok, [255, 0, 0, 255]} = Pixel.to_pixel(image, :red, opacity: :opaque) + assert {:ok, [255, 0, 0, 100]} = Pixel.to_pixel(image, :red, opacity: 100) end test "hex with alpha is preserved", %{image: image} do @@ -92,6 +92,35 @@ defmodule Image.PixelTest do test "a list one band short gains the missing alpha", %{image: image} do assert {:ok, [255, 0, 0, 255]} = Pixel.to_pixel(image, [255, 0, 0]) end + + test ":opacity applies to a pre-encoded list color", %{image: image} do + assert {:ok, [255, 0, 0, 128]} = Pixel.to_pixel(image, [255, 0, 0, 255], opacity: 0.5) + end + + test ":opacity applies to a list color that needs resolving", %{image: image} do + assert {:ok, [255, 0, 0, 128]} = Pixel.to_pixel(image, [1.0, 0.0, 0.0], opacity: 0.5) + end + end + + describe "put_alpha/3" do + test "replaces the alpha component of a pixel" do + image = Image.new!(2, 2, color: [0, 0, 0, 255]) + + assert Pixel.put_alpha([255, 0, 0, 255], image, 0.5) == {:ok, [255, 0, 0, 128]} + assert Pixel.put_alpha([255, 0, 0, 255], image, :transparent) == {:ok, [255, 0, 0, 0]} + end + + test "scales the alpha to the image's band" do + image = Image.to_colorspace!(Image.new!(2, 2, color: [0, 0, 0, 255]), :rgb16) + + assert Pixel.put_alpha([65_535, 0, 0, 65_535], image, 0.5) == {:ok, [65_535, 0, 0, 32_768]} + end + + test "leaves a pixel alone when the image has no alpha band" do + image = Image.new!(2, 2, color: [0, 0, 0]) + + assert Pixel.put_alpha([255, 0, 0], image, 0.5) == {:ok, [255, 0, 0]} + end end describe "to_pixel/3 fits a list color to the image band count" do @@ -142,27 +171,36 @@ defmodule Image.PixelTest do test "opaque alpha resolves to 255 for every 0..255-scale interpretation" do for colorspace <- [:srgb, :cmyk, :hsv, :bw, :lab, :lch, :labs] do image = with_alpha(colorspace) - {:ok, pixel} = Pixel.to_pixel(image, :red, alpha: :opaque) + {:ok, pixel} = Pixel.to_pixel(image, :red, opacity: :opaque) assert List.last(pixel) == 255, "#{colorspace} opaque alpha was #{List.last(pixel)}" end end test "scRGB opaque alpha stays 1.0" do image = with_alpha(:scrgb) - assert {:ok, [_r, _g, _b, alpha]} = Pixel.to_pixel(image, :red, alpha: :opaque) + assert {:ok, [_r, _g, _b, alpha]} = Pixel.to_pixel(image, :red, opacity: :opaque) assert alpha == 1.0 end - test "a plain color (no explicit :alpha) also synthesizes opaque 255 on Lab" do + test "a plain color (no explicit :opacity) also synthesizes opaque 255 on Lab" do image = with_alpha(:lab) assert {:ok, [_l, _a, _b, 255]} = Pixel.to_pixel(image, :red) end test "alpha 0.5 resolves to 128 on Lab" do image = with_alpha(:lab) - assert {:ok, [_l, _a, _b, alpha]} = Pixel.to_pixel(image, :red, alpha: 0.5) + assert {:ok, [_l, _a, _b, alpha]} = Pixel.to_pixel(image, :red, opacity: 0.5) assert alpha == 128 end + + # Rounding a float to a byte first would put 0.5 at 128/255. + test "a float alpha scales directly to the encoder's alpha range" do + {:ok, scrgb} = Pixel.to_pixel(with_alpha(:scrgb), :red, opacity: 0.5) + assert List.last(scrgb) == 0.5 + + {:ok, grey16} = Pixel.to_pixel(with_alpha(:grey16), :red, opacity: 0.5) + assert List.last(grey16) == 32_768 + end end describe "to_pixel/3 against a CMYK image" do @@ -276,30 +314,55 @@ defmodule Image.PixelTest do end end - describe "transparency/1" do + describe "alpha_for/2 on an 8-bit image" do + defp srgb, do: Image.new!(2, 2, color: [0, 0, 0, 255]) + test "atoms" do - assert {:ok, 0} = Pixel.transparency(:none) - assert {:ok, 0} = Pixel.transparency(:transparent) - assert {:ok, 255} = Pixel.transparency(:opaque) + assert {:ok, 0} = Pixel.alpha_for(srgb(), :transparent) + assert {:ok, 255} = Pixel.alpha_for(srgb(), :opaque) end test "integers" do - assert {:ok, 0} = Pixel.transparency(0) - assert {:ok, 128} = Pixel.transparency(128) - assert {:ok, 255} = Pixel.transparency(255) + assert {:ok, 0} = Pixel.alpha_for(srgb(), 0) + assert {:ok, 128} = Pixel.alpha_for(srgb(), 128) + assert {:ok, 255} = Pixel.alpha_for(srgb(), 255) end test "floats" do - assert {:ok, 0} = Pixel.transparency(0.0) - assert {:ok, 128} = Pixel.transparency(0.5) - assert {:ok, 255} = Pixel.transparency(1.0) + assert {:ok, 0} = Pixel.alpha_for(srgb(), 0.0) + assert {:ok, 128} = Pixel.alpha_for(srgb(), 0.5) + assert {:ok, 255} = Pixel.alpha_for(srgb(), 1.0) end test "out of range" do - assert {:error, _} = Pixel.transparency(-1) - assert {:error, _} = Pixel.transparency(256) - assert {:error, _} = Pixel.transparency(2.0) - assert {:error, _} = Pixel.transparency(:blue) + assert {:error, _} = Pixel.alpha_for(srgb(), -1) + assert {:error, _} = Pixel.alpha_for(srgb(), 256) + assert {:error, _} = Pixel.alpha_for(srgb(), 2.0) + assert {:error, _} = Pixel.alpha_for(srgb(), :blue) + end + end + + describe "alpha_for/2 on other interpretations" do + test "a 16-bit image scales to 0..65535" do + image = with_alpha(:rgb16) + + assert {:ok, 0} = Pixel.alpha_for(image, :transparent) + assert {:ok, 65_535} = Pixel.alpha_for(image, :opaque) + assert {:ok, 32_768} = Pixel.alpha_for(image, 0.5) + assert {:ok, 32_896} = Pixel.alpha_for(image, 128) + end + + test "an scRGB image scales to 0.0..1.0" do + image = with_alpha(:scrgb) + + assert Pixel.alpha_for(image, :transparent) == {:ok, 0.0} + assert Pixel.alpha_for(image, :opaque) == {:ok, 1.0} + assert Pixel.alpha_for(image, 0.5) == {:ok, 0.5} + end + + test "Lab and LCH keep a 0..255 alpha despite their float color bands" do + assert {:ok, 255} = Pixel.alpha_for(with_alpha(:lab), :opaque) + assert {:ok, 128} = Pixel.alpha_for(with_alpha(:lch), 0.5) end end end diff --git a/test/shape_coverage_test.exs b/test/shape_coverage_test.exs index 9aea988f..646af881 100644 --- a/test/shape_coverage_test.exs +++ b/test/shape_coverage_test.exs @@ -70,6 +70,14 @@ defmodule Image.ShapeCoverage.Test do assert message == "Invalid value \"abc\" in polygon points string" end + test "accepts an 8-bit or atom :opacity, normalizing it to a float" do + assert {:ok, opts} = Image.Options.Shape.validate_polygon_options(opacity: 128) + assert_in_delta opts[:opacity], 128 / 255, 1.0e-6 + + assert {:ok, opts} = Image.Options.Shape.validate_polygon_options(opacity: :opaque) + assert opts[:opacity] == 1.0 + end + test "returns an error for an invalid opacity" do assert {:error, %Image.Error{message: "Invalid option or option value: opacity: 3.0"}} = Shape.polygon([[0, 0], [10, 0], [5, 8]], opacity: 3.0)