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.
@@ -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