defmodule Mogrify do use Mogrify.Compat alias Mogrify.Compat alias Mogrify.Image alias Mogrify.Option @doc """ Opens image source. """ def open(path) do path = Path.expand(path) unless File.regular?(path), do: raise(File.Error) %Image{path: path, ext: Path.extname(path)} end @doc """ Saves modified image. ## Options * `:path` - The output path of the image. Defaults to a temporary file. * `:in_place` - Overwrite the original image, ignoring `:path` option. Default `false`. """ def save(image, opts \\ []) do cmd_opts = [stderr_to_stdout: true] if opts[:in_place] do final_output_path = if image.dirty[:path], do: image.dirty[:path], else: image.path args = arguments_for_saving_in_place(image) {_, 0} = cmd_mogrify(args, cmd_opts) image_after_command(image, final_output_path) else cmd_output_path = output_path_for(image, opts) final_output_path = Keyword.get(opts, :path, cmd_output_path) create_folder_if_doesnt_exist!(cmd_output_path) create_folder_if_doesnt_exist!(final_output_path) args = arguments_for_saving(image, cmd_output_path) {_, 0} = cmd_convert(args, cmd_opts) # final output path may differ if temporary path was used for image format if cmd_output_path != final_output_path do # copy then rm, because File.rename/2 may fail across filesystem boundary File.copy!(cmd_output_path, final_output_path) File.rm!(cmd_output_path) end image_after_command(image, final_output_path) end end @doc """ Creates or saves image. Uses the `convert` command, which accepts both existing images, or image operators. If you have an existing image, prefer save/2. ## Options * `:path` - The output path of the image. Defaults to a temporary file. * `:in_place` - Overwrite the original image, ignoring `:path` option. Default `false`. * `:buffer` - Pass `true` to write to Collectable in Image.buffer instead of file. * `:into` - Used with `:buffer` to specify a Collectable. Defaults to `""`. See `System.cmd/3`. """ def create(image, opts \\ []) do if opts[:buffer] do cmd_opts = [stderr_to_stdout: false] cmd_opts = if opts[:into], do: cmd_opts ++ [into: opts[:into]], else: cmd_opts {image_collectable, 0} = cmd_convert(arguments(image), cmd_opts) image_after_buffer_command(image, image_collectable) else cmd_opts = [stderr_to_stdout: true] output_path = output_path_for(image, opts) create_folder_if_doesnt_exist!(output_path) {_, 0} = cmd_convert(arguments_for_creating(image, output_path), cmd_opts) image_after_command(image, output_path) end end @doc """ Returns the histogram of the image Runs ImageMagick's `histogram:info:-` command. Results are returned as a list of maps where each map includes keys red, blue, green, hex and count. ## Examples iex> open("test/fixtures/rbgw.png") |> histogram [ %{"alpha" => 255, "blue" => 255, "count" => 400, "green" => 0, "hex" => "#0000ff", "red" => 0}, %{"alpha" => 255, "blue" => 0, "count" => 225, "green" => 255, "hex" => "#00ff00", "red" => 0}, %{"alpha" => 255, "blue" => 0, "count" => 525, "green" => 0, "hex" => "#ff0000", "red" => 255}, %{"alpha" => 255, "blue" => 255, "count" => 1350, "green" => 255, "hex" => "#ffffff", "red" => 255} ] """ def histogram(image) do img = image |> custom("format", "%c") args = arguments(img) ++ [image.path, "histogram:info:-"] res = cmd_convert(args, stderr_to_stdout: false) res |> elem(0) |> process_histogram_output end defp image_after_command(image, output_path) do format = Map.get(image.dirty, :format, image.format) %{ clear_operations(image) | path: output_path, ext: Path.extname(output_path), format: format } end defp image_after_buffer_command(image, image_collectable) do %{ clear_operations(image) | buffer: image_collectable } end defp clear_operations(image) do %{image | operations: [], dirty: %{}} end defp cleanse_histogram(hist) do hist |> Enum.into(%{}, &clean_histogram_entry/1) end defp clean_histogram_entry({"hex", v}), do: {"hex", v} defp clean_histogram_entry({"alpha", ""}), do: {"alpha", 255} defp clean_histogram_entry({k, ""}), do: {k, 0} defp clean_histogram_entry({k, v}), do: {k, v |> Float.parse() |> elem(0) |> Float.round(0) |> trunc} def extract_histogram_data(entry) do ~r/^\s+(?\d+):\s+\((?[\d(?:\.\d+)?)\s]+),(?[\d(?:\.\d+)?)\s]+),(?[\d(?:\.\d+)?)\s]+)(,(?[\d(?:\.\d+)?)\s]+))?\)\s+(?\#[abcdef\d]{6,8})\s+/i |> Regex.named_captures(entry) |> Enum.map(fn {k, v} -> {k, v |> Compat.string_trim()} end) |> cleanse_histogram end defp process_histogram_output(histogram_output) do histogram_output |> String.split("\n") |> Enum.reject(fn s -> s |> String.length() == 0 end) |> Enum.map(&extract_histogram_data/1) end defp output_path_for(image, save_opts) do cond do save_opts[:in_place] -> image.path image.dirty[:path] -> temporary_path_for(image) # temp file to ensure image format applied save_opts[:path] -> save_opts[:path] true -> temporary_path_for(image) end end # used with `convert` defp arguments_for_saving(image, path) do [image.path] ++ arguments(image) ++ [path] end # used with `mogrify` defp arguments_for_saving_in_place(image) do arguments(image) ++ [image.path] end defp arguments_for_creating(image, path) do basename = if image.path, do: Path.basename(image.path), else: Path.basename(path) base_arguments = [Path.join(Path.dirname(path), basename)] arguments(image) ++ base_arguments end defp arguments(image) do Enum.flat_map(image.operations, &normalize_arguments/1) end defp normalize_arguments({:image_operator, params}), do: ~w(#{params}) defp normalize_arguments({"annotate", params}), do: ["-annotate"] ++ String.split(params, " ", parts: 2) defp normalize_arguments({"histogram:" <> option, nil}), do: ["histogram:#{option}"] defp normalize_arguments({"pango", params}), do: ["pango:#{params}"] defp normalize_arguments({"stdout", params}), do: ["#{params}"] defp normalize_arguments({"plasma", params}), do: ["plasma:#{params}"] defp normalize_arguments({"canvas", params}), do: ["canvas:#{params}"] defp normalize_arguments({"+" <> option, nil}), do: ["+#{option}"] defp normalize_arguments({"-" <> option, nil}), do: ["-#{option}"] defp normalize_arguments({option, nil}), do: ["-#{option}"] defp normalize_arguments({"+" <> option, params}), do: ["+#{option}", to_string(params)] defp normalize_arguments({"-" <> option, params}), do: ["-#{option}", to_string(params)] defp normalize_arguments({option, params}), do: ["-#{option}", to_string(params)] @doc """ Makes a copy of original image. """ def copy(image) do temp = temporary_path_for(image) File.cp!(image.path, temp) Map.put(image, :path, temp) end def temporary_path_for(%{dirty: %{path: dirty_path}} = _image) do do_temporary_path_for(dirty_path) end def temporary_path_for(%{path: path} = _image) do do_temporary_path_for(path) end defp do_temporary_path_for(path) do name = if path, do: Path.basename(path), else: Compat.rand_uniform(999_999) random = Compat.rand_uniform(999_999) Path.join(System.tmp_dir(), "#{random}-#{name}") end @doc """ Provides detailed information about the image. This corresponds to the `mogrify -verbose` output which is similar to `identify`. It does NOT correspond to `identify -verbose` which prints out much more information. """ def verbose(image) do Map.merge(image, identify(image.path)) end @doc """ Provides "identify" information about an image. """ def identify(file_path) do args = [file_path] {output, 0} = cmd_identify(args, stderr_to_stdout: false) output |> image_information_string_to_map() |> put_frame_count(output) end @doc """ Provides "identify" information about an image with raw access attribute. Example: identify(file_path, format: "'%[orientation]'") """ def identify(file_path, format: option) do args = ["-format"] ++ [option] ++ [file_path] {output, 0} = cmd_identify(args, stderr_to_stdout: false) output |> String.replace("'", "") end defp image_information_string_to_map(image_information_string) do ~r/\b(?\[0])? (?\S+) (?\d+)x(?\d+)/ |> Regex.named_captures(image_information_string) |> Enum.map(&normalize_verbose_term/1) |> Enum.into(%{}) end defp normalize_verbose_term({"animated", "[0]"}), do: {:animated, true} defp normalize_verbose_term({"animated", ""}), do: {:animated, false} defp normalize_verbose_term({key, value}) when key in ["width", "height"] do {String.to_atom(key), String.to_integer(value)} end defp normalize_verbose_term({key, value}), do: {String.to_atom(key), String.downcase(value)} defp put_frame_count(%{animated: false} = map, _), do: Map.put(map, :frame_count, 1) defp put_frame_count(map, text) do # skip the [0] lines which may be duplicated matches = Regex.scan(~r/\b\[[1-9][0-9]*] \S+ \d+x\d+/, text) # add 1 for the skipped [0] frame frame_count = length(matches) + 1 Map.put(map, :frame_count, frame_count) end @doc """ Converts the image to the image format you specify. """ def format(image, format) do downcase_format = String.downcase(format) ext = ".#{downcase_format}" rootname = Path.rootname(image.path, image.ext) %{ image | operations: image.operations ++ [format: format], dirty: %{path: "#{rootname}#{ext}", format: downcase_format, ext: ext} |> Enum.into(image.dirty) } end @doc """ Resizes the image with provided geometry. """ def resize(image, params) do %{image | operations: image.operations ++ [resize: params]} end @doc """ Changes quality of the image to desired quality. """ def quality(image, params) do %{image | operations: image.operations ++ [quality: params]} end @doc """ Extends the image to the specified dimensions. """ def extent(image, params) do %{image | operations: image.operations ++ [extent: params]} end @doc """ Sets the gravity of the image. """ def gravity(image, params) do %{image | operations: image.operations ++ [gravity: params]} end @doc """ Resize the image to fit within the specified dimensions while retaining the original aspect ratio. Will only resize the image if it is larger than the specified dimensions. The resulting image may be shorter or narrower than specified in the smaller dimension but will not be larger than the specified values. """ def resize_to_limit(image, params) do resize(image, "#{params}>") end @doc """ Resize the image to fit within the specified dimensions while retaining the aspect ratio of the original image. If necessary, crop the image in the larger dimension. """ def resize_to_fill(image, params) do [_, width, height] = Regex.run(~r/(\d+)x(\d+)/, params) image = Mogrify.verbose(image) {width, _} = Float.parse(width) {height, _} = Float.parse(height) cols = image.width rows = image.height if width != cols || height != rows do # .to_f scale_x = width / cols # .to_f scale_y = height / rows larger_scale = max(scale_x, scale_y) cols = (larger_scale * (cols + 0.5)) |> Float.round() rows = (larger_scale * (rows + 0.5)) |> Float.round() image = resize(image, if(scale_x >= scale_y, do: "#{cols}", else: "x#{rows}")) if width != cols || height != rows do extent(image, params) else image end else image end end def auto_orient(image) do %{image | operations: image.operations ++ ["auto-orient": nil]} end def canvas(image, color) do image_operator(image, "xc:#{color}") end def add_option(image, option) do validate_option!(option) custom(image, option.name, option.argument) end def custom(image, action, options \\ nil) do %{image | operations: image.operations ++ [{action, options}]} end def image_operator(image, operator) do %{image | operations: image.operations ++ [{:image_operator, operator}]} end defp valid_option?(%Option{require_arg: true, argument: nil}), do: false defp valid_option?(_), do: true defp validate_option!(%Option{name: name} = option) do if valid_option?(option) do option else [prefix, leading] = extract_prefix_and_leading(name) option_name = name |> String.replace_leading(leading, "") |> String.replace("-", "_") raise ArgumentError, message: "the option #{option_name} need arguments. Be sure to pass arguments to option_#{prefix}#{ option_name }(arg)" end end defp extract_prefix_and_leading(name) do if String.contains?(name, "+") do ["plus_", "+"] else ["", "-"] end end defp cmd_magick(tool, args, opts) do {command, additional_args} = command_options(tool) System.cmd(command, additional_args ++ args, opts) rescue e in [ErlangError] -> if e.original == :enoent do raise "missing prerequisite: '#{tool}'" else reraise e, __STACKTRACE__ end end defp cmd_mogrify(args, opts), do: cmd_magick(:mogrify, args, opts) defp cmd_identify(args, opts), do: cmd_magick(:identify, args, opts) @doc false def cmd_convert(args, opts), do: cmd_magick(:convert, args, opts) defp create_folder_if_doesnt_exist!(path) do path |> Path.dirname() |> File.mkdir_p!() end defp command_options(command) do config = Application.get_env(:mogrify, :"#{command}_command", []) path = Keyword.get(config, :path) args = Keyword.get(config, :args, []) if path do {path, args} else default_command(command) end end defp default_command(command) do case :os.type() do {:win32, _} -> {"magick", ["#{command}"]} _ -> {"#{command}", []} end end end