From 9068de1441f40bdbef5ead63ddf5b9eb07f044ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5vard=20Lindset?= Date: Tue, 7 Jul 2026 23:34:58 +0200 Subject: [PATCH] Unify background handling under a single :background option --- lib/image.ex | 282 ++++++++++-------- lib/image/background_color.ex | 91 ++++-- lib/image/options/affine.ex | 67 +++-- lib/image/options/embed.ex | 198 +++++------- lib/image/options/join.ex | 27 +- lib/image/options/rotate.ex | 13 +- lib/image/options/warp_perspective.ex | 61 ++-- lib/image/options/write.ex | 6 +- mix/for_dialyzer.ex | 9 +- test/affine_test.exs | 72 +++-- test/background_color_test.exs | 69 +++++ test/coverage_wave3_options_test.exs | 63 +++- test/embed_test.exs | 37 ++- test/flatten_test.exs | 2 +- test/image_adjustments_coverage_test.exs | 29 +- test/image_geometry_coverage_test.exs | 6 +- test/join_test.exs | 11 + test/options_geometry_test.exs | 8 +- test/rotate_test.exs | 15 +- .../validate/join/rainbow_green_shim.png | Bin 0 -> 1185 bytes 20 files changed, 656 insertions(+), 410 deletions(-) create mode 100644 test/support/validate/join/rainbow_green_shim.png diff --git a/lib/image.ex b/lib/image.ex index 63648361..66e1236b 100644 --- a/lib/image.ex +++ b/lib/image.ex @@ -3716,14 +3716,18 @@ defmodule Image do * `:horizontal_align` is `:left`, `:center` or `:right`. The default is `:left`. - * `:background_color` is the color of any pixels generated + * `:background` is the color that fills any pixels generated between images. This can be specified as a single integer - which will be applied to all bands, or a list of - integers representing the color for each - band. The default is `0`, meaning black. The color - can also be supplied as a CSS color name as a - string or atom. For example: `:misty_rose`. See - `Color.new/2` from the Color library. + which will 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 transparent or semi-transparent fill. + See `Image.Pixel.to_pixel/2` for the full range of accepted + color forms. + + If omitted, `libvips`' native all-zeros fill is used: + transparent for images with an alpha band, black otherwise. * `:shim` is the number of pixels of spacing between images in the grid. The default is `0`. @@ -3745,8 +3749,9 @@ defmodule Image do by `:vertical_spacing` pixels and cropped. These default to the largest width and largest height of the input images. - Space between images is filled with `:background_color` . - This defaults to black. + Space between images is filled with `:background`, which defaults + to `:transparent` (transparent for images with an alpha band, + black otherwise). Images are positioned within their `:horizontal_spacing` by `:vertical_spacing` box at `:vertical_align` of `:bottom`, @@ -3814,14 +3819,18 @@ defmodule Image do * `:horizontal_align` is `:left`, `:center` or `:right`. The default is `:left`. - * `:background_color` is the color of any pixels generated + * `:background` is the color that fills any pixels generated between images. This can be specified as a single integer - which will be applied to all bands, or a list of - integers representing the color for each - band. The default is `0`, meaning black. The color - can also be supplied as a CSS color name as a - string or atom. For example: `:misty_rose`. See - `Color.new/2` from the Color library. + which will 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 transparent or semi-transparent fill. + See `Image.Pixel.to_pixel/2` for the full range of accepted + color forms. + + If omitted, `libvips`' native all-zeros fill is used: + transparent for images with an alpha band, black otherwise. * `:shim` is the number of pixels of spacing between images in the grid. The default is `0`. @@ -3843,8 +3852,9 @@ defmodule Image do by `:vertical_spacing` pixels and cropped. These default to the largest width and largest height of the input images. - Space between images is filled with `:background_color` . - This defaults to black. + Space between images is filled with `:background`, which defaults + to `:transparent` (transparent for images with an alpha band, + black otherwise). Images are positioned within their `:horizontal_spacing` by `:vertical_spacing` box at `:vertical_align` of `:bottom`, @@ -4533,7 +4543,7 @@ defmodule Image do ### Example iex> red_dot = Image.new!(1, 1, color: [255, 0, 0]) - iex> {:ok, image} = Image.embed(red_dot, 3, 1, background_color: [0, 0, 255], x: 0, y: 0) + iex> {:ok, image} = Image.embed(red_dot, 3, 1, background: [0, 0, 255], x: 0, y: 0) iex> {:ok, flipped} = Image.flip(image, :horizontal) iex> Image.get_pixel(flipped, 2, 0) {:ok, [255, 0, 0]} @@ -5619,41 +5629,41 @@ defmodule Image do It may also be `:center` (the default) in which case the image will be centered vertically within the canvas. - * `:background_color` defines the color of the generated background + * `:background` defines the color that fills the generated border pixels. This can be specified as a single integer which will 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`. - It can also be supplied as a hex string of - the form `#rrggbb`. The default is `:black`. `:background` can - also be set to `:average` in which case the background will be - the average color of the base image. See also `Color.new/2` - from the Color library. - - * `:background_transparency` defines the transparency of the - `:background` pixels when `image` has an alpha band. - The default is `:opaque`. The values are an integer in the - range `0..255` where `0` is transparent and `255` is opaque. - The number can also be a float in the range `0.0` to `1.0`. - In this case the float is converted to an integer in the range - `0..255`. Lastly, the atoms `:transparent` and `:opaque` can - also be used. - - * `:extend_mode` determines how any additional pixels - are generated. The values are: - - * `:black` (the default if no `background_color` is specified) - meaning the generated pixels are black. - * `:white` meaning the generated pixels are white. + CSS color name as a string or atom (for example `:misty_rose`), + a hex string (`#rrggbb`, or `#rrggbbaa` to include an alpha), or + `:average` for the average color of the base image. See + `Image.Pixel.to_pixel/2` for the full range of accepted color + forms. + + If omitted, `libvips`' native all-zeros fill is used: a + transparent border on images with an alpha band, black + 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}`. + + * `:extend_mode` synthesizes the generated border from the *image + content* instead of a `:background` color. A content mode consumes + no color, so combining one with an explicit `:background` is + contradictory and returns an error (a `nil` background counts as + unset and does not conflict). The values are: + * `:copy` means the generated pixels take the value of the nearest edge pixel of the base image. * `:repeat` means the generated pixels are tiles from the base image. * `:mirror` means the generated pixels are reflected tiles of the base image. - * `:background` means the generated pixels are the `:background_color` - color set in `options`. This is the default if a `background_color` - is specified. + * `:background` explicitly selects the background color fill, + which is also the default behavior when no `:extend_mode` is + given. The color comes from the `:background` option. ### Returns @@ -5664,7 +5674,7 @@ defmodule Image do ### Example iex> image = Image.new!(10, 10, color: [255, 0, 0]) - iex> {:ok, embedded} = Image.embed(image, 20, 20, background_color: [0, 0, 255]) + iex> {:ok, embedded} = Image.embed(image, 20, 20, background: [0, 0, 255]) iex> Image.shape(embedded) {20, 20, 3} iex> Image.get_pixel(embedded, 0, 0) @@ -5683,11 +5693,18 @@ defmodule Image do def embed(image, width, height, options \\ []) do with {:ok, options} <- Options.Embed.validate_options(image, width, height, options) do - embed_options = [background: options.background_color, extend: options.extend_mode] + embed_options = embed_options(options) Operation.embed(image, options.x, options.y, width, height, embed_options) end end + # With no :background, none is passed and libvips uses its native fill. + defp embed_options(%{background: background, extend_mode: extend}), + do: [background: background, extend: extend] + + defp embed_options(%{extend_mode: extend}), + do: [extend: extend] + @doc """ Embeds an image in a larger image canvas, generating addition border pixels if required. @@ -5717,7 +5734,7 @@ defmodule Image do ### Example iex> image = Image.new!(10, 10, color: [255, 0, 0]) - iex> embedded = Image.embed!(image, 20, 20, background_color: [0, 0, 255]) + iex> embedded = Image.embed!(image, 20, 20, background: [0, 0, 255]) iex> Image.get_pixel(embedded, 0, 0) {:ok, [0, 0, 255]} @@ -6087,7 +6104,7 @@ defmodule Image do ### Example iex> square = Image.new!(10, 10, color: :white) - iex> {:ok, image} = Image.embed(square, 40, 40, background_color: :black) + iex> {:ok, image} = Image.embed(square, 40, 40, background: :black) iex> Image.find_trim(image) {:ok, {15, 15, 10, 10}} @@ -6208,7 +6225,7 @@ defmodule Image do ### Example iex> square = Image.new!(10, 10, color: :white) - iex> {:ok, image} = Image.embed(square, 40, 40, background_color: :black) + iex> {:ok, image} = Image.embed(square, 40, 40, background: :black) iex> Image.find_trim!(image) {15, 15, 10, 10} @@ -6236,11 +6253,14 @@ defmodule Image do ### Options - * `:background_color` is an RGB color which is used - to fill the transparent parts of the image.. The color - can be an integer between `0..255`, a three-element list of - integers representing an RGB color or an atom - representing a CSS color name. The default is `:black`. + * `:background` is the pixel used when flattening the alpha. It can + be an integer `0..255`, a list of integers, a CSS color name as a + string or atom, a hex string, or `:average` (the image's average + color). If omitted, `libvips`' native all-zeros fill is used: + black in most colorspaces, paper-white in CMYK. This matches how + writing to a non-alpha format flattens. Pass `:black`, `:white` + or an explicit pixel to force a specific background. Any alpha + is ignored, the flattened background is always opaque. ### Returns @@ -6261,17 +6281,25 @@ defmodule Image do @spec flatten(image :: Vimage.t(), options :: Keyword.t()) :: {:ok, Vimage.t()} | {:error, error()} def flatten(%Vimage{} = image, options \\ []) do - background_color = Keyword.get(options, :background_color, :black) + background = Keyword.get(options, :background) - with {:ok, background_color} <- Pixel.to_pixel(image, background_color) do - if has_alpha?(image) do - # Flatten strips alpha and replaces it with the background; the - # background must therefore be the opaque colour part only. - bands = Vix.Vips.Image.bands(image) - 1 - Operation.flatten(image, background: Enum.take(background_color, bands)) - else + cond do + not has_alpha?(image) -> {:ok, image} - end + + is_nil(background) -> + # No (or nil) `:background`: defer to libvips' native all-zeros fill + # (black in most colorspaces, paper-white in CMYK), matching how + # writing to a non-alpha format flattens. + Operation.flatten(image) + + true -> + with {:ok, resolved} <- Image.BackgroundColor.resolve(image, background) do + # Flatten strips alpha and replaces it with the background, so the + # background must be the opaque color part only. + bands = Vix.Vips.Image.bands(image) - 1 + Operation.flatten(image, background: Enum.take(resolved, bands)) + end end end @@ -6287,11 +6315,14 @@ defmodule Image do ### Options - * `:background_color` is an RGB color which is used - to fill the transparent parts of the image.. The color - can be an integer between `0..255`, a three-element list of - integers representing an RGB color or an atom - representing a CSS color name. The default is `:black`. + * `:background` is the pixel used when flattening the alpha. It can + be an integer `0..255`, a list of integers, a CSS color name as a + string or atom, a hex string, or `:average` (the image's average + color). If omitted, `libvips`' native all-zeros fill is used: + black in most colorspaces, paper-white in CMYK. This matches how + writing to a non-alpha format flattens. Pass `:black`, `:white` + or an explicit pixel to force a specific background. Any alpha + is ignored, the flattened background is always opaque. ### Returns @@ -6302,7 +6333,7 @@ defmodule Image do ### Example iex> image = Image.new!(4, 4, color: [255, 0, 0, 0]) - iex> flattened = Image.flatten!(image, background_color: [0, 0, 255]) + iex> flattened = Image.flatten!(image, background: [0, 0, 255]) iex> Image.get_pixel(flattened, 0, 0) {:ok, [0, 0, 255]} @@ -6583,10 +6614,11 @@ defmodule Image do Can also be set to `:average` in which case the background will be the average color of the base image. - When omitted, `libvips` uses its native fill: transparent for - images with an alpha band, black otherwise. + If omitted, `libvips`' native all-zeros fill is used: + transparent for images with an alpha band, black otherwise. - See also `Image.Pixel.to_pixel/2`. + See `Image.Pixel.to_pixel/2` for the full range of accepted + color forms. ## Transparent backgrounds @@ -6649,8 +6681,10 @@ defmodule Image do Operation.rot(image, rot_angle) has_alpha?(image) -> - # `vips_rotate` doesn't expose the `:premultiplied` option, so alpha images - # run the equivalent affine through the premultiplied pipeline instead + # `vips_rotate` doesn't expose the `:premultiplied` option, so alpha + # images run the equivalent affine through the premultiplied pipeline. + # Rotate has no `:extend_mode`, so set the fringe extend explicitly. + options = Keyword.put(options, :extend, :VIPS_EXTEND_BACKGROUND) premultiplied_affine(image, rotation_matrix(angle), options) true -> @@ -11873,27 +11907,28 @@ defmodule Image do in which case the background will be the average color of the base image. - When omitted, `libvips` uses its native fill: transparent for - images with an alpha band, black otherwise. + If omitted, `libvips`' native all-zeros fill is used: + transparent for images with an alpha band, black otherwise. - See also `Image.Pixel.to_pixel/2`. + See `Image.Pixel.to_pixel/2` for the full range of accepted + color forms. - * `:extend_mode` controls how the interpolator synthesises the - thin band of pixels just beyond the source edge when resampling - boundary pixels. It is visible only for transforms that sample - between pixels (rotation, scaling, sub-pixel translation), as a - one-pixel fringe along the content edge. It does *not* fill the - blank canvas left uncovered by the transformation - that area is - always filled with `:background`. The values are: + * `:extend_mode` controls how the interpolator synthesizes the + one-pixel fringe just beyond the content edge when resampling + boundary pixels. It does *not* fill the canvas left uncovered by + the transformation: that area is always filled with + `:background`. The values are: - * `:background` (the default) means the synthesised pixels use - the `:background` color. - * `:black` means the synthesised pixels are black. - * `:white` means the synthesised pixels are white. - * `:copy` means the synthesised pixels copy the nearest edge - pixel of the base image. - * `:repeat` means the base image is tiled. - * `:mirror` means the base image is reflected. + * `:background` (the default) blends the fringe toward the + `:background` color. This is correct whenever the transform + exposes canvas (for example a rotation or a scale-down). + + * `:copy` clamps the fringe to the nearest content pixel. Use it + when the content fills the whole canvas (a pure scale up, a + sub-pixel translation, an `:output_area` inside the content), + where the default can leave a faint border along the outermost + pixels. For a pure scale, `Image.resize/3` avoids this + entirely. * `:output_area` is a four-element list of integers `[left, top, width, height]` selecting the rectangle of output space to @@ -12088,7 +12123,7 @@ defmodule Image do `:background` or `:extend_mode`) is supported. Because the canvas is not resized, `:background` fills the area - vacated by the shift; `:extend_mode` only affects the antialiased + vacated by the shift. `:extend_mode` only affects the antialiased edge fringe on fractional (sub-pixel) shifts. ### Returns @@ -12599,23 +12634,17 @@ defmodule Image do It can also be supplied as a hex string of the form `#rrggbb`. The default is `:black`. `:background` can also be set to `:average` in which case the background will be the average color of the base - image. See also `Color.new/2` from the Color library. - - * `:extend_mode` controls how the interpolator synthesises the - thin band of pixels just beyond the source edge when resampling - the warped content boundary. It is visible only as a one-pixel - fringe along the content edge. It does *not* fill the blank - canvas left uncovered by the transformation — that area is - always filled with `:background`. The values are: - - * `:background` (the default) means the synthesised pixels use - the `:background` color. - * `:black` means the synthesised pixels are black. - * `:white` means the synthesised pixels are white. - * `:copy` means the synthesised pixels copy the nearest edge - pixel of the base image. - * `:repeat` means the base image is tiled. - * `:mirror` means the base image is reflected. + image. See `Image.Pixel.to_pixel/2` for the full range of accepted + color forms. + + * `:extend_mode` controls how the one-pixel fringe just beyond + the warped content edge is synthesized during interpolation. + The uncovered canvas is always filled with `:background`. The + values are `:background` (the default), which blends the + fringe toward the `:background` color, and `:copy`, which + clamps it to the nearest content pixel (useful when the warped + content covers the whole output). See `Image.affine/3` for + more detail. ### Notes @@ -12687,13 +12716,12 @@ defmodule Image do It can also be supplied as a hex string of the form `#rrggbb`. The default is `:black`. `:background` can also be set to `:average` in which case the background will be - the average color of the base image. See also `Color.new/2` from the Color library. + the average color of the base image. See `Image.Pixel.to_pixel/2` + for the full range of accepted color forms. - * `:extend_mode` controls how pixels just beyond the source edge - are synthesised during interpolation. It is visible only as a - one-pixel fringe along the content edge; it does not fill the - blank canvas left uncovered by the transformation, which is - always filled by `:background`. See `Image.warp_perspective/4`. + The one-pixel antialiased fringe along the warped content edge + blends toward `:background` by default. See `Image.warp_perspective/4` + for the `:extend_mode` option. ### Returns @@ -12760,13 +12788,12 @@ defmodule Image do It can also be supplied as a hex string of the form `#rrggbb`. The default is `:black`. `:background` can also be set to `:average` in which case the background will be - the average color of the base image. See also `Color.new/2` from the Color library. + the average color of the base image. See `Image.Pixel.to_pixel/2` + for the full range of accepted color forms. - * `:extend_mode` controls how pixels just beyond the source edge - are synthesised during interpolation. It is visible only as a - one-pixel fringe along the content edge; it does not fill the - blank canvas left uncovered by the transformation, which is - always filled by `:background`. See `Image.warp_perspective/4`. + The one-pixel antialiased fringe along the warped content edge + blends toward `:background` by default. See `Image.warp_perspective/4` + for the `:extend_mode` option. ### Returns @@ -12848,13 +12875,8 @@ defmodule Image do It can also be supplied as a hex string of the form `#rrggbb`. The default is `:black`. `:background` can also be set to `:average` in which case the background will be - the average color of the base image. See also `Color.new/2` from the Color library. - - * `:extend_mode` controls how pixels just beyond the source edge - are synthesised during interpolation. It is visible only as a - one-pixel fringe along the content edge; it does not fill the - blank canvas left uncovered by the transformation, which is - always filled by `:background`. See `Image.warp_perspective/4`. + the average color of the base image. See `Image.Pixel.to_pixel/2` + for the full range of accepted color forms. ### Returns diff --git a/lib/image/background_color.ex b/lib/image/background_color.ex index 06232bf6..830974c0 100644 --- a/lib/image/background_color.ex +++ b/lib/image/background_color.ex @@ -8,14 +8,19 @@ 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). - In both cases the resolved pixel matches `image`'s number of bands. + 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. + + In all cases the resolved pixel matches `image`'s number of bands. """ alias Image.Pixel alias Vix.Vips.Image, as: Vimage - @typedoc "A background color specification: the image's average color, or any color." - @type spec :: Pixel.t() | :average + @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()]} @doc """ Resolves a background color `spec` into a pixel matching `image`'s @@ -25,8 +30,9 @@ defmodule Image.BackgroundColor do * `image` is any `t:Vix.Vips.Image.t/0`. - * `spec` is `:average` (the image's average color) or any color - accepted by `Image.Pixel.to_pixel/2`. + * `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. ### Returns @@ -50,25 +56,25 @@ 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. color when is_list(color) -> - if Image.has_alpha?(image) do - # Append an opaque alpha band in the interpretation's own scale via `to_pixel` - case Pixel.to_pixel(image, :black, alpha: :opaque) do - {:ok, opaque_pixel} -> - {:ok, color ++ [List.last(opaque_pixel)]} - - {:error, reason} -> - {:error, error("Could not construct alpha #{inspect(color)}", reason)} - end - else - {:ok, color} - end + put_alpha_band(image, color, :opaque) {:error, reason} -> {:error, error("Could not compute the image average", reason)} end end + # The wrapped `{spec, alpha: alpha}` form: resolve the color part like any + # other spec, then set the alpha band directly. + def resolve(%Vimage{} = image, {spec, opts}) when is_list(opts) do + with {:ok, alpha} <- fetch_alpha(spec, opts), + {:ok, pixel} <- resolve(image, spec) do + apply_alpha(image, pixel, alpha) + end + end + def resolve(%Vimage{} = image, color) do case Pixel.to_pixel(image, color) do {:ok, pixel} -> @@ -79,6 +85,57 @@ defmodule Image.BackgroundColor do end end + # `:alpha` 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_alpha(spec, opts) do + {:error, + %Image.Error{ + reason: :invalid_background, + value: {spec, opts}, + message: + "Invalid background color #{inspect({spec, opts})}: " <> + "expected {color, alpha: transparency}" + }} + 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 diff --git a/lib/image/options/affine.ex b/lib/image/options/affine.ex index ecfa3926..4f6c7443 100644 --- a/lib/image/options/affine.ex +++ b/lib/image/options/affine.ex @@ -10,7 +10,7 @@ defmodule Image.Options.Affine do alias Vix.Vips.Image, as: Vimage alias Vix.Vips.Interpolate - alias Image.Pixel + alias Image.BackgroundColor @typedoc """ The interpolators that may be selected with the `:interpolate` @@ -32,6 +32,20 @@ defmodule Image.Options.Affine do | :nohalo | :vsqbs + @typedoc """ + How the interpolator synthesizes the one-pixel fringe just beyond + the content edge when resampling boundary pixels. + + * `:background` (the default) blends the fringe toward the + `:background` color. This is correct whenever the transform + exposes canvas. + * `:copy` clamps to the nearest content pixel. Use it when the + content fills the whole canvas, where the default would leave a + faint border along the outermost row and column. + + """ + @type extend_mode :: :background | :copy + @typedoc """ The options applicable to an affine transformation. @@ -42,9 +56,9 @@ defmodule Image.Options.Affine do | {:odx, number()} | {:ody, number()} | {:interpolate, interpolate()} - | {:background, Pixel.t() | :average} + | {:background, BackgroundColor.spec() | nil} + | {:extend_mode, extend_mode()} | {:output_area, [integer()]} - | {:extend_mode, Image.ExtendMode.t()} ] # The libvips nickname for each interpolator is identical to the @@ -53,6 +67,12 @@ defmodule Image.Options.Affine do @displacement_options [:idx, :idy, :odx, :ody] + # The other libvips extend modes are deliberately not exposed: :repeat + # samples the opposite edge of the image into the fringe, :mirror is + # indistinguishable from :copy at one pixel deep, and :black/:white are + # colors, which belong to :background. + @extend_modes [background: :VIPS_EXTEND_BACKGROUND, copy: :VIPS_EXTEND_COPY] + @doc """ Validate the options for `Image.affine/3`. @@ -65,6 +85,8 @@ defmodule Image.Options.Affine do @spec validate_options(Vimage.t(), Keyword.t()) :: {:ok, Keyword.t()} | {:error, Image.error()} def validate_options(image, options) do + # A nil `:background` means "unset", i.e. it falls back to the default. + options = Enum.reject(options, &match?({:background, nil}, &1)) options = Keyword.merge(default_options(), options) case Enum.reduce_while(options, options, &validate_option(&1, image, &2)) do @@ -87,24 +109,6 @@ defmodule Image.Options.Affine do end end - # The public option is `:extend_mode`, renamed internally to `:extend` for `libvips` - defp validate_option({:extend_mode, extend}, _image, options) - when is_atom(extend) or is_binary(extend) do - case Image.ExtendMode.validate_extend(extend) do - {:ok, extend} -> - options = - options - |> Keyword.delete(:extend_mode) - |> Keyword.put(:extend, extend) - - {:cont, options} - - {:error, reason} -> - {:halt, - {:error, %Image.Error{reason: :invalid_extend_mode, value: extend, message: reason}}} - end - end - defp validate_option({:background, background}, image, options) do case Image.BackgroundColor.resolve(image, background) do {:ok, pixel} -> {:cont, Keyword.put(options, :background, pixel)} @@ -112,6 +116,17 @@ defmodule Image.Options.Affine do end end + # The public option is `:extend_mode`, renamed to `:extend` for `libvips`. + defp validate_option({:extend_mode, extend_mode}, _image, options) + when extend_mode in [:background, :copy] do + options = + options + |> Keyword.delete(:extend_mode) + |> Keyword.put(:extend, Keyword.fetch!(@extend_modes, extend_mode)) + + {:cont, options} + end + # The public option is `:output_area`, libvips names it `oarea`. defp validate_option({:output_area, [left, top, width, height] = area}, _image, options) when is_integer(left) and is_integer(top) and is_integer(width) and is_integer(height) do @@ -140,11 +155,11 @@ defmodule Image.Options.Affine do } end - # `:extend_mode` defaults to `:background` rather than `:black`: since - # extend only governs the antialiased edge fringe (not the canvas fill), - # `:background` blends the fringe toward the fill color, whereas `:black` - # would leave a dark fringe on a non-black background. + # No default `:background` is injected. When omitted, libvips uses its + # native all-zeros fill. `:interpolate` defaults to `:bilinear`, also + # libvips' own default. `:extend_mode` defaults to `:background` so the + # edge fringe blends into the canvas fill. defp default_options do - [extend_mode: :background, interpolate: :bilinear] + [interpolate: :bilinear, extend_mode: :background] end end diff --git a/lib/image/options/embed.ex b/lib/image/options/embed.ex index c1791945..5d721326 100644 --- a/lib/image/options/embed.ex +++ b/lib/image/options/embed.ex @@ -3,28 +3,31 @@ defmodule Image.Options.Embed do Options and option validation for `Image.embed/4`. """ - alias Image.Pixel + alias Image.BackgroundColor @typedoc """ Options applicable to `Image.embed/4`. """ @type embed_option :: - {:background_color, Pixel.t() | :average} - | {:background_transparency, Pixel.transparency()} + {:background, BackgroundColor.spec() | nil} | {:extend_mode, extend_mode()} | {:x, non_neg_integer() | :center} | {:y, non_neg_integer() | :center} @typedoc """ - When extending the canvas the generated - pixels are determined by this option. + How the generated border pixels are produced. + + * `:copy`, `:repeat` and `:mirror` synthesize the border from the *image + content*. + * `:background` fills the border with the `:background` color. This is the + default behavior when no `:extend_mode` is given, so passing it is only + ever explicit documentation of intent. The color still comes from the + `:background` option. """ @type extend_mode :: - :black - | :white - | :copy + :copy | :repeat | :mirror | :background @@ -35,11 +38,21 @@ defmodule Image.Options.Embed do """ @type embed_options :: [embed_option()] + # 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_extend_modes Keyword.keys(@content_extends) + @vips_content_extends Keyword.values(@content_extends) + @doc """ Validate the options for `Image.embed/4`. """ def validate_options(image, width, height, options) when is_list(options) do + # A nil `:background` means "unset". It falls back to the default and does + # not count as an explicitly-supplied background, so it does not conflict + # with a content `:extend_mode`. + options = Enum.reject(options, &match?({:background, nil}, &1)) user_supplied_keys = Keyword.keys(options) options = Keyword.merge(default_options(), options) @@ -50,7 +63,7 @@ defmodule Image.Options.Embed do options -> options |> Map.new() - |> adjust_background(image, user_supplied_keys) + |> adjust_background(user_supplied_keys) end end @@ -58,49 +71,27 @@ defmodule Image.Options.Embed do {:ok, options} end - defp validate_option({:background_color, :average}, image, _width, _height, options) do - case Image.average(image) do - color when is_list(color) -> - options = Keyword.put(options, :background_color, color) - {:cont, options} - - {:error, reason} -> - {:halt, - {:error, - %Image.Error{ - message: "Could not get the image average: #{inspect(reason)}", - reason: "Could not get the image average: #{inspect(reason)}" - }}} - end - end - - defp validate_option({:background_color, color} = option, image, _width, _height, options) do - case Pixel.to_pixel(image, color) do + # `:average`, colors, and the `{color, alpha: a}` 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 + case BackgroundColor.resolve(image, background) do {:ok, pixel} -> - {:cont, Keyword.put(options, :background_color, pixel)} + {:cont, Keyword.put(options, :background, pixel)} - _other -> - {:halt, {:error, invalid_option(option)}} - end - end - - defp validate_option( - {:background_transparency, transparency} = option, - _image, - _width, - _height, - options - ) do - case Pixel.transparency(transparency) do - {:ok, transparency} -> - {:cont, Keyword.put(options, :background_transparency, transparency)} - - _other -> - {:halt, {:error, invalid_option(option)}} + # The resolve error is already an %Image.Error{} with a more specific + # message than invalid_option/1 would produce. + {:error, reason} -> + {:halt, {:error, reason}} end end - defp validate_option({:extend_mode, extend}, _image, _width, _height, options) do + # `:extend_mode` now only names the content-derived borders. Color and + # transparency fills go through `:background`, so `:black`/`:white`/ + # `:background` are no longer extend modes, they fall through to + # `invalid_option/1`. + defp validate_option({:extend_mode, extend}, _image, _width, _height, options) + when extend in @content_extend_modes do case Image.ExtendMode.validate_extend(extend) do {:ok, extend_mode} -> options = Keyword.put(options, :extend_mode, extend_mode) @@ -111,6 +102,14 @@ defmodule Image.Options.Embed do end end + # `:background` is accepted as an explicit selector for the color fill (the + # default behavior). The color itself comes from the `:background` option. + # It is a *mode*, not a color, so it is allowed here even though `:black`/ + # `:white` (which are colors) are not: those go through `:background`. + defp validate_option({:extend_mode, :background}, _image, _width, _height, options) do + {:cont, Keyword.put(options, :extend_mode, :VIPS_EXTEND_BACKGROUND)} + end + defp validate_option({:x, :center}, image, width, height, options) do x = trunc((width - Image.width(image)) / 2) validate_option({:x, x}, image, width, height, options) @@ -155,89 +154,45 @@ defmodule Image.Options.Embed do } end - @geometric_extend_modes [:VIPS_EXTEND_COPY, :VIPS_EXTEND_REPEAT, :VIPS_EXTEND_MIRROR] - - # Resolves the interaction between :extend_mode, :background_color and - # :background_transparency. + # Resolves the interaction between :extend_mode and :background. # - # * A geometric extend mode (:copy, :repeat, :mirror) is always honored - # as given. + # * A content-derived extend mode (:copy/:repeat/:mirror) synthesizes the + # border from the image and consumes no color, so combining it with an + # explicit :background is contradictory and returns an error. A nil + # :background was stripped as "unset" and does not conflict. # - # * An explicit :background_color (or extend_mode: :background) extends - # with that color, with the alpha band (when present) set from - # :background_transparency. - # - # * The :black and :white extend modes on an image with an alpha band - # are converted to a background extension so that the generated - # pixels carry the requested :background_transparency. The color and - # alpha are scaled to the image interpretation by Pixel.to_pixel/3. - defp adjust_background(options, image, user_supplied_keys) do - explicit_background? = :background_color in user_supplied_keys - - cond do - options.extend_mode in @geometric_extend_modes -> - {:ok, Map.delete(options, :background_transparency)} - - explicit_background? or options.extend_mode == :VIPS_EXTEND_BACKGROUND -> - with {:ok, background} <- background_with_alpha(options, image) do - {:ok, - options - |> Map.put(:extend_mode, :VIPS_EXTEND_BACKGROUND) - |> Map.put(:background_color, background) - |> Map.delete(:background_transparency)} - end - - Image.has_alpha?(image) -> - base_color = if options.extend_mode == :VIPS_EXTEND_WHITE, do: :white, else: :black - - with {:ok, pixel} <- - Pixel.to_pixel(image, base_color, alpha: options.background_transparency) do - {:ok, - options - |> Map.put(:extend_mode, :VIPS_EXTEND_BACKGROUND) - |> Map.put(:background_color, pixel) - |> Map.delete(:background_transparency)} - end - - true -> - {:ok, Map.delete(options, :background_transparency)} - end - end - - defp background_with_alpha(options, image) do - color = options.background_color - bands = Image.bands(image) + # * Otherwise the border is filled with :background via VIPS_EXTEND_BACKGROUND. + # With no user-supplied background, no background is passed and libvips + # uses its native all-zeros fill. + defp adjust_background(options, user_supplied_keys) do + explicit_background? = :background in user_supplied_keys + content_extend = Map.get(options, :extend_mode) cond do - not Image.has_alpha?(image) -> - {:ok, conform_single_band(color, bands)} + content_extend in @vips_content_extends and explicit_background? -> + contradictory_options_error(content_extend) - length(color) == bands -> - with {:ok, alpha} <- background_alpha(options, image) do - {:ok, List.replace_at(color, -1, alpha)} - end - - length(color) == bands - 1 -> - with {:ok, alpha} <- background_alpha(options, image) do - {:ok, color ++ [alpha]} - end + content_extend in @vips_content_extends -> + {:ok, options} true -> - {:ok, color} + {:ok, Map.put(options, :extend_mode, :VIPS_EXTEND_BACKGROUND)} end end - # The opaque alpha value in the scale of the image interpretation, - # adjusted by the requested :background_transparency. - defp background_alpha(options, image) do - with {:ok, pixel} <- - Pixel.to_pixel(image, :black, alpha: options.background_transparency) do - {:ok, List.last(pixel)} - end - end + defp contradictory_options_error(vips_extend) do + {extend_mode, _vips} = List.keyfind!(@content_extends, vips_extend, 1) - defp conform_single_band(color, 1) when length(color) > 1, do: [hd(color)] - defp conform_single_band(color, _bands), do: color + {:error, + %Image.Error{ + reason: :invalid_option, + value: {:extend_mode, extend_mode}, + message: + "extend_mode: #{inspect(extend_mode)} generates the border from the image " <> + "content and cannot be combined with an explicit :background. " <> + "Pass one or the other (background: nil is treated as unset)." + }} + end @doc false def normalize_dim(a, _max) when a >= 0, do: a @@ -246,10 +201,7 @@ defmodule Image.Options.Embed do defp default_options do [ x: :center, - y: :center, - extend_mode: :black, - background_color: :black, - background_transparency: :opaque + y: :center ] end diff --git a/lib/image/options/join.ex b/lib/image/options/join.ex index e0f2adda..46beb07c 100644 --- a/lib/image/options/join.ex +++ b/lib/image/options/join.ex @@ -3,7 +3,7 @@ defmodule Image.Options.Join do Options and option validation for `Image.join/2`. """ - alias Image.Pixel + alias Image.BackgroundColor @typedoc """ Options applicable to `Image.join/2`. @@ -14,7 +14,7 @@ defmodule Image.Options.Join do | {:horizontal_spacing, non_neg_integer()} | {:vertical_align, :top | :middle | :bottom} | {:horizontal_align, :left | :center | :right} - | {:background_color, Pixel.t() | nil} + | {:background, BackgroundColor.spec() | nil} | {:shim, non_neg_integer()} | {:across, pos_integer()} @@ -38,9 +38,6 @@ defmodule Image.Options.Join do # Default horizontal alignment of images. @default_horizontal_alignment :left - # Default background color of the final iamge. - @default_background_color nil - # Default pixels between images. @default_shim_in_pixels 0 @@ -63,6 +60,8 @@ defmodule Image.Options.Join do """ def validate_options(image, options) when is_list(options) do + # A nil `:background` means "unset", i.e. it falls back to the default. + options = Enum.reject(options, &match?({:background, nil}, &1)) options = Keyword.merge(default_options(), options) case Enum.reduce_while(options, options, &validate_option(&1, image, &2)) do @@ -108,17 +107,16 @@ defmodule Image.Options.Join do replace_option(options, :horizontal_align, :halign, Map.get(@alignment_map, horizontal_align))} end - defp validate_option({:background_color, nil}, _image, options) do - {:cont, Keyword.delete(options, :background_color)} - end - - defp validate_option({:background_color, color} = option, image, options) do - case Pixel.to_pixel(image, color) do + # 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 + # a transparent or semi-transparent color. `arrayjoin` reproduces it exactly. + defp validate_option({:background, background}, image, options) do + case BackgroundColor.resolve(image, background) do {:ok, pixel} -> - {:cont, replace_option(options, :background_color, :background, pixel)} + {:cont, Keyword.put(options, :background, pixel)} - _other -> - {:halt, {:error, invalid_option(option)}} + {:error, reason} -> + {:halt, {:error, reason}} end end @@ -156,7 +154,6 @@ defmodule Image.Options.Join do horizontal_spacing: @default_horizontal_spacing, vertical_align: @default_vertical_alignment, horizontal_align: @default_horizontal_alignment, - background_color: @default_background_color, shim: @default_shim_in_pixels, across: @default_images_across ] diff --git a/lib/image/options/rotate.ex b/lib/image/options/rotate.ex index 20e6b82e..97c61abd 100644 --- a/lib/image/options/rotate.ex +++ b/lib/image/options/rotate.ex @@ -5,7 +5,7 @@ defmodule Image.Options.Rotate do """ alias Vix.Vips.Interpolate - alias Image.Pixel + alias Image.BackgroundColor @typedoc """ The interpolators that may be selected with the `:interpolate` @@ -38,7 +38,7 @@ defmodule Image.Options.Rotate do | {:ody, number()} | {:odx, number()} | {:interpolate, interpolate()} - | {:background, Pixel.t() | :average} + | {:background, BackgroundColor.spec() | nil} ] # The libvips nickname for each interpolator is identical to the @@ -59,6 +59,8 @@ defmodule Image.Options.Rotate do @spec validate_options(Vix.Vips.Image.t(), Keyword.t()) :: {:ok, Keyword.t()} | {:error, Image.error()} def validate_options(image, options) do + # A nil `:background` means "unset", i.e. it falls back to the default. + options = Enum.reject(options, &match?({:background, nil}, &1)) options = Keyword.merge(default_options(), options) case Enum.reduce_while(options, options, &validate_option(&1, image, &2)) do @@ -105,10 +107,9 @@ defmodule Image.Options.Rotate do } end - # No default `:background` is injected: when the caller omits it, - # `libvips` keeps its own native fill (transparent for images with - # an alpha band, black otherwise). `:interpolate` defaults to - # `:bilinear`, which is also `libvips`' own default. + # No default `:background` is injected. When omitted, libvips uses its + # native all-zeros fill. `:interpolate` defaults to `:bilinear`, which + # is also `libvips`' own default. defp default_options do [interpolate: :bilinear] end diff --git a/lib/image/options/warp_perspective.ex b/lib/image/options/warp_perspective.ex index fee83cc7..2ecfdb97 100644 --- a/lib/image/options/warp_perspective.ex +++ b/lib/image/options/warp_perspective.ex @@ -4,7 +4,9 @@ defmodule Image.Options.WarpPerspective do """ alias Vix.Vips.Image, as: Vimage - alias Image.Pixel + alias Image.BackgroundColor + + @extend_modes [background: :VIPS_EXTEND_BACKGROUND, copy: :VIPS_EXTEND_COPY] @typedoc """ Options for Image.warp_perspective/4. @@ -13,27 +15,12 @@ defmodule Image.Options.WarpPerspective do @type t :: [warp_perspective_option()] @typedoc """ - Options applicable to `Image.embed/4`. - - """ - @type warp_perspective_option :: [ - {:background, Pixel.t() | :average} - | {:extend_mode, extend_mode()} - ] - - @typedoc """ - When extending the canvas the generated - pixels are determined by this option. + Options applicable to `Image.warp_perspective/4`. """ - @type extend_mode :: [ - :black - | :white - | :copy - | :repeat - | :mirror - | :background - ] + @type warp_perspective_option :: + {:background, BackgroundColor.spec() | nil} + | {:extend_mode, Image.Options.Affine.extend_mode()} @doc """ Validate the options for `Image.warp_perspective/4`. @@ -42,6 +29,8 @@ defmodule Image.Options.WarpPerspective do @spec validate_options(Vimage.t(), Keyword.t()) :: {:ok, Keyword.t()} | {:error, Image.error()} def validate_options(image, options) when is_list(options) do + # A nil `:background` means "unset", i.e. it falls back to the default. + options = Enum.reject(options, &match?({:background, nil}, &1)) options = Keyword.merge(default_options(), options) case Enum.reduce_while(options, options, &validate_option(&1, image, &2)) do @@ -60,20 +49,17 @@ defmodule Image.Options.WarpPerspective do end end - # The public option is `:extend_mode`, renamed internally to `:extend` for `libvips` - defp validate_option({:extend_mode, extend}, _image, options) do - case Image.ExtendMode.validate_extend(extend) do - {:ok, extend_mode} -> - options = - options - |> Keyword.delete(:extend_mode) - |> Keyword.put(:extend, extend_mode) - - {:cont, options} - - {:error, reason} -> - {:halt, {:error, reason}} - end + # The public option is `:extend_mode`, renamed to `:extend` for `libvips`. + # Only `:background` and `:copy` are exposed. See the `@extend_modes` + # comment in `Image.Options.Affine` for the rationale. + defp validate_option({:extend_mode, extend_mode}, _image, options) + when extend_mode in [:background, :copy] do + options = + options + |> Keyword.delete(:extend_mode) + |> Keyword.put(:extend, Keyword.fetch!(@extend_modes, extend_mode)) + + {:cont, options} end defp validate_option(option, _image, _options) do @@ -88,10 +74,9 @@ defmodule Image.Options.WarpPerspective do } end + # `:extend_mode` defaults to `:background` so the edge fringe blends + # into the canvas fill. defp default_options do - [ - extend_mode: :background, - background: :black - ] + [background: :black, extend_mode: :background] end end diff --git a/lib/image/options/write.ex b/lib/image/options/write.ex index 7c2f69a0..8ce5599c 100644 --- a/lib/image/options/write.ex +++ b/lib/image/options/write.ex @@ -133,6 +133,10 @@ defmodule Image.Options.Write do def validate_options(%Vimage{} = image, path, options) when is_binary(path) and is_list(options) do + # A nil `:background` means "unset". Leave it out so the format's own + # flatten (onto black) applies, rather than trying to resolve nil. + options = Enum.reject(options, &match?({:background, nil}, &1)) + with {:ok, image_type} <- path |> Path.extname() |> image_type_from(options[:suffix]), {:ok, options} <- merge_image_type_options(options, image_type) do case Enum.reduce_while(options, options, &validate_option(&1, &2, image, image_type)) do @@ -305,7 +309,7 @@ defmodule Image.Options.Write do # `:average` uses the image's average color and any other value is resolved # by `Image.BackgroundColor.resolve/2` (numbers, lists, CSS names, hex - # strings). the resolved pixel's alpha band (opaque for `:average` on an + # strings). The resolved pixel's alpha band (opaque for `:average` on an # alpha image) is stripped. defp validate_option({:background, background}, options, image, _image_type) do case BackgroundColor.resolve(image, background) do diff --git a/mix/for_dialyzer.ex b/mix/for_dialyzer.ex index 9c57bd01..5f9ae8db 100644 --- a/mix/for_dialyzer.ex +++ b/mix/for_dialyzer.ex @@ -17,10 +17,11 @@ defmodule ForDialyzer do penguin = Image.open!("./test/support/images/penguin_with_alpha.png") {width, height, _bands} = Image.shape(penguin) - {:ok, _embed_1} = Image.embed(penguin, width, height + 50, extend_mode: :black) - {:ok, _embed_1} = Image.embed(penguin, width, height + 50, x: 0, y: 0, extend_mode: :black, background_transparency: :transparent) - {:ok, _embed_1} = Image.embed(penguin, width, height + 50, x: 0, y: 0, extend_mode: :black, background_transparency: :opaque) - {:ok, _embed_1} = Image.embed(penguin, width, height + 50, x: 0, y: 0, extend_mode: :background, background_color: :green) + {: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, extend_mode: :mirror) + {:ok, _embed_1} = Image.embed(penguin, width, height + 50, x: 0, y: 0, extend_mode: :background, background: :green) end diff --git a/test/affine_test.exs b/test/affine_test.exs index 524449dd..fb0cf076 100644 --- a/test/affine_test.exs +++ b/test/affine_test.exs @@ -51,28 +51,13 @@ defmodule Image.Affine.Test do Image.affine(image, [1, 0, 0, 1], not_an_option: 1) end - test "accepts the :extend_mode and :output_area options" do + test "accepts the :output_area option" do image = white_dot(20, 20, 2, 3) - assert {:ok, %Vimage{}} = Image.affine(image, [1, 0, 0, 1], extend_mode: :mirror) {:ok, cropped} = Image.affine(image, [1, 0, 0, 1], output_area: [0, 0, 10, 10]) assert Image.shape(cropped) == {10, 10, 3} end - test "rejects an invalid :extend_mode with a structured error" do - image = white_dot(20, 20, 2, 3) - - assert {:error, %Image.Error{reason: :invalid_extend_mode, value: :bogus}} = - Image.affine(image, [1, 0, 0, 1], extend_mode: :bogus) - end - - test "rejects a non-atom/binary :extend_mode without crashing" do - image = white_dot(20, 20, 2, 3) - - assert {:error, %Image.Error{reason: :invalid_option, value: {:extend_mode, 123}}} = - Image.affine(image, [1, 0, 0, 1], extend_mode: 123) - end - test "applies output displacements :odx/:ody after the matrix" do image = white_dot(20, 20, 2, 3) {:ok, result} = Image.affine(image, [1, 0, 0, 1], odx: 4, ody: 1) @@ -104,26 +89,64 @@ defmodule Image.Affine.Test do end end - test "applies the documented default :extend_mode and :interpolate and leaves :background to libvips" do + test "defaults :extend to background, :interpolate to bilinear and no :background" do image = white_dot(20, 20, 2, 3) {:ok, options} = Image.Options.Affine.validate_options(image, []) assert Keyword.get(options, :extend) == :VIPS_EXTEND_BACKGROUND + refute Keyword.has_key?(options, :extend_mode) assert %Vix.Vips.Interpolate{} = Keyword.get(options, :interpolate) - # No default :background is injected so libvips keeps its native - # fill (transparent for alpha images, black otherwise). + # No :background is injected. libvips uses its native all-zeros fill. refute Keyword.has_key?(options, :background) end - test "preserves libvips' transparent fill for alpha images when :background is unset" do - # A translation vacates a strip of canvas. With no :background the - # exposed pixels must keep libvips' native transparent fill rather - # than being forced to opaque black. + test "extend_mode: :copy is renamed to :extend for libvips" do + image = white_dot(20, 20, 2, 3) + {:ok, options} = Image.Options.Affine.validate_options(image, extend_mode: :copy) + + assert Keyword.get(options, :extend) == :VIPS_EXTEND_COPY + refute Keyword.has_key?(options, :extend_mode) + end + + test "extend_mode: :copy avoids the edge fringe on a full-canvas scale" do + # A pure scale-up: the content covers the whole output canvas, so no + # background should ever be visible. With the default :background + # extend the outermost row/column still blends toward the (black) + # fill. :copy clamps to the content instead. + white = Image.new!(10, 10, color: [255, 255, 255]) + + {:ok, blended} = Image.affine(white, [2, 0, 0, 2]) + assert Image.get_pixel!(blended, 19, 10) == [128, 128, 128] + + {:ok, clamped} = Image.affine(white, [2, 0, 0, 2], extend_mode: :copy) + assert Image.get_pixel!(clamped, 19, 10) == [255, 255, 255] + end + + test "only :background and :copy are valid extend modes" do + image = white_dot(20, 20, 2, 3) + + for extend_mode <- [:repeat, :mirror, :black, :white] do + assert {:error, %Image.Error{reason: :invalid_option}} = + Image.affine(image, [2, 0, 0, 2], extend_mode: extend_mode) + end + end + + test "the default fill is transparent for alpha images" do + # A translation vacates a strip of canvas. On an alpha image the native + # all-zeros default keeps those pixels transparent. image = Image.new!(20, 20, color: [0, 0, 0, 255]) {:ok, translated} = Image.translate(image, 10, 0) assert Image.get_pixel!(translated, 2, 10) == [0, 0, 0, 0] end + + test "a nil :background is treated as unset and falls back to the default" do + image = Image.new!(20, 20, color: [0, 0, 0, 255]) + + {:ok, translated} = Image.translate(image, 10, 0, background: nil) + # falls back to the native default -> transparent vacated strip + assert Image.get_pixel!(translated, 2, 10) == [0, 0, 0, 0] + end end describe "Image.affine/3 with a partially transparent :background" do @@ -190,8 +213,7 @@ defmodule Image.Affine.Test do test "fills the vacated area with the background colour" do image = white_dot(20, 20, 10, 10) - {:ok, result} = - Image.translate(image, 5, 0, background: [100, 100, 100], extend_mode: :background) + {:ok, result} = Image.translate(image, 5, 0, background: [100, 100, 100]) assert Image.get_pixel!(result, 0, 0) == [100, 100, 100] end diff --git a/test/background_color_test.exs b/test/background_color_test.exs index 99aebee8..eb9d594a 100644 --- a/test/background_color_test.exs +++ b/test/background_color_test.exs @@ -106,6 +106,75 @@ 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 + image = solid([0, 0, 0, 255]) + assert BackgroundColor.resolve(image, {:red, alpha: 0.5}) == {:ok, [255, 0, 0, 128]} + end + + test "resolves :average first, then applies the alpha, 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]} + 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]} + end + + test "strips the alpha 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]} + end + + test "strips the alpha 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]} + 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}) + + 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 + 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" + end + + test "validates the alpha 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" + end + + test "a misspelled or missing :alpha 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}) + + assert error.message =~ "expected {color, alpha: transparency}" + assert {:error, %Image.Error{}} = BackgroundColor.resolve(image, {:red, []}) + end + + test "extra keys alongside :alpha are rejected" do + image = solid([0, 0, 0, 255]) + + assert {:error, %Image.Error{}} = + BackgroundColor.resolve(image, {:red, [alpha: 0.5, bogus: 1]}) + end + end + describe "resolve/2 error handling" do test "wraps an invalid color in an Image.Error" do image = solid([0, 0, 0]) diff --git a/test/coverage_wave3_options_test.exs b/test/coverage_wave3_options_test.exs index 94f95192..e7250598 100644 --- a/test/coverage_wave3_options_test.exs +++ b/test/coverage_wave3_options_test.exs @@ -22,6 +22,25 @@ defmodule Image.CoverageWave3.Options.Test do assert {:error, _} = Image.warp_perspective(rgb(100), from, to, background_color: :not_a_color) end + + test ":extend defaults to background and :extend_mode is renamed for libvips" do + assert {:ok, options} = Image.Options.WarpPerspective.validate_options(rgb(100), []) + assert Keyword.get(options, :extend) == :VIPS_EXTEND_BACKGROUND + refute Keyword.has_key?(options, :extend_mode) + + assert {:ok, options} = + Image.Options.WarpPerspective.validate_options(rgb(100), extend_mode: :copy) + + assert Keyword.get(options, :extend) == :VIPS_EXTEND_COPY + refute Keyword.has_key?(options, :extend_mode) + end + + test "only :background and :copy are valid extend modes" do + for extend_mode <- [:repeat, :mirror, :black, :white] do + assert {:error, %Image.Error{reason: :invalid_option}} = + Image.Options.WarpPerspective.validate_options(rgb(100), extend_mode: extend_mode) + end + end end describe "Image.Options.Compose function-valued offsets" do @@ -122,13 +141,53 @@ defmodule Image.CoverageWave3.Options.Test do Embed.validate_options(rgb(), 40, 40, %{extend_mode: :VIPS_EXTEND_BLACK}) end + test "the default is a background extend with no injected background" do + # With no :background passed, libvips fills with its native all-zeros + # pixel: transparent on this alpha image. + assert {:ok, %{extend_mode: :VIPS_EXTEND_BACKGROUND} = options} = + Embed.validate_options(rgba(), 40, 40, []) + + refute Map.has_key?(options, :background) + end + + test "an explicit :background combined with a content extend_mode is an error" do + # A content mode consumes no color, so the combination is contradictory + # rather than one option silently winning. + assert {:error, %Image.Error{reason: :invalid_option, message: message}} = + Embed.validate_options(rgba(), 40, 40, background: :red, extend_mode: :copy) + + assert message =~ "cannot be combined with an explicit :background" + end + + test "a geometric extend_mode is honored when no :background is given" do + assert {:ok, %{extend_mode: :VIPS_EXTEND_COPY}} = + Embed.validate_options(rgba(), 40, 40, extend_mode: :copy) + end + + test "extend_mode: :background explicitly selects the color fill" do + assert {:ok, %{extend_mode: :VIPS_EXTEND_BACKGROUND, background: [255, 0, 0, 255]}} = + Embed.validate_options(rgba(), 40, 40, extend_mode: :background, background: :red) + end + + test "extend_mode: :background with no color uses the native fill" do + assert {:ok, %{extend_mode: :VIPS_EXTEND_BACKGROUND} = options} = + Embed.validate_options(rgba(), 40, 40, extend_mode: :background) + + refute Map.has_key?(options, :background) + end + + test ":background nil is treated as unset and does not override a content extend mode" do + assert {:ok, %{extend_mode: :VIPS_EXTEND_COPY}} = + Embed.validate_options(rgba(), 40, 40, background: nil, extend_mode: :copy) + end + test ":average background color on an alpha image" do - assert {:ok, embedded} = Image.embed(rgba(), 40, 40, background_color: :average) + assert {:ok, embedded} = Image.embed(rgba(), 40, 40, background: :average) assert Image.shape(embedded) == {40, 40, 4} end test ":average background color on an rgb image" do - assert {:ok, embedded} = Image.embed(rgb(), 40, 40, background_color: :average) + assert {:ok, embedded} = Image.embed(rgb(), 40, 40, background: :average) assert Image.get_pixel!(embedded, 0, 0) == [10, 20, 30] end diff --git a/test/embed_test.exs b/test/embed_test.exs index 37732b49..14d6ea29 100644 --- a/test/embed_test.exs +++ b/test/embed_test.exs @@ -2,7 +2,25 @@ defmodule Image.Embed.Test do use ExUnit.Case, async: true import Image.TestSupport - test "Image.embed/4 with extend_mode: :black" 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}`. + image_file = "penguin_with_alpha.png" + validate_file = "embed/penguin_with_alpha_embed_black_transparent.png" + + image_path = image_path(image_file) + validate_path = validate_path(validate_file) + + image = Image.open!(image_path, access: :random) + {width, height, _bands} = Image.shape(image) + {:ok, embedded} = Image.embed(image, width, height + 50, x: 0, y: 0) + + # {:ok, _image} = Image.write(embedded, validate_path) + assert_images_equal(embedded, validate_path) + end + + test "Image.embed/4 with an explicit background: :black gives an opaque black border" do image_file = "penguin_with_alpha.png" validate_file = "embed/penguin_with_alpha_embed_black.png" @@ -11,13 +29,13 @@ defmodule Image.Embed.Test do image = Image.open!(image_path, access: :random) {width, height, _bands} = Image.shape(image) - {:ok, embedded} = Image.embed(image, width, height + 50, x: 0, y: 0, extend_mode: :black) + {:ok, embedded} = Image.embed(image, width, height + 50, x: 0, y: 0, background: :black) # {:ok, _image} = Image.write(embedded, validate_path) assert_images_equal(embedded, validate_path) end - test "Image.embed/4 with extend_mode: :black, background_transparency: :transparent" do + test "Image.embed/4 with background: {:black, alpha: :transparent}" do image_file = "penguin_with_alpha.png" validate_file = "embed/penguin_with_alpha_embed_black_transparent.png" @@ -31,8 +49,7 @@ defmodule Image.Embed.Test do Image.embed(image, width, height + 50, x: 0, y: 0, - extend_mode: :black, - background_transparency: :transparent + background: {:black, alpha: :transparent} ) # {:ok, _image} = Image.write(embedded, validate_path) @@ -49,13 +66,13 @@ defmodule Image.Embed.Test do image = Image.open!(image_path, access: :random) {width, height, _bands} = Image.shape(image) - {:ok, embedded} = Image.embed(image, width, height + 50, background_color: :blue) + {:ok, embedded} = Image.embed(image, width, height + 50, background: :blue) # {:ok, _image} = Image.write(embedded, validate_path) assert_images_equal(embedded, validate_path) end - test "Image.embed/4 with extend_mode: :background on an image with an alpha band" do + test "Image.embed/4 with an explicit opaque background on an image with an alpha band" do image_file = "penguin_with_alpha.png" validate_file = "embed/penguin_with_alpha_embed_background.png" @@ -66,13 +83,13 @@ defmodule Image.Embed.Test do assert Image.has_alpha?(image) {width, height, _bands} = Image.shape(image) - {:ok, embedded} = Image.embed(image, width, height + 50, x: 0, y: 0, extend_mode: :background) + {:ok, embedded} = Image.embed(image, width, height + 50, x: 0, y: 0, background: :black) # {:ok, _image} = Image.write(embedded, validate_path) assert_images_equal(embedded, validate_path) end - test "Image.embed/4 with extend_mode: :background on a single-band image" do + test "Image.embed/4 with an explicit background on a single-band image" do validate_file = "embed/sydney_opera_house_bw_embed_background.png" validate_path = validate_path(validate_file) @@ -85,7 +102,7 @@ defmodule Image.Embed.Test do assert bands == 1 refute Image.has_alpha?(image) - {:ok, embedded} = Image.embed(image, width, height + 50, x: 0, y: 0, extend_mode: :background) + {:ok, embedded} = Image.embed(image, width, height + 50, x: 0, y: 0, background: :black) # {:ok, _image} = Image.write(embedded, validate_path) assert_images_equal(embedded, validate_path) diff --git a/test/flatten_test.exs b/test/flatten_test.exs index b8b61d18..8c8d70e9 100644 --- a/test/flatten_test.exs +++ b/test/flatten_test.exs @@ -20,7 +20,7 @@ defmodule Image.FlattenTest do validate_path = validate_path("penguine_with_green_background.png") {:ok, image} = Image.open(image_path) - {:ok, result} = Image.flatten(image, background_color: :green) + {:ok, result} = Image.flatten(image, background: :green) # Image.write! result, validate_path {:ok, result} = Vix.Vips.Image.write_to_buffer(result, ".png") diff --git a/test/image_adjustments_coverage_test.exs b/test/image_adjustments_coverage_test.exs index 3d1723db..a9694806 100644 --- a/test/image_adjustments_coverage_test.exs +++ b/test/image_adjustments_coverage_test.exs @@ -394,7 +394,7 @@ defmodule Image.AdjustmentsCoverageTest do image = Image.new!(10, 10, color: [200, 100, 50]) transparent = Image.add_alpha!(image, :transparent) - assert {:ok, flattened} = Image.flatten(transparent, background_color: :white) + assert {:ok, flattened} = Image.flatten(transparent, background: :white) assert Image.get_pixel!(flattened, 5, 5) == [255, 255, 255] end @@ -405,6 +405,14 @@ defmodule Image.AdjustmentsCoverageTest do assert Image.get_pixel!(flattened, 5, 5) == [200, 100, 50] end + test "a nil background falls back to the colorspace-native default (black for sRGB)" do + image = Image.new!(10, 10, color: [200, 100, 50]) + transparent = Image.add_alpha!(image, :transparent) + + assert {:ok, flattened} = Image.flatten(transparent, background: nil) + assert Image.get_pixel!(flattened, 5, 5) == [0, 0, 0] + end + test "flatten!/2 returns an image" do image = Image.new!(10, 10, color: [200, 100, 50]) transparent = Image.add_alpha!(image, :transparent) @@ -412,6 +420,25 @@ defmodule Image.AdjustmentsCoverageTest do flattened = Image.flatten!(transparent) refute Image.has_alpha?(flattened) end + + test "the default flattens onto the colorspace-native background (paper-white for CMYK)" do + base = Image.new!(8, 8, color: [200, 100, 50]) |> Image.to_colorspace!(:cmyk) + {:ok, transparent} = Image.add_alpha(base, :transparent) + {:ok, cmyk} = Image.Draw.rect(transparent, 0, 0, 3, 3, color: [255, 0, 0, 255, 255]) + + {:ok, flattened} = Image.flatten(cmyk) + # CMYK's native empty is no-ink / paper-white, not full-K black. + assert Image.get_pixel!(flattened, 6, 6) == [0, 0, 0, 0] + end + + test "an explicit :black forces full-K black even on CMYK" do + base = Image.new!(8, 8, color: [200, 100, 50]) |> Image.to_colorspace!(:cmyk) + {:ok, transparent} = Image.add_alpha(base, :transparent) + {:ok, cmyk} = Image.Draw.rect(transparent, 0, 0, 3, 3, color: [255, 0, 0, 255, 255]) + + {:ok, flattened} = Image.flatten(cmyk, background: :black) + assert Image.get_pixel!(flattened, 6, 6) == [0, 0, 0, 255] + end end describe "opacity/2 and opacity!/2" do diff --git a/test/image_geometry_coverage_test.exs b/test/image_geometry_coverage_test.exs index ba6de487..a6da04c9 100644 --- a/test/image_geometry_coverage_test.exs +++ b/test/image_geometry_coverage_test.exs @@ -380,7 +380,7 @@ defmodule Image.GeometryCoverageTest do test "embeds with a background color" do image = Image.new!(20, 20, color: :white) - assert {:ok, embedded} = Image.embed(image, 40, 40, background_color: :red) + assert {:ok, embedded} = Image.embed(image, 40, 40, background: :red) assert Image.get_pixel!(embedded, 0, 0) == [255, 0, 0] end @@ -409,7 +409,7 @@ defmodule Image.GeometryCoverageTest do describe "trim/2 and trim!/2" do setup do inner = Image.new!(20, 20, color: :white) - {:ok, framed} = Image.embed(inner, 60, 60, background_color: :black) + {:ok, framed} = Image.embed(inner, 60, 60, background: :black) {:ok, image: framed} end @@ -443,7 +443,7 @@ defmodule Image.GeometryCoverageTest do describe "find_trim/2" do test "returns the bounding box of the non-background area" do inner = Image.new!(20, 20, color: :white) - {:ok, framed} = Image.embed(inner, 60, 60, background_color: :black) + {:ok, framed} = Image.embed(inner, 60, 60, background: :black) assert {:ok, {left, top, width, height}} = Image.find_trim(framed, background: :black) assert left == 20 diff --git a/test/join_test.exs b/test/join_test.exs index e0af3f92..3270aa9a 100644 --- a/test/join_test.exs +++ b/test/join_test.exs @@ -31,4 +31,15 @@ defmodule Image.Join.Test do # {:ok, _image} = Image.write(joined, validate_path) assert_images_equal(joined, validate_path) end + + test "Image.join/2 fills the shim gaps with the :background color" do + validate_file = "join/rainbow_green_shim.png" + validate_path = validate_path(validate_file) + + rainbow = rainbow() + {:ok, joined} = Image.join(rainbow, across: length(rainbow), shim: 6, background: :green) + + # {:ok, _image} = Image.write(joined, validate_path) + assert_images_equal(joined, validate_path) + end end diff --git a/test/options_geometry_test.exs b/test/options_geometry_test.exs index 0e34230a..5fe08722 100644 --- a/test/options_geometry_test.exs +++ b/test/options_geometry_test.exs @@ -426,8 +426,8 @@ defmodule Image.Options.Geometry.Test do assert {:ok, %Vimage{}} = Image.join(images, shim: 2) end - test "with a valid :background_color", %{images: images} do - assert {:ok, %Vimage{}} = Image.join(images, background_color: :green, shim: 2) + test "with a valid :background", %{images: images} do + assert {:ok, %Vimage{}} = Image.join(images, background: :green, shim: 2) end test "with valid alignment options", %{images: images} do @@ -443,8 +443,8 @@ defmodule Image.Options.Geometry.Test do assert {:error, %Image.Error{}} = Image.join(images, shim: -1) end - test "with an invalid :background_color", %{images: images} do - assert {:error, %Image.Error{}} = Image.join(images, background_color: :not_a_color) + test "with an invalid :background", %{images: images} do + assert {:error, %Image.Error{}} = Image.join(images, background: :not_a_color) end test "with a negative :vertical_spacing", %{images: images} do diff --git a/test/rotate_test.exs b/test/rotate_test.exs index 36c52f12..16098a32 100644 --- a/test/rotate_test.exs +++ b/test/rotate_test.exs @@ -63,17 +63,16 @@ defmodule Image.Rotate.Test do assert {:error, _reason} = Image.rotate(image, 45, background: :not_a_colour) end - test "defaults :interpolate and leaves :background to libvips when unset" do + test "defaults :interpolate to bilinear and injects no :background" do image = white_dot(20, 20, 2, 3) {:ok, options} = Image.Options.Rotate.validate_options(image, []) assert %Vix.Vips.Interpolate{} = Keyword.get(options, :interpolate) - # No default :background is injected so libvips keeps its native - # fill (transparent for alpha images, black otherwise). + # No :background is injected. libvips uses its native all-zeros fill. refute Keyword.has_key?(options, :background) end - test "preserves libvips' transparent fill for alpha images when :background is unset" do + test "the default fill is transparent for alpha images" do image = Image.new!(20, 20, color: [0, 0, 0, 255]) |> Image.Draw.rect!(2, 3, 1, 1, color: [255, 255, 255, 255]) @@ -82,6 +81,14 @@ defmodule Image.Rotate.Test do assert Image.get_pixel!(rotated, 0, 0) == [0, 0, 0, 0] end + test "a nil :background is treated as unset and falls back to the default" do + image = Image.new!(20, 20, color: [0, 0, 0, 255]) + + {:ok, rotated} = Image.rotate(image, 45, background: nil) + # falls back to the native default -> transparent corner + assert Image.get_pixel!(rotated, 0, 0) == [0, 0, 0, 0] + end + test "rotate!/3 raises on error" do image = white_dot(20, 20, 2, 3) assert_raise Image.Error, fn -> Image.rotate!(image, 45, interpolate: :unknown) end diff --git a/test/support/validate/join/rainbow_green_shim.png b/test/support/validate/join/rainbow_green_shim.png new file mode 100644 index 0000000000000000000000000000000000000000..4e16e7c904bb777a67eab87d750f90f07208fc7a GIT binary patch literal 1185 zcmeAS@N?(olHy`uVBq!ia0y~yV0;2(J8&=oNf)!pkAT#c)CkWsPfsld4j_k(L5Pu= zff2|8VJSvdFdYVDOGDXlK(+={4agK>CWQLTb{4SuG@v*PykLf~Kr|Zz!vuB)7NCHE zk)Z+OL;JE9Wm4U+A(&_Jy!Su< zznXder>XqsudgcWE}q#}JuiNX{`}+OZ}(OI`?0A0{XC%Po@%x0V%IjjJI}Z#(e`gX z>+64;-lV-vn|pum-8I{pMx@ZrsPZ zlMhBj@7ZwfW9wF_CEF#gWlgX&&zexTcm>-_pov!%F8>eP@a`{AQYBt-mE$&`fmsti zFJ@b*+xB&Z?z>>I+Gef~!u-1QwKS<||L?PgRoP{p}^sU_Pat}SN!2y{1ZwyDQHzZg@Gyp2*W zyhVjq6t*7aek~IZR9DrVxRz~Z@nW`@=3QA6{>dDO;xz(!_#Q~|ljtI;l`7%-%ZN6zGMYR)tp;b{@?VFg