discourse_image_optim 0.24.4

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.
Files changed (104) hide show
  1. checksums.yaml +7 -0
  2. data/.appveyor.yml +46 -0
  3. data/.gitignore +18 -0
  4. data/.rubocop.yml +110 -0
  5. data/.travis.yml +42 -0
  6. data/CHANGELOG.markdown +316 -0
  7. data/CONTRIBUTING.markdown +11 -0
  8. data/Gemfile +16 -0
  9. data/LICENSE.txt +20 -0
  10. data/README.markdown +358 -0
  11. data/Vagrantfile +38 -0
  12. data/bin/image_optim +28 -0
  13. data/image_optim.gemspec +34 -0
  14. data/lib/image_optim.rb +267 -0
  15. data/lib/image_optim/bin_resolver.rb +142 -0
  16. data/lib/image_optim/bin_resolver/bin.rb +115 -0
  17. data/lib/image_optim/bin_resolver/comparable_condition.rb +60 -0
  18. data/lib/image_optim/bin_resolver/error.rb +6 -0
  19. data/lib/image_optim/bin_resolver/simple_version.rb +31 -0
  20. data/lib/image_optim/cache.rb +72 -0
  21. data/lib/image_optim/cache_path.rb +16 -0
  22. data/lib/image_optim/cmd.rb +122 -0
  23. data/lib/image_optim/config.rb +219 -0
  24. data/lib/image_optim/configuration_error.rb +3 -0
  25. data/lib/image_optim/handler.rb +57 -0
  26. data/lib/image_optim/hash_helpers.rb +45 -0
  27. data/lib/image_optim/image_meta.rb +20 -0
  28. data/lib/image_optim/non_negative_integer_range.rb +11 -0
  29. data/lib/image_optim/optimized_path.rb +25 -0
  30. data/lib/image_optim/option_definition.rb +38 -0
  31. data/lib/image_optim/option_helpers.rb +17 -0
  32. data/lib/image_optim/path.rb +70 -0
  33. data/lib/image_optim/runner.rb +139 -0
  34. data/lib/image_optim/runner/glob_helpers.rb +45 -0
  35. data/lib/image_optim/runner/option_parser.rb +246 -0
  36. data/lib/image_optim/space.rb +29 -0
  37. data/lib/image_optim/true_false_nil.rb +16 -0
  38. data/lib/image_optim/worker.rb +170 -0
  39. data/lib/image_optim/worker/advpng.rb +37 -0
  40. data/lib/image_optim/worker/class_methods.rb +107 -0
  41. data/lib/image_optim/worker/gifsicle.rb +65 -0
  42. data/lib/image_optim/worker/jhead.rb +47 -0
  43. data/lib/image_optim/worker/jpegoptim.rb +63 -0
  44. data/lib/image_optim/worker/jpegrecompress.rb +49 -0
  45. data/lib/image_optim/worker/jpegtran.rb +48 -0
  46. data/lib/image_optim/worker/optipng.rb +53 -0
  47. data/lib/image_optim/worker/pngcrush.rb +56 -0
  48. data/lib/image_optim/worker/pngout.rb +40 -0
  49. data/lib/image_optim/worker/pngquant.rb +61 -0
  50. data/lib/image_optim/worker/svgo.rb +34 -0
  51. data/script/template/jquery-2.1.3.min.js +4 -0
  52. data/script/template/sortable-0.6.0.min.js +2 -0
  53. data/script/template/worker_analysis.erb +254 -0
  54. data/script/update_worker_options_in_readme +59 -0
  55. data/script/worker_analysis +589 -0
  56. data/spec/image_optim/bin_resolver/comparable_condition_spec.rb +37 -0
  57. data/spec/image_optim/bin_resolver/simple_version_spec.rb +65 -0
  58. data/spec/image_optim/bin_resolver_spec.rb +290 -0
  59. data/spec/image_optim/cache_path_spec.rb +57 -0
  60. data/spec/image_optim/cache_spec.rb +162 -0
  61. data/spec/image_optim/cmd_spec.rb +93 -0
  62. data/spec/image_optim/config_spec.rb +254 -0
  63. data/spec/image_optim/handler_spec.rb +90 -0
  64. data/spec/image_optim/hash_helpers_spec.rb +74 -0
  65. data/spec/image_optim/image_meta_spec.rb +61 -0
  66. data/spec/image_optim/optimized_path_spec.rb +58 -0
  67. data/spec/image_optim/option_definition_spec.rb +138 -0
  68. data/spec/image_optim/option_helpers_spec.rb +25 -0
  69. data/spec/image_optim/path_spec.rb +103 -0
  70. data/spec/image_optim/runner/glob_helpers_spec.rb +21 -0
  71. data/spec/image_optim/runner/option_parser_spec.rb +105 -0
  72. data/spec/image_optim/space_spec.rb +23 -0
  73. data/spec/image_optim/worker/optipng_spec.rb +102 -0
  74. data/spec/image_optim/worker/pngquant_spec.rb +67 -0
  75. data/spec/image_optim/worker_spec.rb +303 -0
  76. data/spec/image_optim_spec.rb +259 -0
  77. data/spec/images/broken_jpeg +1 -0
  78. data/spec/images/comparison.png +0 -0
  79. data/spec/images/decompressed.jpeg +0 -0
  80. data/spec/images/icecream.gif +0 -0
  81. data/spec/images/image.jpg +0 -0
  82. data/spec/images/invisiblepixels/generate +24 -0
  83. data/spec/images/invisiblepixels/image.png +0 -0
  84. data/spec/images/lena.jpg +0 -0
  85. data/spec/images/orient/0.jpg +0 -0
  86. data/spec/images/orient/1.jpg +0 -0
  87. data/spec/images/orient/2.jpg +0 -0
  88. data/spec/images/orient/3.jpg +0 -0
  89. data/spec/images/orient/4.jpg +0 -0
  90. data/spec/images/orient/5.jpg +0 -0
  91. data/spec/images/orient/6.jpg +0 -0
  92. data/spec/images/orient/7.jpg +0 -0
  93. data/spec/images/orient/8.jpg +0 -0
  94. data/spec/images/orient/generate +23 -0
  95. data/spec/images/orient/original.jpg +0 -0
  96. data/spec/images/quant/64.png +0 -0
  97. data/spec/images/quant/generate +25 -0
  98. data/spec/images/rails.png +0 -0
  99. data/spec/images/test.svg +3 -0
  100. data/spec/images/transparency1.png +0 -0
  101. data/spec/images/transparency2.png +0 -0
  102. data/spec/images/vergroessert.jpg +0 -0
  103. data/spec/spec_helper.rb +93 -0
  104. metadata +281 -0
@@ -0,0 +1,34 @@
1
+ # encoding: UTF-8
2
+
3
+ Gem::Specification.new do |s|
4
+ s.name = 'discourse_image_optim'
5
+ s.version = '0.24.4'
6
+ s.summary = %q{Optimize (lossless compress, optionally lossy) images (jpeg, png, gif, svg) using external utilities (advpng, gifsicle, jhead, jpeg-recompress, jpegoptim, jpegrescan, jpegtran, optipng, pngcrush, pngout, pngquant, svgo)}
7
+ s.homepage = "http://github.com/toy/#{s.name}"
8
+ s.authors = ['Ivan Kuchin']
9
+ s.license = 'MIT'
10
+
11
+ s.rubyforge_project = s.name
12
+
13
+ s.files = `git ls-files`.split("\n")
14
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
15
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
16
+ s.require_paths = %w[lib]
17
+
18
+ s.post_install_message = <<-EOF
19
+ Rails image assets optimization is extracted into image_optim_rails gem
20
+ You can safely remove `config.assets.image_optim = false` if you are not going to use that gem
21
+ EOF
22
+
23
+ s.add_dependency 'fspath', '~> 3.0'
24
+ s.add_dependency 'image_size', '~> 1.5'
25
+ s.add_dependency 'exifr', '~> 1.2', '>= 1.2.2'
26
+ s.add_dependency 'progress', '~> 3.0', '>= 3.0.1'
27
+ s.add_dependency 'in_threads', '~> 1.3'
28
+
29
+ s.add_development_dependency 'image_optim_pack', '~> 0.2', '>= 0.2.2'
30
+ s.add_development_dependency 'rspec', '~> 3.0'
31
+ if RUBY_VERSION >= '2.0'
32
+ s.add_development_dependency 'rubocop', '~> 0.47'
33
+ end
34
+ end
@@ -0,0 +1,267 @@
1
+ require 'image_optim/bin_resolver'
2
+ require 'image_optim/cache'
3
+ require 'image_optim/config'
4
+ require 'image_optim/handler'
5
+ require 'image_optim/image_meta'
6
+ require 'image_optim/optimized_path'
7
+ require 'image_optim/path'
8
+ require 'image_optim/worker'
9
+ require 'in_threads'
10
+ require 'shellwords'
11
+
12
+ %w[
13
+ pngcrush pngout advpng optipng pngquant
14
+ jhead jpegoptim jpegrecompress jpegtran
15
+ gifsicle
16
+ svgo
17
+ ].each do |worker|
18
+ require "image_optim/worker/#{worker}"
19
+ end
20
+
21
+ # Main interface
22
+ class ImageOptim
23
+ # Nice level
24
+ attr_reader :nice
25
+
26
+ # Number of threads to run with
27
+ attr_reader :threads
28
+
29
+ # Verbose output?
30
+ attr_reader :verbose
31
+
32
+ # Use image_optim_pack
33
+ attr_reader :pack
34
+
35
+ # Skip workers with missing or problematic binaries
36
+ attr_reader :skip_missing_workers
37
+
38
+ # Allow lossy workers and optimizations
39
+ attr_reader :allow_lossy
40
+
41
+ # Cache directory
42
+ attr_reader :cache_dir
43
+
44
+ # Cache worker digests
45
+ attr_reader :cache_worker_digests
46
+
47
+ # Timeout
48
+ attr_reader :timeout
49
+
50
+ # Initialize workers, specify options using worker underscored name:
51
+ #
52
+ # pass false to disable worker
53
+ #
54
+ # ImageOptim.new(:pngcrush => false)
55
+ #
56
+ # or hash with options to worker
57
+ #
58
+ # ImageOptim.new(:advpng => {:level => 3}, :optipng => {:level => 2})
59
+ #
60
+ # use :threads to set number of parallel optimizers to run (passing true or
61
+ # nil determines number of processors, false disables parallel processing)
62
+ #
63
+ # ImageOptim.new(:threads => 8)
64
+ #
65
+ # use :nice to specify optimizers nice level (true or nil makes it 10, false
66
+ # makes it 0)
67
+ #
68
+ # ImageOptim.new(:nice => 20)
69
+ def initialize(options = {})
70
+ config = Config.new(options)
71
+ @verbose = config.verbose
72
+ $stderr << "config:\n#{config.to_s.gsub(/^/, ' ')}" if verbose
73
+
74
+ %w[
75
+ nice
76
+ threads
77
+ pack
78
+ skip_missing_workers
79
+ allow_lossy
80
+ cache_dir
81
+ cache_worker_digests
82
+ timeout
83
+ ].each do |name|
84
+ instance_variable_set(:"@#{name}", config.send(name))
85
+ $stderr << "#{name}: #{send(name)}\n" if verbose
86
+ end
87
+
88
+ @bin_resolver = BinResolver.new(self)
89
+
90
+ @workers_by_format = Worker.create_all_by_format(self) do |klass|
91
+ config.for_worker(klass)
92
+ end
93
+
94
+ @cache = Cache.new(self, @workers_by_format)
95
+
96
+ log_workers_by_format if verbose
97
+
98
+ config.assert_no_unused_options!
99
+ end
100
+
101
+ # Get workers for image
102
+ def workers_for_image(path)
103
+ @workers_by_format[Path.convert(path).image_format]
104
+ end
105
+
106
+ # Optimize one file, return new path as OptimizedPath or nil if
107
+ # optimization failed
108
+ def optimize_image(original)
109
+ original = Path.convert(original)
110
+ return unless (workers = workers_for_image(original))
111
+
112
+ optimized = @cache.fetch(original) do
113
+ Handler.for(original) do |handler|
114
+ workers.each do |worker|
115
+ handler.process do |src, dst|
116
+ worker.optimize(src, dst)
117
+ end
118
+ end
119
+ end
120
+ end
121
+
122
+ return unless optimized
123
+ OptimizedPath.new(optimized, original)
124
+ end
125
+
126
+ # Optimize one file in place, return original as OptimizedPath or nil if
127
+ # optimization failed
128
+ def optimize_image!(original)
129
+ original = Path.convert(original)
130
+ return unless (result = optimize_image(original))
131
+ result.replace(original)
132
+ OptimizedPath.new(original, result.original_size)
133
+ end
134
+
135
+ # Optimize image data, return new data or nil if optimization failed
136
+ def optimize_image_data(original_data)
137
+ format = ImageMeta.format_for_data(original_data)
138
+ return unless format
139
+ Path.temp_file %W[image_optim .#{format}] do |temp|
140
+ temp.binmode
141
+ temp.write(original_data)
142
+ temp.close
143
+
144
+ if (result = optimize_image(temp.path))
145
+ result.binread
146
+ end
147
+ end
148
+ end
149
+
150
+ # Optimize multiple images
151
+ # if block given yields path and result for each image and returns array of
152
+ # yield results
153
+ # else return array of path and result pairs
154
+ def optimize_images(paths, &block)
155
+ run_method_for(paths, :optimize_image, &block)
156
+ end
157
+
158
+ # Optimize multiple images in place
159
+ # if block given yields path and result for each image and returns array of
160
+ # yield results
161
+ # else return array of path and result pairs
162
+ def optimize_images!(paths, &block)
163
+ run_method_for(paths, :optimize_image!, &block)
164
+ end
165
+
166
+ # Optimize multiple image datas
167
+ # if block given yields original and result for each image data and returns
168
+ # array of yield results
169
+ # else return array of path and result pairs
170
+ def optimize_images_data(datas, &block)
171
+ run_method_for(datas, :optimize_image_data, &block)
172
+ end
173
+
174
+ class << self
175
+ # Optimization methods with default options
176
+ def method_missing(method, *args, &block)
177
+ if optimize_image_method?(method)
178
+ new.send(method, *args, &block)
179
+ else
180
+ super
181
+ end
182
+ end
183
+
184
+ def respond_to_missing?(method, include_private = false)
185
+ optimize_image_method?(method) || super
186
+ end
187
+
188
+ if RUBY_VERSION < '1.9'
189
+ def respond_to?(method, include_private = false)
190
+ optimize_image_method?(method) || super
191
+ end
192
+ end
193
+
194
+ # Version of image_optim gem spec loaded
195
+ def version
196
+ Gem.loaded_specs['image_optim'].version.to_s
197
+ rescue
198
+ 'DEV'
199
+ end
200
+
201
+ # Full version of image_optim
202
+ def full_version
203
+ "image_optim v#{version}"
204
+ end
205
+
206
+ private
207
+
208
+ def optimize_image_method?(method)
209
+ method_defined?(method) && method.to_s =~ /^optimize_image/
210
+ end
211
+ end
212
+
213
+ # Are there workers for file at path?
214
+ def optimizable?(path)
215
+ !!workers_for_image(path)
216
+ end
217
+
218
+ # Check existance of binary, create symlink if ENV contains path for key
219
+ # XXX_BIN where XXX is upper case bin name
220
+ def resolve_bin!(bin)
221
+ @bin_resolver.resolve!(bin)
222
+ end
223
+
224
+ # Join resolve_dir, default path and vendor path for PATH environment variable
225
+ def env_path
226
+ @bin_resolver.env_path
227
+ end
228
+
229
+ private
230
+
231
+ def log_workers_by_format
232
+ $stderr << "Workers by format:\n"
233
+ @workers_by_format.each do |format, workers|
234
+ $stderr << "#{format}:\n"
235
+ workers.each do |worker|
236
+ $stderr << " #{worker.class.bin_sym}:\n"
237
+ worker.options.each do |name, value|
238
+ $stderr << " #{name}: #{value.inspect}\n"
239
+ end
240
+ end
241
+ end
242
+ end
243
+
244
+ # Run method for each item in list
245
+ # if block given yields item and result for item and returns array of yield
246
+ # results
247
+ # else return array of item and result pairs
248
+ def run_method_for(list, method_name, &block)
249
+ apply_threading(list).map do |item|
250
+ result = send(method_name, item)
251
+ if block
252
+ yield item, result
253
+ else
254
+ [item, result]
255
+ end
256
+ end
257
+ end
258
+
259
+ # Apply threading if threading is allowed
260
+ def apply_threading(enum)
261
+ if threads > 1
262
+ enum.in_threads(threads)
263
+ else
264
+ enum
265
+ end
266
+ end
267
+ end
@@ -0,0 +1,142 @@
1
+ require 'thread'
2
+ require 'fspath'
3
+ require 'image_optim/bin_resolver/error'
4
+ require 'image_optim/bin_resolver/bin'
5
+
6
+ class ImageOptim
7
+ # Handles resolving binaries and checking versions
8
+ #
9
+ # If there is an environment variable XXX_BIN when resolving xxx, then a
10
+ # symlink to binary will be created in a temporary directory which will be
11
+ # added to PATH
12
+ class BinResolver
13
+ class BinNotFound < Error; end
14
+
15
+ # Directory for symlinks to bins if XXX_BIN was used
16
+ attr_reader :dir
17
+
18
+ # Path to pack from image_optim_pack if used
19
+ attr_reader :pack_path
20
+
21
+ def initialize(image_optim)
22
+ @image_optim = image_optim
23
+ @bins = {}
24
+ @lock = Mutex.new
25
+ init_pack
26
+ end
27
+
28
+ # Binary resolving: create symlink if there is XXX_BIN environment variable,
29
+ # build Bin with full path, check binary version
30
+ # Return Bin instance
31
+ def resolve!(name)
32
+ name = name.to_sym
33
+
34
+ resolving(name) do
35
+ path = symlink_custom_bin!(name) || full_path(name)
36
+ bin = Bin.new(name, path) if path
37
+
38
+ if bin && @image_optim.verbose
39
+ $stderr << "Resolved #{bin}\n"
40
+ end
41
+
42
+ @bins[name] = bin
43
+
44
+ bin.check! if bin
45
+ end
46
+
47
+ fail BinNotFound, "`#{name}` not found" unless @bins[name]
48
+
49
+ @bins[name].check_fail!
50
+
51
+ @bins[name]
52
+ end
53
+
54
+ # Path to vendor at root of image_optim
55
+ VENDOR_PATH = File.expand_path('../../../vendor', __FILE__)
56
+
57
+ # Prepand `dir` and append `VENDOR_PATH` to `PATH` from environment
58
+ def env_path
59
+ [
60
+ dir,
61
+ pack_path,
62
+ ENV['PATH'],
63
+ VENDOR_PATH,
64
+ ].compact.join(File::PATH_SEPARATOR)
65
+ end
66
+
67
+ # Collect resolving errors when running block over items of enumerable
68
+ def self.collect_errors(enumerable)
69
+ errors = []
70
+ enumerable.each do |item|
71
+ begin
72
+ yield item
73
+ rescue Error => e
74
+ errors << e
75
+ end
76
+ end
77
+ errors
78
+ end
79
+
80
+ private
81
+
82
+ def init_pack
83
+ return unless @image_optim.pack
84
+
85
+ @pack_path = if @image_optim.verbose
86
+ Pack.path do |message|
87
+ $stderr << "#{message}\n"
88
+ end
89
+ else
90
+ Pack.path
91
+ end
92
+ return if @pack_path
93
+
94
+ warn 'No pack for this OS and/or ARCH, check verbose output'
95
+ end
96
+
97
+ # Double-checked locking
98
+ def resolving(name)
99
+ return if @bins.include?(name)
100
+ @lock.synchronize do
101
+ yield unless @bins.include?(name)
102
+ end
103
+ end
104
+
105
+ # Check path in XXX_BIN to exist, be a file and be executable and symlink to
106
+ # dir as name
107
+ def symlink_custom_bin!(name)
108
+ env_name = "#{name}_bin".upcase
109
+ path = ENV[env_name]
110
+ return unless path
111
+ path = File.expand_path(path)
112
+ desc = "`#{path}` specified in #{env_name}"
113
+ fail "#{desc} doesn\'t exist" unless File.exist?(path)
114
+ fail "#{desc} is not a file" unless File.file?(path)
115
+ fail "#{desc} is not executable" unless File.executable?(path)
116
+ if @image_optim.verbose
117
+ $stderr << "Custom path for #{name} specified in #{env_name}: #{path}\n"
118
+ end
119
+ unless @dir
120
+ @dir = FSPath.temp_dir
121
+ at_exit{ FileUtils.remove_entry_secure @dir }
122
+ end
123
+ symlink = @dir / name
124
+ symlink.make_symlink(path)
125
+ path
126
+ end
127
+
128
+ # Return full path to bin or null
129
+ # based on http://stackoverflow.com/a/5471032/96823
130
+ def full_path(name)
131
+ # PATHEXT is needed only for windows
132
+ exts = ENV['PATHEXT'] ? ENV['PATHEXT'].split(';') : ['']
133
+ env_path.split(File::PATH_SEPARATOR).each do |dir|
134
+ exts.each do |ext|
135
+ path = File.expand_path("#{name}#{ext}", dir)
136
+ return path if File.file?(path) && File.executable?(path)
137
+ end
138
+ end
139
+ nil
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,115 @@
1
+ require 'image_optim/bin_resolver/error'
2
+ require 'image_optim/bin_resolver/simple_version'
3
+ require 'image_optim/bin_resolver/comparable_condition'
4
+ require 'image_optim/cmd'
5
+ require 'image_optim/path'
6
+ require 'shellwords'
7
+ require 'digest/sha1'
8
+
9
+ class ImageOptim
10
+ class BinResolver
11
+ # Holds bin name and path, gets version
12
+ class Bin
13
+ class UnknownVersion < Error; end
14
+ class BadVersion < Error; end
15
+
16
+ attr_reader :name, :path, :version
17
+ def initialize(name, path)
18
+ @name = name.to_sym
19
+ @path = path.to_s
20
+ @version = detect_version
21
+ end
22
+
23
+ def digest
24
+ return @digest if defined?(@digest)
25
+ @digest = File.exist?(@path) && Digest::SHA1.file(@path).hexdigest
26
+ end
27
+
28
+ def to_s
29
+ "#{name} #{version || '?'} at #{path}"
30
+ end
31
+
32
+ is = ComparableCondition.is
33
+
34
+ FAIL_CHECKS = [
35
+ [:pngcrush, is.between?('1.7.60', '1.7.65'), 'is known to produce '\
36
+ 'broken pngs'],
37
+ [:pngcrush, is == '1.7.80', 'loses one color in indexed images'],
38
+ [:pngquant, is < '2.0', 'is not supported'],
39
+ ].freeze
40
+
41
+ WARN_CHECKS = [
42
+ [:advpng, is < '1.17', 'does not use zopfli'],
43
+ [:gifsicle, is < '1.85', 'does not support removing extension blocks'],
44
+ [:pngcrush, is < '1.7.38', 'does not have blacken flag'],
45
+ [:pngquant, is < '2.1', 'may be lossy even with quality `100-`'],
46
+ [:optipng, is < '0.7', 'does not support -strip option'],
47
+ ].freeze
48
+
49
+ # Fail if version will not work properly
50
+ def check_fail!
51
+ unless version
52
+ fail UnknownVersion, "could not get version of #{name} at #{path}"
53
+ end
54
+
55
+ FAIL_CHECKS.each do |bin_name, matcher, message|
56
+ next unless bin_name == name
57
+ next unless matcher.match(version)
58
+ fail BadVersion, "#{self} (#{matcher}) #{message}"
59
+ end
60
+ end
61
+
62
+ # Run check_fail!, otherwise warn if version is known to misbehave
63
+ def check!
64
+ check_fail!
65
+
66
+ WARN_CHECKS.each do |bin_name, matcher, message|
67
+ next unless bin_name == name
68
+ next unless matcher.match(version)
69
+ warn "WARN: #{self} (#{matcher}) #{message}"
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ # Wrap version_string with SimpleVersion
76
+ def detect_version
77
+ str = version_string
78
+ str && SimpleVersion.new(str)
79
+ end
80
+
81
+ # Getting version of bin, will fail for an unknown name
82
+ def version_string
83
+ case name
84
+ when :advpng, :gifsicle, :jpegoptim, :optipng
85
+ capture("#{escaped_path} --version 2> #{Path::NULL}")[/\d+(\.\d+)+/]
86
+ when :svgo, :pngquant
87
+ capture("#{escaped_path} --version 2>&1")[/\d+(\.\d+)+/]
88
+ when :jhead, :'jpeg-recompress'
89
+ capture("#{escaped_path} -V 2> #{Path::NULL}")[/\d+(\.\d+)+/]
90
+ when :jpegtran
91
+ capture("#{escaped_path} -v - 2>&1")[/version (\d+\S*)/, 1]
92
+ when :pngcrush
93
+ capture("#{escaped_path} -version 2>&1")[/pngcrush (\d+(\.\d+)+)/, 1]
94
+ when :pngout
95
+ date_regexp = /[A-Z][a-z]{2} (?: |\d)\d \d{4}/
96
+ date_str = capture("#{escaped_path} 2>&1")[date_regexp]
97
+ Date.parse(date_str).strftime('%Y%m%d') if date_str
98
+ when :jpegrescan
99
+ # jpegrescan has no version so use first 8 characters of sha1 hex
100
+ Digest::SHA1.file(path).hexdigest[0, 8] if path
101
+ else
102
+ fail "getting `#{name}` version is not defined"
103
+ end
104
+ end
105
+
106
+ def capture(cmd)
107
+ Cmd.capture(cmd)
108
+ end
109
+
110
+ def escaped_path
111
+ path.shellescape
112
+ end
113
+ end
114
+ end
115
+ end