image_optim 0.22.1 → 0.23.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +8 -8
- data/.appveyor.yml +95 -0
- data/.rubocop.yml +3 -0
- data/.travis.yml +27 -22
- data/CHANGELOG.markdown +10 -0
- data/CONTRIBUTING.markdown +2 -1
- data/Gemfile +1 -1
- data/README.markdown +10 -2
- data/image_optim.gemspec +4 -4
- data/lib/image_optim.rb +32 -16
- data/lib/image_optim/bin_resolver/bin.rb +11 -4
- data/lib/image_optim/cache.rb +71 -0
- data/lib/image_optim/cache_path.rb +16 -0
- data/lib/image_optim/config.rb +12 -2
- data/lib/image_optim/handler.rb +1 -1
- data/lib/image_optim/image_meta.rb +5 -10
- data/lib/image_optim/optimized_path.rb +25 -0
- data/lib/image_optim/path.rb +70 -0
- data/lib/image_optim/runner/option_parser.rb +13 -0
- data/lib/image_optim/worker.rb +5 -8
- data/lib/image_optim/worker/class_methods.rb +3 -1
- data/lib/image_optim/worker/jpegoptim.rb +3 -0
- data/lib/image_optim/worker/jpegrecompress.rb +3 -0
- data/lib/image_optim/worker/pngquant.rb +3 -0
- data/script/worker_analysis +10 -9
- data/spec/image_optim/bin_resolver/comparable_condition_spec.rb +1 -1
- data/spec/image_optim/bin_resolver/simple_version_spec.rb +48 -40
- data/spec/image_optim/bin_resolver_spec.rb +190 -172
- data/spec/image_optim/cache_path_spec.rb +59 -0
- data/spec/image_optim/cache_spec.rb +159 -0
- data/spec/image_optim/cmd_spec.rb +11 -7
- data/spec/image_optim/config_spec.rb +92 -71
- data/spec/image_optim/handler_spec.rb +3 -6
- data/spec/image_optim/image_meta_spec.rb +61 -0
- data/spec/image_optim/optimized_path_spec.rb +58 -0
- data/spec/image_optim/option_helpers_spec.rb +25 -0
- data/spec/image_optim/path_spec.rb +105 -0
- data/spec/image_optim/railtie_spec.rb +6 -6
- data/spec/image_optim/runner/glob_helpers_spec.rb +2 -6
- data/spec/image_optim/runner/option_parser_spec.rb +3 -3
- data/spec/image_optim/space_spec.rb +16 -18
- data/spec/image_optim/worker/optipng_spec.rb +3 -3
- data/spec/image_optim/worker/pngquant_spec.rb +47 -7
- data/spec/image_optim/worker_spec.rb +114 -17
- data/spec/image_optim_spec.rb +58 -69
- data/spec/images/broken_jpeg +1 -0
- data/spec/spec_helper.rb +40 -10
- metadata +30 -8
- data/lib/image_optim/image_path.rb +0 -68
- data/spec/image_optim/image_path_spec.rb +0 -54
@@ -0,0 +1,71 @@
|
|
1
|
+
require 'digest/sha1'
|
2
|
+
require 'fspath'
|
3
|
+
require 'image_optim/cache_path'
|
4
|
+
|
5
|
+
class ImageOptim
|
6
|
+
# Handles image cache
|
7
|
+
class Cache
|
8
|
+
def initialize(image_optim, workers_by_format)
|
9
|
+
return unless image_optim.cache_dir
|
10
|
+
@cache_dir = FSPath.new(image_optim.cache_dir)
|
11
|
+
@cache_worker_digests = image_optim.cache_worker_digests
|
12
|
+
@options_by_format = Hash[workers_by_format.map do |format, workers|
|
13
|
+
[format, workers.map(&:inspect).sort.join(', ')]
|
14
|
+
end]
|
15
|
+
@bins_by_format = Hash[workers_by_format.map do |format, workers|
|
16
|
+
[format, workers.map(&:used_bins).flatten!.map! do |sym|
|
17
|
+
bin = image_optim.resolve_bin!(sym)
|
18
|
+
"#{bin.name}[#{bin.digest}]"
|
19
|
+
end.sort!.uniq.join(', ')]
|
20
|
+
end]
|
21
|
+
end
|
22
|
+
|
23
|
+
def fetch(original)
|
24
|
+
return yield unless @cache_dir
|
25
|
+
|
26
|
+
digest = digest(original, original.image_format)
|
27
|
+
cached = @cache_dir / digest
|
28
|
+
return cached.size? && CachePath.convert(cached) if cached.file?
|
29
|
+
|
30
|
+
optimized = yield
|
31
|
+
|
32
|
+
cached.dirname.mkpath
|
33
|
+
|
34
|
+
if optimized
|
35
|
+
tmp = FSPath.temp_file_path(digest, @cache_dir)
|
36
|
+
FileUtils.mv(optimized, tmp)
|
37
|
+
tmp.rename(cached)
|
38
|
+
cached_path = CachePath.convert(cached)
|
39
|
+
|
40
|
+
# mark cached image as already optimized
|
41
|
+
cached = @cache_dir / digest(cached, original.image_format)
|
42
|
+
cached.dirname.mkpath
|
43
|
+
FileUtils.touch(cached)
|
44
|
+
|
45
|
+
cached_path
|
46
|
+
else
|
47
|
+
# mark image as already optimized
|
48
|
+
FileUtils.touch(cached)
|
49
|
+
nil
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
private
|
54
|
+
|
55
|
+
def options_by_format(format)
|
56
|
+
@options_by_format[format]
|
57
|
+
end
|
58
|
+
|
59
|
+
def bins_by_format(format)
|
60
|
+
@bins_by_format[format]
|
61
|
+
end
|
62
|
+
|
63
|
+
def digest(path, format)
|
64
|
+
digest = Digest::SHA1.file(path)
|
65
|
+
digest.update options_by_format(format)
|
66
|
+
digest.update bins_by_format(format) if @cache_worker_digests
|
67
|
+
s = digest.hexdigest
|
68
|
+
"#{s[0..1]}/#{s[2..-1]}"
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
require 'image_optim/path'
|
2
|
+
|
3
|
+
class ImageOptim
|
4
|
+
# ImageOptiom::Path with a non self destructing #replace method
|
5
|
+
class CachePath < Path
|
6
|
+
# Atomic replace dst with self
|
7
|
+
def replace(dst)
|
8
|
+
dst = self.class.new(dst)
|
9
|
+
dst.temp_path(dst.dirname) do |temp|
|
10
|
+
copy(temp)
|
11
|
+
dst.copy_metadata(temp)
|
12
|
+
temp.rename(dst.to_s)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
data/lib/image_optim/config.rb
CHANGED
@@ -22,7 +22,8 @@ class ImageOptim
|
|
22
22
|
|
23
23
|
class << self
|
24
24
|
# Read options at path: expand path (warn on failure), return {} if file
|
25
|
-
# does not exist, read yaml, check if it is a Hash, deep
|
25
|
+
# does not exist or is empty, read yaml, check if it is a Hash, deep
|
26
|
+
# symbolise keys
|
26
27
|
def read_options(path)
|
27
28
|
begin
|
28
29
|
full_path = File.expand_path(path)
|
@@ -30,7 +31,7 @@ class ImageOptim
|
|
30
31
|
warn "Can't expand path #{path}: #{e}"
|
31
32
|
return {}
|
32
33
|
end
|
33
|
-
return {} unless File.
|
34
|
+
return {} unless File.size?(full_path)
|
34
35
|
config = YAML.load_file(full_path)
|
35
36
|
unless config.is_a?(Hash)
|
36
37
|
fail "expected hash, got #{config.inspect}"
|
@@ -147,6 +148,15 @@ class ImageOptim
|
|
147
148
|
!!get!(:allow_lossy)
|
148
149
|
end
|
149
150
|
|
151
|
+
def cache_dir
|
152
|
+
dir = get!(:cache_dir)
|
153
|
+
dir unless dir.nil? || dir.empty?
|
154
|
+
end
|
155
|
+
|
156
|
+
def cache_worker_digests
|
157
|
+
!!get!(:cache_worker_digests)
|
158
|
+
end
|
159
|
+
|
150
160
|
# Options for worker class by its `bin_sym`:
|
151
161
|
# * `Hash` passed as is
|
152
162
|
# * `{}` for `true` or `nil`
|
data/lib/image_optim/handler.rb
CHANGED
@@ -2,24 +2,19 @@ require 'image_size'
|
|
2
2
|
|
3
3
|
class ImageOptim
|
4
4
|
# Getting format of image at path or as data
|
5
|
-
|
6
|
-
def self.
|
5
|
+
module ImageMeta
|
6
|
+
def self.format_for_path(path)
|
7
7
|
is = ImageSize.path(path)
|
8
|
-
|
8
|
+
is.format if is
|
9
9
|
rescue ImageSize::FormatError => e
|
10
10
|
warn "#{e} (detecting format of image at #{path})"
|
11
11
|
end
|
12
12
|
|
13
|
-
def self.
|
13
|
+
def self.format_for_data(data)
|
14
14
|
is = ImageSize.new(data)
|
15
|
-
|
15
|
+
is.format if is
|
16
16
|
rescue ImageSize::FormatError => e
|
17
17
|
warn "#{e} (detecting format of image data)"
|
18
18
|
end
|
19
|
-
|
20
|
-
attr_reader :format
|
21
|
-
def initialize(format)
|
22
|
-
@format = format
|
23
|
-
end
|
24
19
|
end
|
25
20
|
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
require 'image_optim/path'
|
2
|
+
|
3
|
+
class ImageOptim
|
4
|
+
# Holds optimized image with reference to original and its size
|
5
|
+
class OptimizedPath < DelegateClass(Path)
|
6
|
+
def initialize(path, original_or_size = nil)
|
7
|
+
path = Path.convert(path)
|
8
|
+
__setobj__(path)
|
9
|
+
if original_or_size.is_a?(Integer)
|
10
|
+
@original = path
|
11
|
+
@original_size = original_or_size
|
12
|
+
elsif original_or_size
|
13
|
+
@original = Path.convert(original_or_size)
|
14
|
+
@original_size = @original.size
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
# Original path, use original_size to get its size as original can be
|
19
|
+
# overwritten
|
20
|
+
attr_reader :original
|
21
|
+
|
22
|
+
# Stored size of original
|
23
|
+
attr_reader :original_size
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
require 'fspath'
|
2
|
+
require 'image_optim/image_meta'
|
3
|
+
|
4
|
+
class ImageOptim
|
5
|
+
# FSPath with additional helpful methods
|
6
|
+
class Path < FSPath
|
7
|
+
NULL = if defined?(IO::NULL)
|
8
|
+
IO::NULL
|
9
|
+
else
|
10
|
+
%w[/dev/null NUL: NUL nul NIL: NL:].find{ |dev| File.exist?(dev) }
|
11
|
+
end
|
12
|
+
|
13
|
+
# Get temp path for this file with same extension
|
14
|
+
def temp_path(*args, &block)
|
15
|
+
ext = extname
|
16
|
+
self.class.temp_file_path([basename(ext).to_s, ext], *args, &block)
|
17
|
+
end
|
18
|
+
|
19
|
+
# Copy file to dst, optionally preserving attributes
|
20
|
+
#
|
21
|
+
# See FileUtils.copy_file
|
22
|
+
def copy(dst, preserve = false)
|
23
|
+
FileUtils.copy_file(self, dst, preserve)
|
24
|
+
end
|
25
|
+
|
26
|
+
# Move file to dst: rename on same device, copy and unlink original
|
27
|
+
# otherwise
|
28
|
+
#
|
29
|
+
# See FileUtils.mv
|
30
|
+
def move(dst)
|
31
|
+
FileUtils.move(self, dst)
|
32
|
+
end
|
33
|
+
|
34
|
+
# Copy metadata: uid, gid, mode, optionally atime and mtime
|
35
|
+
#
|
36
|
+
# Adapted from FileUtils::Entry_#copy_metadata by Minero Aoki
|
37
|
+
def copy_metadata(dst, time = false)
|
38
|
+
stat = lstat
|
39
|
+
dst.utime(stat.atime, stat.mtime) if time
|
40
|
+
begin
|
41
|
+
dst.chown(stat.uid, stat.gid)
|
42
|
+
rescue Errno::EPERM
|
43
|
+
dst.chmod(stat.mode & 0o1777)
|
44
|
+
else
|
45
|
+
dst.chmod(stat.mode)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
# Atomic replace dst with self
|
50
|
+
def replace(dst)
|
51
|
+
dst = self.class.new(dst)
|
52
|
+
dst.temp_path(dst.dirname) do |temp|
|
53
|
+
move(temp)
|
54
|
+
dst.copy_metadata(temp)
|
55
|
+
temp.rename(dst.to_s)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
# Get format using ImageSize
|
60
|
+
def image_format
|
61
|
+
ImageMeta.format_for_path(self)
|
62
|
+
end
|
63
|
+
|
64
|
+
# Returns path if it is already an instance of this class otherwise new
|
65
|
+
# instance
|
66
|
+
def self.convert(path)
|
67
|
+
path.is_a?(self) ? path : new(path)
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
@@ -143,6 +143,19 @@ ImageOptim::Runner::OptionParser::DEFINE = proc do |op, options|
|
|
143
143
|
options[:pack] = pack
|
144
144
|
end
|
145
145
|
|
146
|
+
op.separator nil
|
147
|
+
op.separator ' Caching:'
|
148
|
+
|
149
|
+
op.on('--cache-dir DIR', 'Cache optimized images '\
|
150
|
+
'into the specified directory') do |cache_dir|
|
151
|
+
options[:cache_dir] = cache_dir
|
152
|
+
end
|
153
|
+
|
154
|
+
op.on('--cache-worker-digests', 'Cache worker digests '\
|
155
|
+
'(updating workers invalidates cache)') do |cache_worker_digests|
|
156
|
+
options[:cache_worker_digests] = cache_worker_digests
|
157
|
+
end
|
158
|
+
|
146
159
|
op.separator nil
|
147
160
|
op.separator ' Disabling workers:'
|
148
161
|
|
data/lib/image_optim/worker.rb
CHANGED
@@ -2,6 +2,7 @@
|
|
2
2
|
|
3
3
|
require 'image_optim/cmd'
|
4
4
|
require 'image_optim/configuration_error'
|
5
|
+
require 'image_optim/path'
|
5
6
|
require 'image_optim/worker/class_methods'
|
6
7
|
require 'shellwords'
|
7
8
|
require 'English'
|
@@ -17,16 +18,12 @@ class ImageOptim
|
|
17
18
|
alias_method :init, :new
|
18
19
|
end
|
19
20
|
|
20
|
-
# Allow lossy optimizations
|
21
|
-
attr_reader :allow_lossy
|
22
|
-
|
23
21
|
# Configure (raises on extra options)
|
24
22
|
def initialize(image_optim, options = {})
|
25
23
|
unless image_optim.is_a?(ImageOptim)
|
26
24
|
fail ArgumentError, 'first parameter should be an ImageOptim instance'
|
27
25
|
end
|
28
26
|
@image_optim = image_optim
|
29
|
-
@allow_lossy = !!options.delete(:allow_lossy)
|
30
27
|
parse_options(options)
|
31
28
|
assert_no_unknown_options!(options)
|
32
29
|
end
|
@@ -81,8 +78,8 @@ class ImageOptim
|
|
81
78
|
|
82
79
|
# Short inspect
|
83
80
|
def inspect
|
84
|
-
options_string =
|
85
|
-
" @#{name}=#{
|
81
|
+
options_string = self.class.option_definitions.map do |option|
|
82
|
+
" @#{option.name}=#{send(option.name).inspect}"
|
86
83
|
end.join(',')
|
87
84
|
"#<#{self.class}#{options_string}>"
|
88
85
|
end
|
@@ -143,14 +140,14 @@ class ImageOptim
|
|
143
140
|
%W[
|
144
141
|
env PATH=#{@image_optim.env_path.shellescape}
|
145
142
|
nice -n #{@image_optim.nice}
|
146
|
-
#{cmd_args.shelljoin} >
|
143
|
+
#{cmd_args.shelljoin} > #{Path::NULL} 2>&1
|
147
144
|
].join(' ')
|
148
145
|
else
|
149
146
|
[
|
150
147
|
{'PATH' => @image_optim.env_path},
|
151
148
|
%W[nice -n #{@image_optim.nice}],
|
152
149
|
cmd_args,
|
153
|
-
{:out =>
|
150
|
+
{:out => Path::NULL, :err => Path::NULL},
|
154
151
|
].flatten
|
155
152
|
end
|
156
153
|
Cmd.run(*args)
|
@@ -82,7 +82,9 @@ class ImageOptim
|
|
82
82
|
klasses.map do |klass|
|
83
83
|
options = options_proc[klass]
|
84
84
|
next if options[:disable]
|
85
|
-
|
85
|
+
if !options.key?(:allow_lossy) && klass.method_defined?(:allow_lossy)
|
86
|
+
options[:allow_lossy] = image_optim.allow_lossy
|
87
|
+
end
|
86
88
|
klass.init(image_optim, options)
|
87
89
|
end.compact.flatten
|
88
90
|
end
|
@@ -5,6 +5,9 @@ class ImageOptim
|
|
5
5
|
class Worker
|
6
6
|
# http://www.kokkonen.net/tjko/projects.html
|
7
7
|
class Jpegoptim < Worker
|
8
|
+
ALLOW_LOSSY_OPTION =
|
9
|
+
option(:allow_lossy, false, 'Allow limiting maximum quality'){ |v| !!v }
|
10
|
+
|
8
11
|
STRIP_OPTION =
|
9
12
|
option(:strip, :all, Array, 'List of extra markers to strip: '\
|
10
13
|
'`:comments`, '\
|
@@ -5,6 +5,9 @@ class ImageOptim
|
|
5
5
|
class Worker
|
6
6
|
# https://github.com/danielgtaylor/jpeg-archive#jpeg-recompress
|
7
7
|
class Jpegrecompress < Worker
|
8
|
+
ALLOW_LOSSY_OPTION =
|
9
|
+
option(:allow_lossy, false, 'Allow worker, it is always lossy'){ |v| !!v }
|
10
|
+
|
8
11
|
# Initialize only if allow_lossy
|
9
12
|
def self.init(image_optim, options = {})
|
10
13
|
super if options[:allow_lossy]
|
@@ -6,6 +6,9 @@ class ImageOptim
|
|
6
6
|
class Worker
|
7
7
|
# http://pngquant.org/
|
8
8
|
class Pngquant < Worker
|
9
|
+
ALLOW_LOSSY_OPTION =
|
10
|
+
option(:allow_lossy, false, 'Allow quality option'){ |v| !!v }
|
11
|
+
|
9
12
|
QUALITY_OPTION =
|
10
13
|
option(:quality, '`100..100`, `0..100` in lossy mode',
|
11
14
|
NonNegativeIntegerRange, 'min..max - don\'t '\
|
data/script/worker_analysis
CHANGED
@@ -80,7 +80,7 @@ Process.times.class.class_eval do
|
|
80
80
|
end
|
81
81
|
end
|
82
82
|
|
83
|
-
ImageOptim::
|
83
|
+
ImageOptim::Path.class_eval do
|
84
84
|
def shellescape
|
85
85
|
to_s.shellescape
|
86
86
|
end
|
@@ -192,7 +192,8 @@ class Analyser
|
|
192
192
|
time = Process.times.sum - start
|
193
193
|
|
194
194
|
dst_size = success ? dst.size : nil
|
195
|
-
|
195
|
+
digest = (success ? dst : src).digest
|
196
|
+
cache = digest.sub(/../, '\0/') + ".#{src.image_format}"
|
196
197
|
result = new(worker.id, success, time, src.size, dst_size, cache)
|
197
198
|
if success
|
198
199
|
path = result.path
|
@@ -209,7 +210,7 @@ class Analyser
|
|
209
210
|
end
|
210
211
|
|
211
212
|
def path
|
212
|
-
ImageOptim::
|
213
|
+
ImageOptim::Path.convert("#{DIR}/worker-analysis/#{cache}")
|
213
214
|
end
|
214
215
|
|
215
216
|
def inspect
|
@@ -251,7 +252,7 @@ class Analyser
|
|
251
252
|
# Run all possible worker chains
|
252
253
|
class WorkerRunner
|
253
254
|
def initialize(path, workers)
|
254
|
-
@path = ImageOptim::
|
255
|
+
@path = ImageOptim::Path.convert(path)
|
255
256
|
@workers = workers
|
256
257
|
end
|
257
258
|
|
@@ -281,7 +282,7 @@ class Analyser
|
|
281
282
|
worker_result, result_image = run_worker(src, worker)
|
282
283
|
|
283
284
|
steps = (last_result ? last_result.steps : []) + [worker_result]
|
284
|
-
chain_result = ChainResult.new(src.
|
285
|
+
chain_result = ChainResult.new(src.image_format, steps)
|
285
286
|
chain_result.difference = difference_with(result_image)
|
286
287
|
|
287
288
|
yield chain_result
|
@@ -337,7 +338,7 @@ class Analyser
|
|
337
338
|
|
338
339
|
def flatten_animation(image)
|
339
340
|
run_cache[:flatten][image.digest] ||= begin
|
340
|
-
if image.
|
341
|
+
if image.image_format == :gif
|
341
342
|
flattened = image.temp_path
|
342
343
|
Cmd.run(*%W[
|
343
344
|
convert
|
@@ -535,10 +536,10 @@ private
|
|
535
536
|
end
|
536
537
|
|
537
538
|
def process_paths(paths)
|
538
|
-
paths = paths.map{ |path| ImageOptim::
|
539
|
+
paths = paths.map{ |path| ImageOptim::Path.convert(path) }
|
539
540
|
paths.select!{ |path| path.exist? || warn("#{path} doesn't exits") }
|
540
541
|
paths.select!{ |path| path.file? || warn("#{path} is not a file") }
|
541
|
-
paths.select!{ |path| path.
|
542
|
+
paths.select!{ |path| path.image_format || warn("#{path} is not an image") }
|
542
543
|
paths.select! do |path|
|
543
544
|
workers_for_image(path) || warn("#{path} can't be handled by any worker")
|
544
545
|
end
|
@@ -546,7 +547,7 @@ private
|
|
546
547
|
end
|
547
548
|
|
548
549
|
def workers_for_image(path)
|
549
|
-
@workers_by_format[ImageOptim::
|
550
|
+
@workers_by_format[ImageOptim::Path.convert(path).image_format]
|
550
551
|
end
|
551
552
|
|
552
553
|
def log_workers_by_format
|