discourse_image_optim 0.24.4

Sign up to get free protection for your applications and to get access to all the features.
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