From 263496b17b61d1d030992e03657f6c889d786950 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5vard=20Lindset?= Date: Thu, 2 Jul 2026 23:24:08 +0200 Subject: [PATCH] Image.write: accept color names, hex and :average for :background --- lib/image.ex | 29 +++++--- lib/image/options/write.ex | 106 +++++++++++++++++++-------- test/image_write_background_test.exs | 52 +++++++++++++ 3 files changed, 147 insertions(+), 40 deletions(-) create mode 100644 test/image_write_background_test.exs diff --git a/lib/image.ex b/lib/image.ex index 7bf7b0e..6cfcd23 100644 --- a/lib/image.ex +++ b/lib/image.ex @@ -1348,10 +1348,21 @@ defmodule Image do * `:strip_metadata` is a boolean indicating if all metadata is to be stripped from the image. The default is `false`. - * `:background` is the background value to be used - for any transparent areas of the image. Jpeg does - not support alpha bands so a color value must be - assigned. + * `:background` is the background color used to fill + any transparent areas of the image when saving to a + format that does not support an alpha band (such as + JPEG). If not specified, transparent areas are + flattened onto black. The value may be any color + accepted by `Image.Pixel.to_pixel/2`: a `Color` struct, + a hex string (`"#ff0000"`), a CSS named color + (`:misty_rose`, `"rebeccapurple"`) or a list of + numbers (`[255, 0, 0]`). It may also be `:average`, + in which case the average color of the image is used. + + The alpha band is stripped from the color. + + Note that it has no effect on alpha-preserving formats + such as PNG or WebP, where transparency is written as-is. * `:quality` which influences image compression and is a integer in the range `1..100`. The default for @@ -1530,7 +1541,7 @@ defmodule Image do def write(image, image_path, options \\ []) def write(%Vimage{} = image, image_path, options) when is_binary(image_path) do - with {:ok, options} <- Options.Write.validate_options(image_path, options) do + with {:ok, options} <- Options.Write.validate_options(image, image_path, options) do image_path |> String.split("[", parts: 2) |> write_path(image, options) @@ -1539,7 +1550,7 @@ defmodule Image do if match?({:module, _module}, Code.ensure_compiled(Plug)) do def write(%Vimage{} = image, %Plug.Conn{} = conn, options) do - with {:ok, options} <- Options.Write.validate_options(options, :require_suffix) do + with {:ok, options} <- Options.Write.validate_options(image, options, :require_suffix) do {suffix, options} = Keyword.pop(options, :suffix) options = suffix <> loader_options(options) @@ -1565,7 +1576,7 @@ defmodule Image do def write(%Vimage{} = image, %module{} = stream, options) when module in [File.Stream, Stream] do - with {:ok, options} <- Options.Write.validate_options(options, :require_suffix) do + with {:ok, options} <- Options.Write.validate_options(image, options, :require_suffix) do case write_stream(image, stream, options) do :ok -> {:ok, image} other -> other @@ -1574,7 +1585,7 @@ defmodule Image do end def write(%Vimage{} = image, :memory, options) do - with {:ok, options} <- Options.Write.validate_options(options, :require_suffix) do + with {:ok, options} <- Options.Write.validate_options(image, options, :require_suffix) do {suffix, options} = Keyword.pop(options, :suffix) options = suffix <> loader_options(options) Vimage.write_to_buffer(image, options) @@ -1811,7 +1822,7 @@ defmodule Image do @spec stream!(Vimage.t(), options :: Options.Write.image_write_options()) :: Enumerable.t() def stream!(%Vimage{} = image, options \\ []) do - with {:ok, options} <- Options.Write.validate_options(options, :require_suffix) do + with {:ok, options} <- Options.Write.validate_options(image, options, :require_suffix) do {suffix, options} = Keyword.pop(options, :suffix) {buffer_size, options} = Keyword.pop(options, :buffer_size, :unbuffered) options = suffix <> loader_options(options) diff --git a/lib/image/options/write.ex b/lib/image/options/write.ex index 05bf157..b615e5a 100644 --- a/lib/image/options/write.ex +++ b/lib/image/options/write.ex @@ -8,13 +8,13 @@ defmodule Image.Options.Write do # Vix option. alias Image.{ICCProfile, Pixel} + alias Vix.Vips.Image, as: Vimage import ICCProfile, only: [is_inbuilt: 1] - import Pixel, only: [is_pixel: 1] @typedoc "Options for writing an image to a file with `Image.write/2`." @type image_write_options :: [ {:quality, 1..100} - | {:background, Image.pixel()} + | {:background, Pixel.t() | :average} | stream_write_option() | jpeg_write_option() | png_write_option() @@ -117,10 +117,10 @@ defmodule Image.Options.Write do @suffix_keys Map.keys(@suffix_map) @suffix_values Map.values(@suffix_map) |> Enum.uniq() - def validate_options(options, :require_suffix) when is_list(options) do + def validate_options(%Vimage{} = image, options, :require_suffix) when is_list(options) do case Keyword.fetch(options, :suffix) do {:ok, _options} -> - validate_options("", options) + validate_options(image, "", options) _other -> {:error, @@ -131,10 +131,11 @@ defmodule Image.Options.Write do end end - def validate_options(path, options) when is_binary(path) and is_list(options) do + def validate_options(%Vimage{} = image, path, options) + when is_binary(path) and is_list(options) do 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_type)) do + case Enum.reduce_while(options, options, &validate_option(&1, &2, image, image_type)) do {:error, value} -> {:error, value} @@ -144,20 +145,20 @@ defmodule Image.Options.Write do end end - defp validate_option({:suffix, "." <> _suffix}, options, _image_type) do + defp validate_option({:suffix, "." <> _suffix}, options, _image, _image_type) do {:cont, options} end # :quality for png files is ignored, there's no practical setting # that adjust quality in the same way as other formats. - defp validate_option({:quality, quality}, options, image_type) + defp validate_option({:quality, quality}, options, _image, image_type) when is_png(image_type) and is_integer(quality) and quality in 1..100 do options = Keyword.delete(options, :quality) {:cont, options} end - defp validate_option({:quality, quality}, options, _image_type) + defp validate_option({:quality, quality}, options, _image, _image_type) when is_integer(quality) and quality in 1..100 do options = options @@ -167,12 +168,13 @@ defmodule Image.Options.Write do {:cont, options} end - defp validate_option({:buffer_size, buffer_size}, options, _image_type) + defp validate_option({:buffer_size, buffer_size}, options, _image, _image_type) when (is_integer(buffer_size) and buffer_size >= 0) or buffer_size == :unbuffered do {:cont, options} end - defp validate_option({:strip_metadata, strip?}, options, _image_type) when is_boolean(strip?) do + defp validate_option({:strip_metadata, strip?}, options, _image, _image_type) + when is_boolean(strip?) do options = options |> Keyword.delete(:strip_metadata) @@ -181,7 +183,7 @@ defmodule Image.Options.Write do {:cont, options} end - defp validate_option({:progressive, progressive?}, options, _image_type) + defp validate_option({:progressive, progressive?}, options, _image, _image_type) when is_boolean(progressive?) do options = options @@ -191,12 +193,12 @@ defmodule Image.Options.Write do {:cont, options} end - defp validate_option({:compression, compression}, options, image_type) + defp validate_option({:compression, compression}, options, _image, image_type) when is_png(image_type) and compression in 1..9 do {:cont, options} end - defp validate_option({:compression, compression}, options, image_type) + defp validate_option({:compression, compression}, options, _image, image_type) when is_heif(image_type) and is_map_key(@heif_compression_map, compression) do vips_compression = Map.fetch!(@heif_compression_map, compression) options = Keyword.put(options, :compression, vips_compression) @@ -204,7 +206,7 @@ defmodule Image.Options.Write do {:cont, options} end - defp validate_option({:compression, compression}, options, image_type) + defp validate_option({:compression, compression}, options, _image, image_type) when is_avif(image_type) and is_map_key(@avif_compression_map, compression) do vips_compression = Map.fetch!(@avif_compression_map, compression) options = Keyword.put(options, :compression, vips_compression) @@ -216,7 +218,8 @@ defmodule Image.Options.Write do # Applies only to jpeg save # For maximum compression with mozjpeg, a useful set of options is # strip, optimize-coding, interlace, optimize-scans, trellis-quant, quant_table=3. - defp validate_option({:minimize_file_size, true}, options, image_type) when is_jpg(image_type) do + defp validate_option({:minimize_file_size, true}, options, _image, image_type) + when is_jpg(image_type) do options = options |> Keyword.delete(:minimize_file_size) @@ -231,7 +234,8 @@ defmodule Image.Options.Write do end # Quantize a png image - defp validate_option({:minimize_file_size, true}, options, image_type) when is_png(image_type) do + defp validate_option({:minimize_file_size, true}, options, _image, image_type) + when is_png(image_type) do options = options |> Keyword.delete(:minimize_file_size) @@ -242,7 +246,8 @@ defmodule Image.Options.Write do end # For webp, apply min-size, strip, and mixed (allow mixed encoding which might reduce file size) - defp validate_option({:minimize_file_size, true}, options, image_type) when is_webp(image_type) do + defp validate_option({:minimize_file_size, true}, options, _image, image_type) + when is_webp(image_type) do options = options |> Keyword.delete(:minimize_file_size) @@ -254,7 +259,7 @@ defmodule Image.Options.Write do end # For webp, apply min-size, strip, and mixed (allow mixed encoding which might reduce file size) - defp validate_option({:minimize_file_size, true}, options, image_type) + defp validate_option({:minimize_file_size, true}, options, _image, image_type) when is_heif(image_type) or is_avif(image_type) do options = options @@ -265,12 +270,12 @@ defmodule Image.Options.Write do end # For tiff files, allow the :pyramid option - defp validate_option({:pyramid, pyramid?}, options, image_type) + defp validate_option({:pyramid, pyramid?}, options, _image, image_type) when is_tiff(image_type) and is_boolean(pyramid?) do {:cont, options} end - defp validate_option({:minimize_file_size, false}, options, image_type) + defp validate_option({:minimize_file_size, false}, options, _image, image_type) when is_png(image_type) or is_jpg(image_type) or is_webp(image_type) do options = options @@ -279,7 +284,7 @@ defmodule Image.Options.Write do {:cont, options} end - defp validate_option({:icc_profile, profile}, options, _image_type) + defp validate_option({:icc_profile, profile}, options, _image, _image_type) when is_inbuilt(profile) or is_binary(profile) do options = options @@ -298,18 +303,41 @@ defmodule Image.Options.Write do end end - defp validate_option({:background, background}, options, _image_type) when is_pixel(background) do - {:cont, options} + # `:average` uses the image's average color and any other value is resolved + # by `Image.Pixel.to_pixel/2` (numbers, lists, CSS names, hex strings). + defp validate_option({:background, :average}, options, image, _image_type) do + case Image.average(image) do + color when is_list(color) -> + {:cont, Keyword.put(options, :background, color)} + + {: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, background} = option, options, image, _image_type) do + case Pixel.to_pixel(image, background) do + {:ok, pixel} -> + {:cont, Keyword.put(options, :background, strip_alpha(pixel, image))} + + {:error, _reason} -> + {:halt, {:error, invalid_option(option)}} + end end - defp validate_option({:effort, effort}, options, image_type) + defp validate_option({:effort, effort}, options, _image, image_type) when is_integer(effort) and effort in 1..10 and not is_jpg(image_type) and not is_tiff(image_type) do options = Keyword.put(options, :effort, conform_effort(effort, image_type)) {:cont, options} end - defp validate_option({:interframe_maxerror, int_max_error}, options, image_type) + defp validate_option({:interframe_maxerror, int_max_error}, options, _image, image_type) when is_gif(image_type) and int_max_error in 0..32 do {:cont, options} end @@ -324,7 +352,7 @@ defmodule Image.Options.Write do # * JPEG / GIF / TIFF / HEIF — not supported (JPEG is always # lossy; GIF/TIFF lossiness is structural; HEIF is # handled via `:compression`). - defp validate_option({:lossy, lossy?}, options, image_type) + defp validate_option({:lossy, lossy?}, options, _image, image_type) when is_webp(image_type) and is_boolean(lossy?) do options = options @@ -334,7 +362,7 @@ defmodule Image.Options.Write do {:cont, options} end - defp validate_option({:lossy, lossy?}, options, image_type) + defp validate_option({:lossy, lossy?}, options, _image, image_type) when is_avif(image_type) and is_boolean(lossy?) do options = options @@ -344,7 +372,7 @@ defmodule Image.Options.Write do {:cont, options} end - defp validate_option({:lossy, lossy?}, options, image_type) + defp validate_option({:lossy, lossy?}, options, _image, image_type) when is_png(image_type) and is_boolean(lossy?) do options = options @@ -377,7 +405,7 @@ defmodule Image.Options.Write do off: :VIPS_FOREIGN_SUBSAMPLE_OFF } - defp validate_option({:chroma_subsampling, mode}, options, image_type) + defp validate_option({:chroma_subsampling, mode}, options, _image, image_type) when (is_jpg(image_type) or is_avif(image_type)) and is_map_key(@subsample_mode_map, mode) do options = options @@ -387,10 +415,18 @@ defmodule Image.Options.Write do {:cont, options} end - defp validate_option(option, _options, image_type) do + defp validate_option(option, _options, _image, image_type) do {:halt, {:error, invalid_option(option, image_type)}} end + defp invalid_option(option) do + %Image.Error{ + reason: :invalid_option, + value: option, + message: "Invalid option or option value: #{inspect(option)}" + } + end + defp invalid_option(option, image_type) do "Invalid option or option value: #{inspect(option)} for image type #{inspect(image_type)}" end @@ -415,6 +451,14 @@ defmodule Image.Options.Write do Enum.reduce(@suffix_values, options, &Keyword.delete(&2, &1)) end + defp strip_alpha(pixel, image) do + if Image.has_alpha?(image) do + Enum.take(pixel, length(pixel) - 1) + else + pixel + end + end + # Range 1..10 defp conform_effort(effort, ".png"), do: effort diff --git a/test/image_write_background_test.exs b/test/image_write_background_test.exs new file mode 100644 index 0000000..ad2e06e --- /dev/null +++ b/test/image_write_background_test.exs @@ -0,0 +1,52 @@ +defmodule Image.Write.Background.Test do + use ExUnit.Case, async: true + + # A 4-band (RGBA) image that is fully transparent red. Written to a + # format without alpha (JPEG) the transparent areas are flattened + # onto the `:background`. + setup do + Temp.track() + tmp_dir = Temp.mkdir!() + {:ok, base} = Image.new(8, 8, color: [255, 0, 0]) + {:ok, rgba} = Image.add_alpha(base, 0) + {:ok, %{rgba: rgba, tmp_dir: tmp_dir}} + end + + defp roundtrip_background(tmp_dir, image, background) do + path = Temp.path!(suffix: ".jpg", basedir: tmp_dir) + {:ok, _} = Image.write(image, path, background: background) + written = Image.open!(path) + Image.get_pixel!(written, 4, 4) + end + + # need the tolerance for jpeg saves. no other non-alpha format at hand. + defp close_to?(actual, [r, g, b], tolerance \\ 6) do + [ar, ag, ab] = Enum.take(actual, 3) + abs(ar - r) <= tolerance and abs(ag - g) <= tolerance and abs(ab - b) <= tolerance + end + + describe "Image.write/3 :background color resolution" do + # just test that it goes through `Image.Pixel.to_pixel/2` + test "accepts a CSS named color as a string", %{rgba: rgba, tmp_dir: tmp_dir} do + pixel = roundtrip_background(tmp_dir, rgba, "lime") + assert close_to?(pixel, [0, 255, 0]) + end + + test "accepts a numeric list", %{rgba: rgba, tmp_dir: tmp_dir} do + pixel = roundtrip_background(tmp_dir, rgba, [0, 255, 0]) + assert close_to?(pixel, [0, 255, 0]) + end + + test "accepts :average and flattens onto the image average", %{rgba: rgba, tmp_dir: tmp_dir} do + # The image is solid red under the transparency, so the + # average is red and the flattened background is red. + pixel = roundtrip_background(tmp_dir, rgba, :average) + assert close_to?(pixel, [255, 0, 0]) + end + + test "returns an error for an unknown color", %{rgba: rgba, tmp_dir: tmp_dir} do + path = Temp.path!(suffix: ".jpg", basedir: tmp_dir) + assert {:error, _reason} = Image.write(rgba, path, background: :not_a_real_color) + end + end +end