image_processing 1.2.0 → 1.12.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
- IMAGE_CLASS = ::MiniMagick::Tool
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
- def resize_to_limit(magick, width, height, **options)
23
- thumbnail(magick, "#{width}x#{height}>", **options)
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
- def resize_to_fit(magick, width, height, **options)
27
- thumbnail(magick, "#{width}x#{height}", **options)
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
- def resize_to_fill(magick, width, height, gravity: "Center", **options)
31
- thumbnail(magick, "#{width}x#{height}^", **options)
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 "rgba(255,255,255,0.0)" # transparent
76
+ magick.background color(:transparent)
34
77
  magick.extent "#{width}x#{height}"
35
78
  end
36
79
 
37
- def resize_and_pad(magick, width, height, background: :transparent, gravity: "Center", **options)
38
- background = "rgba(255,255,255,0.0)" if background.to_s == "transparent"
39
-
40
- thumbnail(magick, "#{width}x#{height}", **options)
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
- def define(magick, options)
47
- return magick.define(options) if options.is_a?(String)
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
- options.each do |namespace, options|
50
- namespace = namespace.to_s.gsub("_", "-")
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
- options.each do |key, value|
53
- key = key.to_s.gsub("_", "-")
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
- magick.define "#{namespace}:#{key}=#{value}"
56
- end
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
- magick
60
- end
61
-
62
- def limits(magick, options)
63
- limit_args = options.flat_map { |type, value| %W[-limit #{type} #{value}] }
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
- def append(magick, *args)
68
- magick.merge! args
69
- end
123
+ overlay_path = convert_to_path(overlay, "overlay")
124
+ mask_path = convert_to_path(mask, "mask") if mask
70
125
 
71
- def load_image(path_or_magick, page: nil, geometry: nil, auto_orient: true, **options)
72
- if path_or_magick.is_a?(::MiniMagick::Tool)
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
- apply_options(magick, options)
129
+ magick.compose(mode) if mode
130
+ define(compose: { args: args }) if args
79
131
 
80
- input_path = source_path
81
- input_path += "[#{page}]" if page
82
- input_path += "[#{geometry}]" if geometry
132
+ magick.gravity(gravity) if gravity
133
+ magick.geometry(geometry) if geometry
83
134
 
84
- magick << input_path
85
- end
135
+ yield magick if block_given?
86
136
 
87
- magick.auto_orient if auto_orient
88
- magick
137
+ magick.composite
89
138
  end
90
139
 
91
- def save_image(magick, destination_path, allow_splitting: false, **options)
92
- apply_options(magick, options)
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
- magick << destination_path
95
- magick.call
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
- disallow_split_layers!(destination_path) unless allow_splitting
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
- def thumbnail(magick, geometry, sharpen: {})
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
- magick.sharpen(sharpen_value(sharpen)) if sharpen
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
- def sharpen_value(parameters)
109
- parameters = SHARPEN_PARAMETERS.merge(parameters)
110
- radius, sigma = parameters.values_at(:radius, :sigma)
111
-
112
- "#{radius}x#{sigma}"
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
- def apply_options(magick, define: {}, **options)
116
- options.each do |option, value|
117
- case value
118
- when true, nil then magick.send(option)
119
- when false then magick.send(option).+
120
- else magick.send(option, *value)
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
- define(magick, define)
125
- end
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
- def prepend_args(magick, args)
128
- magick.args.replace args + magick.args
129
- magick
130
- end
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
- def disallow_split_layers!(destination_path)
133
- layers = Dir[destination_path.sub(/\.\w+$/, '-*\0')]
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
- if layers.any?
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 :source, :loader, :saver, :format, :operations, :processor_class, :destination
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
- image
21
+ call_processor
26
22
  elsif destination
27
23
  handle_destination do
28
- processor.save_image(image, destination, **saver)
24
+ call_processor(destination: destination)
29
25
  end
30
26
  else
31
27
  create_tempfile do |tempfile|
32
- processor.save_image(image, tempfile.path, **saver)
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 = File.extname(destination)[1..-1] if destination
40
+ format = determine_format(destination) if destination
43
41
  format ||= self.format
44
- format ||= File.extname(source_path)[1..-1] if source_path
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
- def normalize_source(source, options)
75
- fail Error, "source file is not provided" unless source
76
-
77
- image_class = options[:processor_class]::IMAGE_CLASS
78
-
79
- if source.is_a?(image_class)
80
- source
81
- elsif source.is_a?(String)
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
- fail Error, "source file needs to respond to #path, or be a String, a Pathname, or a #{image_class} object"
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 initialize(pipeline)
4
- @pipeline = pipeline
5
- end
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
- def apply_operation(name, image, *args)
8
- if respond_to?(name)
9
- public_send(name, image, *args)
22
+ if destination
23
+ save_image(accumulator, destination, **saver)
10
24
  else
11
- image.send(name, *args)
25
+ accumulator
12
26
  end
13
27
  end
14
28
 
15
- def custom(image, block)
16
- (block && block.call(image)) || image
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
- private
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
- attr_reader :pipeline
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
@@ -1,3 +1,3 @@
1
1
  module ImageProcessing
2
- VERSION = "1.2.0"
2
+ VERSION = "1.12.2"
3
3
  end