-
Notifications
You must be signed in to change notification settings - Fork 62
/
mogrify.ex
480 lines (397 loc) · 14.6 KB
/
mogrify.ex
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
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+(?<count>\d+):\s+\((?<red>[\d(?:\.\d+)?)\s]+),(?<green>[\d(?:\.\d+)?)\s]+),(?<blue>[\d(?:\.\d+)?)\s]+)(,(?<alpha>[\d(?:\.\d+)?)\s]+))?\)\s+(?<hex>\#[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: String.split(params)
defp normalize_arguments({"annotate", params}),
do: ["-annotate"] ++ String.split(params, " ", parts: 2)
defp normalize_arguments({"morphology", params}), do: ["-morphology"] ++ String.split(params)
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({:limit, params}), do: ["-limit"] ++ String.split(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(?<animated>\[0])? (?<format>\S+) (?<width>\d+)x(?<height>\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 """
Sets a pixel cache resource limit.
"""
def limit(image, type, value) do
image
|> limit("#{type} #{value}")
end
@doc """
Sets a pixel cache resource limit, in the form of a space-separated string (e.g. `"memory 30mb"`).
"""
def limit(image, params) do
%{image | operations: image.operations ++ [limit: 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