image_optim 0.22.1 → 0.23.0
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.
- 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
|