diff --git a/src/ansel.ex b/src/ansel.ex index 6482dc3..515d5cb 100644 --- a/src/ansel.ex +++ b/src/ansel.ex @@ -5,17 +5,17 @@ defmodule Ansel do Path.expand(path) |> Image.new_from_file() end - def write_to_file(img, path) do - save_path = Path.expand(path) + def write_to_file(img, path, {:format_components, extension, options}) do + save_path = Path.expand(path <> extension) - case Image.write_to_file(img, save_path) do - :ok -> {:ok, nil} + case Image.write_to_file(img, save_path <> options) do + :ok -> {:ok, save_path} {:error, reason} -> {:error, reason} end end - def to_bit_array(image, format) do - Image.write_to_stream(image, format) |> Enum.into(<<>>) + def to_bit_array(image, {:format_components, extension, options}) do + Image.write_to_stream(image, extension <> options) |> Enum.into(<<>>) end def to_rgb_list(image) do diff --git a/src/ansel/image.gleam b/src/ansel/image.gleam index 7f3a615..e62ea09 100644 --- a/src/ansel/image.gleam +++ b/src/ansel/image.gleam @@ -42,9 +42,11 @@ import snag /// own vips binary on the host system to. See the package readme for details. /// /// The `Custom` constructor allows for advanced vips save options to be -/// used, like `ansel.Custom(".png[compression=90,squash=true]"), refer to the -/// [Vix package documentation](https://hexdocs.pm/vix/Vix.Vips.Image.html#write_to_file/2) -/// for details. +/// used as a comma separated list, like `ansel.Custom(".png", "compression=90,squash=true")`, +/// which is equivalent to the `.png[compression=90,squash=true]` syntax used in vips. +/// +/// You can use the libvips CLI to print a list of all supported options for each image format, +/// e.g. `vips pngsave`, `vips jpegsave`. pub type ImageFormat { JPEG(quality: Int, keep_metadata: Bool) JPEG2000(quality: Int, keep_metadata: Bool) @@ -65,38 +67,43 @@ pub type ImageFormat { Analyze NIfTI DeepZoom - Custom(format: String) + Custom(extension: String, format: String) +} + +type FormatComponents { + FormatComponents(extension: String, options: String) } -fn image_format_to_string(format: ImageFormat) -> String { +fn image_format_to_string(format: ImageFormat) -> FormatComponents { case format { JPEG(quality:, keep_metadata:) -> - ".jpeg" <> format_common_options(quality, keep_metadata) + FormatComponents(".jpeg", format_common_options(quality, keep_metadata)) JPEG2000(quality:, keep_metadata:) -> - ".jp2" <> format_common_options(quality, keep_metadata) + FormatComponents(".jp2", format_common_options(quality, keep_metadata)) JPEGXL(quality:, keep_metadata:) -> - ".jxl" <> format_common_options(quality, keep_metadata) - PNG -> ".png" + FormatComponents(".jxl", format_common_options(quality, keep_metadata)) + PNG -> FormatComponents(".png", "") WebP(quality:, keep_metadata:) -> - ".webp" <> format_common_options(quality, keep_metadata) + FormatComponents(".webp", format_common_options(quality, keep_metadata)) AVIF(quality:, keep_metadata:) -> - ".avif" <> format_common_options(quality, keep_metadata) + FormatComponents(".avif", format_common_options(quality, keep_metadata)) TIFF(quality:, keep_metadata:) -> - ".tiff" <> format_common_options(quality, keep_metadata) + FormatComponents(".tiff", format_common_options(quality, keep_metadata)) HEIC(quality:, keep_metadata:) -> - ".heic" <> format_common_options(quality, keep_metadata) - FITS -> ".fits" - Matlab -> ".mat" - PDF -> ".pdf" - SVG -> ".svg" - HDR -> ".hdr" - PPM -> ".ppm" - CSV -> ".csv" - GIF -> ".gif" - Analyze -> ".analyze" - NIfTI -> ".nii" - DeepZoom -> ".dzi" - Custom(format:) -> format + FormatComponents(".heic", format_common_options(quality, keep_metadata)) + FITS -> FormatComponents(".fits", "") + Matlab -> FormatComponents(".mat", "") + PDF -> FormatComponents(".pdf", "") + SVG -> FormatComponents(".svg", "") + HDR -> FormatComponents(".hdr", "") + PPM -> FormatComponents(".ppm", "") + CSV -> FormatComponents(".csv", "") + GIF -> FormatComponents(".gif", "") + Analyze -> FormatComponents(".analyze", "") + NIfTI -> FormatComponents(".nii", "") + DeepZoom -> FormatComponents(".dzi", "") + Custom(extension:, format:) -> + FormatComponents(extension, "[" <> format <> "]") } } @@ -173,7 +180,7 @@ pub fn to_bit_array(img: ansel.Image, format: ImageFormat) -> BitArray { } @external(erlang, "Elixir.Ansel", "to_bit_array") -fn to_bit_array_ffi(img: ansel.Image, format: String) -> BitArray +fn to_bit_array_ffi(img: ansel.Image, format: FormatComponents) -> BitArray /// Converts a vips image into a matrix of pixel values. /// @@ -491,18 +498,32 @@ pub fn scale( fn resize_ffi(img: ansel.Image, scale: Float) -> Result(ansel.Image, String) /// Writes an image to the specified path in the specified format. +/// A filename extension is added automatically to the path, based on the format, +/// and therefore should not be provided by the user. +/// Returns the absolute path to the image as it was written to disk, including the extension. +/// +/// ## Example +/// ```gleam +/// image.new(6, 6, color.Olive) +/// |> image.write(to: "wobble", in: image.PNG) +/// // -> Ok("Users/lucy/myproject/wobble.png") +/// ``` pub fn write( image img: ansel.Image, to path: String, in format: ImageFormat, -) -> Result(Nil, snag.Snag) { - write_ffi(img, path <> image_format_to_string(format)) +) -> Result(String, snag.Snag) { + write_ffi(img, path, image_format_to_string(format)) |> result.map_error(snag.new) |> snag.context("Unable to write image to file") } @external(erlang, "Elixir.Ansel", "write_to_file") -fn write_ffi(img: ansel.Image, to path: String) -> Result(Nil, String) +fn write_ffi( + img: ansel.Image, + to path: String, + using format: FormatComponents, +) -> Result(String, String) /// Reads an image from the specified path. pub fn read(from path: String) -> Result(ansel.Image, snag.Snag) { diff --git a/test/image_test.gleam b/test/image_test.gleam index 07840df..64a6933 100644 --- a/test/image_test.gleam +++ b/test/image_test.gleam @@ -3,6 +3,7 @@ import ansel/color import ansel/image import gleam/list import gleam/result +import gleam/string import gleeunit import gleeunit/should import simplifile @@ -23,6 +24,37 @@ pub fn read_test() { |> should.equal(6) } +pub fn write_test() { + let path = "test/tmp_write_test_asset" + + let output_path = + image.new(5, 5, color.GleamLucy) + |> should.be_ok + |> image.write(path, image.JPEG(quality: 100, keep_metadata: True)) + |> should.be_ok() + + string.ends_with(output_path, "jpeg") + |> should.be_true() + + image.read(output_path) + |> should.be_ok + |> image.get_width + |> should.equal(5) + + let assert Ok(_) = simplifile.delete(output_path) +} + +pub fn custom_options_test() { + let assert Ok(reference_image) = image.new(6, 6, color.Grey) + let output = + reference_image + |> image.to_bit_array(image.JPEG(quality: 50, keep_metadata: True)) + + reference_image + |> image.to_bit_array(image.Custom(".jpeg", "Q=50,strip=false")) + |> should.equal(output) +} + pub fn new_solid_grey_test() { let assert Ok(bin) = simplifile.read_bits("test/resources/solid_grey_6x6.avif")