image_processing 1.2.0 → 1.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +143 -47
- data/README.md +62 -16
- data/image_processing.gemspec +8 -8
- data/lib/image_processing/builder.rb +19 -2
- data/lib/image_processing/chainable.rb +59 -32
- data/lib/image_processing/mini_magick.rb +174 -74
- data/lib/image_processing/pipeline.rb +39 -28
- data/lib/image_processing/processor.rb +60 -11
- data/lib/image_processing/version.rb +1 -1
- data/lib/image_processing/vips.rb +144 -60
- metadata +25 -18
@@ -5,6 +5,7 @@ module ImageProcessing
|
|
5
5
|
module MiniMagick
|
6
6
|
extend Chainable
|
7
7
|
|
8
|
+
# Returns whether the given image file is processable.
|
8
9
|
def self.valid_image?(file)
|
9
10
|
::MiniMagick::Tool::Convert.new do |convert|
|
10
11
|
convert << file.path
|
@@ -16,125 +17,224 @@ module ImageProcessing
|
|
16
17
|
end
|
17
18
|
|
18
19
|
class Processor < ImageProcessing::Processor
|
19
|
-
|
20
|
+
accumulator :magick, ::MiniMagick::Tool
|
21
|
+
|
22
|
+
# Default sharpening parameters used on generated thumbnails.
|
20
23
|
SHARPEN_PARAMETERS = { radius: 0, sigma: 1 }
|
21
24
|
|
22
|
-
|
23
|
-
|
25
|
+
# Initializes the image on disk into a MiniMagick::Tool object. Accepts
|
26
|
+
# additional options related to loading the image (e.g. geometry).
|
27
|
+
# Additionally auto-orients the image to be upright.
|
28
|
+
def self.load_image(path_or_magick, loader: nil, page: nil, geometry: nil, auto_orient: true, **options)
|
29
|
+
if path_or_magick.is_a?(::MiniMagick::Tool)
|
30
|
+
magick = path_or_magick
|
31
|
+
else
|
32
|
+
source_path = path_or_magick
|
33
|
+
magick = ::MiniMagick::Tool::Convert.new
|
34
|
+
|
35
|
+
Utils.apply_options(magick, **options)
|
36
|
+
|
37
|
+
input = source_path
|
38
|
+
input = "#{loader}:#{input}" if loader
|
39
|
+
input += "[#{page}]" if page
|
40
|
+
input += "[#{geometry}]" if geometry
|
41
|
+
|
42
|
+
magick << input
|
43
|
+
end
|
44
|
+
|
45
|
+
magick.auto_orient if auto_orient
|
46
|
+
magick
|
47
|
+
end
|
48
|
+
|
49
|
+
# Calls the built ImageMagick command to perform processing and save
|
50
|
+
# the result to disk. Accepts additional options related to saving the
|
51
|
+
# image (e.g. quality).
|
52
|
+
def self.save_image(magick, destination_path, allow_splitting: false, **options)
|
53
|
+
Utils.apply_options(magick, **options)
|
54
|
+
|
55
|
+
magick << destination_path
|
56
|
+
magick.call
|
57
|
+
|
58
|
+
Utils.disallow_split_layers!(destination_path) unless allow_splitting
|
24
59
|
end
|
25
60
|
|
26
|
-
|
27
|
-
|
61
|
+
# Resizes the image to not be larger than the specified dimensions.
|
62
|
+
def resize_to_limit(width, height, **options)
|
63
|
+
thumbnail("#{width}x#{height}>", **options)
|
28
64
|
end
|
29
65
|
|
30
|
-
|
31
|
-
|
66
|
+
# Resizes the image to fit within the specified dimensions.
|
67
|
+
def resize_to_fit(width, height, **options)
|
68
|
+
thumbnail("#{width}x#{height}", **options)
|
69
|
+
end
|
70
|
+
|
71
|
+
# Resizes the image to fill the specified dimensions, applying any
|
72
|
+
# necessary cropping.
|
73
|
+
def resize_to_fill(width, height, gravity: "Center", **options)
|
74
|
+
thumbnail("#{width}x#{height}^", **options)
|
32
75
|
magick.gravity gravity
|
33
|
-
magick.background
|
76
|
+
magick.background color(:transparent)
|
34
77
|
magick.extent "#{width}x#{height}"
|
35
78
|
end
|
36
79
|
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
thumbnail(
|
41
|
-
magick.background background
|
80
|
+
# Resizes the image to fit within the specified dimensions and fills
|
81
|
+
# the remaining area with the specified background color.
|
82
|
+
def resize_and_pad(width, height, background: :transparent, gravity: "Center", **options)
|
83
|
+
thumbnail("#{width}x#{height}", **options)
|
84
|
+
magick.background color(background)
|
42
85
|
magick.gravity gravity
|
43
86
|
magick.extent "#{width}x#{height}"
|
44
87
|
end
|
45
88
|
|
46
|
-
|
47
|
-
|
89
|
+
# Crops the image with the specified crop points.
|
90
|
+
def crop(*args)
|
91
|
+
case args.count
|
92
|
+
when 1 then magick.crop(*args)
|
93
|
+
when 4 then magick.crop("#{args[2]}x#{args[3]}+#{args[0]}+#{args[1]}")
|
94
|
+
else fail ArgumentError, "wrong number of arguments (expected 1 or 4, got #{args.count})"
|
95
|
+
end
|
96
|
+
end
|
48
97
|
|
49
|
-
|
50
|
-
|
98
|
+
# Rotates the image by an arbitrary angle. For angles that are not
|
99
|
+
# multiple of 90 degrees an optional background color can be specified to
|
100
|
+
# fill in the gaps.
|
101
|
+
def rotate(degrees, background: nil)
|
102
|
+
magick.background color(background) if background
|
103
|
+
magick.rotate(degrees)
|
104
|
+
end
|
51
105
|
|
52
|
-
|
53
|
-
|
106
|
+
# Overlays the specified image over the current one. Supports specifying
|
107
|
+
# an additional mask, composite mode, direction or offset of the overlay
|
108
|
+
# image.
|
109
|
+
def composite(overlay = :none, mask: nil, mode: nil, gravity: nil, offset: nil, args: nil, **options, &block)
|
110
|
+
return magick.composite if overlay == :none
|
54
111
|
|
55
|
-
|
56
|
-
|
112
|
+
if options.key?(:compose)
|
113
|
+
warn "[IMAGE_PROCESSING] The :compose parameter in #composite has been renamed to :mode, the :compose alias will be removed in ImageProcessing 2."
|
114
|
+
mode = options[:compose]
|
57
115
|
end
|
58
116
|
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
prepend_args(magick, limit_args)
|
65
|
-
end
|
117
|
+
if options.key?(:geometry)
|
118
|
+
warn "[IMAGE_PROCESSING] The :geometry parameter in #composite has been deprecated and will be removed in ImageProcessing 2. Use :offset instead, e.g. `geometry: \"+10+15\"` should be replaced with `offset: [10, 15]`."
|
119
|
+
geometry = options[:geometry]
|
120
|
+
end
|
121
|
+
geometry = "%+d%+d" % offset if offset
|
66
122
|
|
67
|
-
|
68
|
-
|
69
|
-
end
|
123
|
+
overlay_path = convert_to_path(overlay, "overlay")
|
124
|
+
mask_path = convert_to_path(mask, "mask") if mask
|
70
125
|
|
71
|
-
|
72
|
-
if
|
73
|
-
magick = path_or_magick
|
74
|
-
else
|
75
|
-
source_path = path_or_magick
|
76
|
-
magick = ::MiniMagick::Tool::Convert.new
|
126
|
+
magick << overlay_path
|
127
|
+
magick << mask_path if mask_path
|
77
128
|
|
78
|
-
|
129
|
+
magick.compose(mode) if mode
|
130
|
+
define(compose: { args: args }) if args
|
79
131
|
|
80
|
-
|
81
|
-
|
82
|
-
input_path += "[#{geometry}]" if geometry
|
132
|
+
magick.gravity(gravity) if gravity
|
133
|
+
magick.geometry(geometry) if geometry
|
83
134
|
|
84
|
-
|
85
|
-
end
|
135
|
+
yield magick if block_given?
|
86
136
|
|
87
|
-
magick.
|
88
|
-
magick
|
137
|
+
magick.composite
|
89
138
|
end
|
90
139
|
|
91
|
-
|
92
|
-
|
140
|
+
# Defines settings from the provided hash.
|
141
|
+
def define(options)
|
142
|
+
return magick.define(options) if options.is_a?(String)
|
143
|
+
Utils.apply_define(magick, options)
|
144
|
+
end
|
93
145
|
|
94
|
-
|
95
|
-
|
146
|
+
# Specifies resource limits from the provided hash.
|
147
|
+
def limits(options)
|
148
|
+
options.each { |type, value| magick.args.unshift("-limit", type.to_s, value.to_s) }
|
149
|
+
magick
|
150
|
+
end
|
96
151
|
|
97
|
-
|
152
|
+
# Appends a raw ImageMagick command-line argument to the command.
|
153
|
+
def append(*args)
|
154
|
+
magick.merge! args
|
98
155
|
end
|
99
156
|
|
100
157
|
private
|
101
158
|
|
102
|
-
|
159
|
+
# Converts the given color value into an identifier ImageMagick understands.
|
160
|
+
# This supports specifying RGB(A) values with arrays, which mainly exists
|
161
|
+
# for compatibility with the libvips implementation.
|
162
|
+
def color(value)
|
163
|
+
return "rgba(255,255,255,0.0)" if value.to_s == "transparent"
|
164
|
+
return "rgb(#{value.join(",")})" if value.is_a?(Array) && value.count == 3
|
165
|
+
return "rgba(#{value.join(",")})" if value.is_a?(Array) && value.count == 4
|
166
|
+
return value if value.is_a?(String)
|
167
|
+
|
168
|
+
raise ArgumentError, "unrecognized color format: #{value.inspect} (must be one of: string, 3-element RGB array, 4-element RGBA array)"
|
169
|
+
end
|
170
|
+
|
171
|
+
# Resizes the image using the specified geometry, and sharpens the
|
172
|
+
# resulting thumbnail.
|
173
|
+
def thumbnail(geometry, sharpen: nil)
|
103
174
|
magick.resize(geometry)
|
104
|
-
|
175
|
+
|
176
|
+
if sharpen
|
177
|
+
sharpen = SHARPEN_PARAMETERS.merge(sharpen)
|
178
|
+
magick.sharpen("#{sharpen[:radius]}x#{sharpen[:sigma]}")
|
179
|
+
end
|
180
|
+
|
105
181
|
magick
|
106
182
|
end
|
107
183
|
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
184
|
+
# Converts the image on disk in various forms into a path.
|
185
|
+
def convert_to_path(file, name)
|
186
|
+
if file.is_a?(String)
|
187
|
+
file
|
188
|
+
elsif file.respond_to?(:to_path)
|
189
|
+
file.to_path
|
190
|
+
elsif file.respond_to?(:path)
|
191
|
+
file.path
|
192
|
+
else
|
193
|
+
raise ArgumentError, "#{name} must be a String, Pathname, or respond to #path"
|
194
|
+
end
|
113
195
|
end
|
114
196
|
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
197
|
+
module Utils
|
198
|
+
module_function
|
199
|
+
|
200
|
+
# When a multi-layer format is being converted into a single-layer
|
201
|
+
# format, ImageMagick will create multiple images, one for each layer.
|
202
|
+
# We want to warn the user that this is probably not what they wanted.
|
203
|
+
def disallow_split_layers!(destination_path)
|
204
|
+
layers = Dir[destination_path.sub(/(\.\w+)?$/, '-*\0')]
|
205
|
+
|
206
|
+
if layers.any?
|
207
|
+
layers.each { |path| File.delete(path) }
|
208
|
+
raise Error, "Source format is multi-layer, but destination format is single-layer. If you care only about the first layer, add `.loader(page: 0)` to your pipeline. If you want to process each layer, see https://github.com/janko/image_processing/wiki/Splitting-a-PDF-into-multiple-images or use `.saver(allow_splitting: true)`."
|
121
209
|
end
|
122
210
|
end
|
123
211
|
|
124
|
-
|
125
|
-
|
212
|
+
# Applies options from the provided hash.
|
213
|
+
def apply_options(magick, define: {}, **options)
|
214
|
+
options.each do |option, value|
|
215
|
+
case value
|
216
|
+
when true, nil then magick.send(option)
|
217
|
+
when false then magick.send(option).+
|
218
|
+
else magick.send(option, *value)
|
219
|
+
end
|
220
|
+
end
|
126
221
|
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
222
|
+
apply_define(magick, define)
|
223
|
+
end
|
224
|
+
|
225
|
+
# Applies settings from the provided (nested) hash.
|
226
|
+
def apply_define(magick, options)
|
227
|
+
options.each do |namespace, settings|
|
228
|
+
namespace = namespace.to_s.tr("_", "-")
|
131
229
|
|
132
|
-
|
133
|
-
|
230
|
+
settings.each do |key, value|
|
231
|
+
key = key.to_s.tr("_", "-")
|
232
|
+
|
233
|
+
magick.define "#{namespace}:#{key}=#{value}"
|
234
|
+
end
|
235
|
+
end
|
134
236
|
|
135
|
-
|
136
|
-
layers.each { |path| File.delete(path) }
|
137
|
-
raise Error, "Multi-layer image is being converted into a single-layer format. You should either process individual layers or set :allow_splitting to true. See https://github.com/janko-m/image_processing/wiki/Splitting-a-PDF-into-multiple-images for how to process each layer individually."
|
237
|
+
magick
|
138
238
|
end
|
139
239
|
end
|
140
240
|
end
|
@@ -4,50 +4,60 @@ module ImageProcessing
|
|
4
4
|
class Pipeline
|
5
5
|
DEFAULT_FORMAT = "jpg"
|
6
6
|
|
7
|
-
attr_reader :
|
7
|
+
attr_reader :loader, :saver, :format, :operations, :processor, :destination
|
8
8
|
|
9
|
+
# Initializes the pipeline with all the processing options.
|
9
10
|
def initialize(options)
|
11
|
+
fail Error, "source file is not provided" unless options[:source]
|
12
|
+
|
10
13
|
options.each do |name, value|
|
11
|
-
value = normalize_source(value, options) if name == :source
|
12
14
|
instance_variable_set(:"@#{name}", value)
|
13
15
|
end
|
14
16
|
end
|
15
17
|
|
18
|
+
# Determines the destination and calls the processor.
|
16
19
|
def call(save: true)
|
17
|
-
processor = processor_class.new(self)
|
18
|
-
image = processor.load_image(source, **loader)
|
19
|
-
|
20
|
-
operations.each do |name, args|
|
21
|
-
image = processor.apply_operation(name, image, *args)
|
22
|
-
end
|
23
|
-
|
24
20
|
if save == false
|
25
|
-
|
21
|
+
call_processor
|
26
22
|
elsif destination
|
27
23
|
handle_destination do
|
28
|
-
|
24
|
+
call_processor(destination: destination)
|
29
25
|
end
|
30
26
|
else
|
31
27
|
create_tempfile do |tempfile|
|
32
|
-
|
28
|
+
call_processor(destination: tempfile.path)
|
33
29
|
end
|
34
30
|
end
|
35
31
|
end
|
36
32
|
|
33
|
+
# Retrieves the source path on disk.
|
37
34
|
def source_path
|
38
35
|
source if source.is_a?(String)
|
39
36
|
end
|
40
37
|
|
38
|
+
# Determines the appropriate destination image format.
|
41
39
|
def destination_format
|
42
|
-
format =
|
40
|
+
format = determine_format(destination) if destination
|
43
41
|
format ||= self.format
|
44
|
-
format ||=
|
42
|
+
format ||= determine_format(source_path) if source_path
|
45
43
|
|
46
44
|
format || DEFAULT_FORMAT
|
47
45
|
end
|
48
46
|
|
49
47
|
private
|
50
48
|
|
49
|
+
def call_processor(**options)
|
50
|
+
processor.call(
|
51
|
+
source: source,
|
52
|
+
loader: loader,
|
53
|
+
operations: operations,
|
54
|
+
saver: saver,
|
55
|
+
**options
|
56
|
+
)
|
57
|
+
end
|
58
|
+
|
59
|
+
# Creates a new tempfile for the destination file, yields it, and refreshes
|
60
|
+
# the file descriptor to get the updated file.
|
51
61
|
def create_tempfile
|
52
62
|
tempfile = Tempfile.new(["image_processing", ".#{destination_format}"], binmode: true)
|
53
63
|
|
@@ -71,22 +81,23 @@ module ImageProcessing
|
|
71
81
|
raise
|
72
82
|
end
|
73
83
|
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
source
|
83
|
-
elsif source.respond_to?(:path)
|
84
|
-
source.path
|
85
|
-
elsif source.respond_to?(:to_path)
|
86
|
-
source.to_path
|
84
|
+
# Converts the source image object into a path or the accumulator object.
|
85
|
+
def source
|
86
|
+
if @source.is_a?(String)
|
87
|
+
@source
|
88
|
+
elsif @source.respond_to?(:path)
|
89
|
+
@source.path
|
90
|
+
elsif @source.respond_to?(:to_path)
|
91
|
+
@source.to_path
|
87
92
|
else
|
88
|
-
|
93
|
+
@source
|
89
94
|
end
|
90
95
|
end
|
96
|
+
|
97
|
+
def determine_format(file_path)
|
98
|
+
extension = File.extname(file_path)
|
99
|
+
|
100
|
+
extension[1..-1] if extension.size > 1
|
101
|
+
end
|
91
102
|
end
|
92
103
|
end
|
@@ -1,23 +1,72 @@
|
|
1
1
|
module ImageProcessing
|
2
|
+
# Abstract class inherited by individual processors.
|
2
3
|
class Processor
|
3
|
-
def
|
4
|
-
|
5
|
-
|
4
|
+
def self.call(source:, loader:, operations:, saver:, destination: nil)
|
5
|
+
unless source.is_a?(String) || source.is_a?(self::ACCUMULATOR_CLASS)
|
6
|
+
fail Error, "invalid source: #{source.inspect}"
|
7
|
+
end
|
8
|
+
|
9
|
+
if operations.dig(0, 0).to_s.start_with?("resize_") &&
|
10
|
+
loader.empty? &&
|
11
|
+
supports_resize_on_load?
|
12
|
+
|
13
|
+
accumulator = source
|
14
|
+
else
|
15
|
+
accumulator = load_image(source, **loader)
|
16
|
+
end
|
17
|
+
|
18
|
+
operations.each do |operation|
|
19
|
+
accumulator = apply_operation(accumulator, operation)
|
20
|
+
end
|
6
21
|
|
7
|
-
|
8
|
-
|
9
|
-
public_send(name, image, *args)
|
22
|
+
if destination
|
23
|
+
save_image(accumulator, destination, **saver)
|
10
24
|
else
|
11
|
-
|
25
|
+
accumulator
|
12
26
|
end
|
13
27
|
end
|
14
28
|
|
15
|
-
|
16
|
-
|
29
|
+
# Use for processor subclasses to specify the name and the class of their
|
30
|
+
# accumulator object (e.g. MiniMagick::Tool or Vips::Image).
|
31
|
+
def self.accumulator(name, klass)
|
32
|
+
define_method(name) { @accumulator }
|
33
|
+
protected(name)
|
34
|
+
const_set(:ACCUMULATOR_CLASS, klass)
|
17
35
|
end
|
18
36
|
|
19
|
-
|
37
|
+
# Delegates to #apply_operation.
|
38
|
+
def self.apply_operation(accumulator, (name, args, block))
|
39
|
+
new(accumulator).apply_operation(name, *args, &block)
|
40
|
+
end
|
20
41
|
|
21
|
-
|
42
|
+
# Whether the processor supports resizing the image upon loading.
|
43
|
+
def self.supports_resize_on_load?
|
44
|
+
false
|
45
|
+
end
|
46
|
+
|
47
|
+
def initialize(accumulator = nil)
|
48
|
+
@accumulator = accumulator
|
49
|
+
end
|
50
|
+
|
51
|
+
# Calls the operation to perform the processing. If the operation is
|
52
|
+
# defined on the processor (macro), calls the method. Otherwise calls the
|
53
|
+
# operation directly on the accumulator object. This provides a common
|
54
|
+
# umbrella above defined macros and direct operations.
|
55
|
+
def apply_operation(name, *args, &block)
|
56
|
+
receiver = respond_to?(name) ? self : @accumulator
|
57
|
+
|
58
|
+
if args.last.is_a?(Hash)
|
59
|
+
kwargs = args.pop
|
60
|
+
receiver.public_send(name, *args, **kwargs, &block)
|
61
|
+
else
|
62
|
+
receiver.public_send(name, *args, &block)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
# Calls the given block with the accumulator object. Useful for when you
|
67
|
+
# want to access the accumulator object directly.
|
68
|
+
def custom(&block)
|
69
|
+
(block && block.call(@accumulator)) || @accumulator
|
70
|
+
end
|
22
71
|
end
|
23
72
|
end
|