image_optim 0.18.0 → 0.19.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,89 @@
1
+ require 'image_optim/bin_resolver'
2
+ require 'image_optim/option_definition'
3
+
4
+ class ImageOptim
5
+ class Worker
6
+ # Class methods of ImageOptim::Worker
7
+ module ClassMethods
8
+ def self.extended(klass)
9
+ klass.instance_variable_set(:@klasses, [])
10
+ end
11
+
12
+ # List of available workers
13
+ def klasses
14
+ @klasses.to_enum
15
+ end
16
+
17
+ # Remember all classes inheriting from this one
18
+ def inherited(base)
19
+ @klasses << base
20
+ end
21
+
22
+ # Underscored class name symbol
23
+ def bin_sym
24
+ @underscored_name ||= name.
25
+ split('::').last. # get last part
26
+ gsub(/([a-z])([A-Z])/, '\1_\2').downcase. # convert AbcDef to abc_def
27
+ to_sym
28
+ end
29
+
30
+ def option_definitions
31
+ @option_definitions ||= []
32
+ end
33
+
34
+ def option(name, default, type, description = nil, &proc)
35
+ attr_reader name
36
+ option_definitions <<
37
+ OptionDefinition.new(name, default, type, description, &proc)
38
+ end
39
+
40
+ # Create hash with format mapped to list of workers sorted by run order
41
+ def create_all_by_format(image_optim, &options_proc)
42
+ by_format = {}
43
+ create_all(image_optim, &options_proc).each do |worker|
44
+ worker.image_formats.each do |format|
45
+ by_format[format] ||= []
46
+ by_format[format] << worker
47
+ end
48
+ end
49
+ by_format
50
+ end
51
+
52
+ # Create list of workers sorted by run order
53
+ # Workers are initialized with options provided through options_proc
54
+ # Resolve all bins of all workers, if there are errors and
55
+ # skip_missing_workers of image_optim is true - show warnings, otherwise
56
+ # fail with one joint exception
57
+ def create_all(image_optim, &options_proc)
58
+ workers = init_all(image_optim, &options_proc)
59
+
60
+ resolved = []
61
+ errors = BinResolver.collect_errors(workers) do |worker|
62
+ worker.resolve_used_bins!
63
+ resolved << worker
64
+ end
65
+
66
+ unless errors.empty?
67
+ if image_optim.skip_missing_workers
68
+ errors.each{ |error| warn error }
69
+ else
70
+ message = ['Bin resolving errors:', *errors].join("\n")
71
+ fail BinResolver::Error, message
72
+ end
73
+ end
74
+
75
+ resolved.sort_by.with_index{ |worker, i| [worker.run_order, i] }
76
+ end
77
+
78
+ private
79
+
80
+ def init_all(image_optim, &options_proc)
81
+ klasses.map do |klass|
82
+ next unless (options = options_proc[klass])
83
+ options = options.merge(:allow_lossy => image_optim.allow_lossy)
84
+ klass.init(image_optim, options)
85
+ end.compact.flatten
86
+ end
87
+ end
88
+ end
89
+ end
@@ -4,8 +4,25 @@ class ImageOptim
4
4
  class Worker
5
5
  # http://www.lcdf.org/gifsicle/
6
6
  class Gifsicle < Worker
7
+ # If interlace specified initialize one instance
8
+ # Otherwise initialize two, one with interlace off and one with on
9
+ def self.init(image_optim, options = {})
10
+ return super if options.key?(:interlace)
11
+
12
+ [false, true].map do |interlace|
13
+ new(image_optim, options.merge(:interlace => interlace))
14
+ end
15
+ end
16
+
7
17
  INTERLACE_OPTION =
8
- option(:interlace, false, 'Turn interlacing on'){ |v| !!v }
18
+ option(:interlace, false, TrueFalseNil, 'Interlace: '\
19
+ '`true` - interlace on, '\
20
+ '`false` - interlace off, '\
21
+ '`nil` - as is in original image '\
22
+ '(defaults to running two instances, one with interlace off and '\
23
+ 'one with on)') do |v|
24
+ TrueFalseNil.convert(v)
25
+ end
9
26
 
10
27
  LEVEL_OPTION =
11
28
  option(:level, 3, 'Compression level: '\
@@ -30,7 +47,13 @@ class ImageOptim
30
47
  #{src}
31
48
  ]
32
49
 
33
- args.unshift('--interlace') if interlace
50
+ if resolve_bin!(:gifsicle).version >= '1.85'
51
+ args.unshift('--no-extensions', '--no-app-extensions')
52
+ end
53
+
54
+ unless interlace.nil?
55
+ args.unshift(interlace ? '--interlace' : '--no-interlace')
56
+ end
34
57
  args.unshift('--careful') if careful
35
58
  args.unshift("--optimize=#{level}") if level
36
59
  execute(:gifsicle, *args) && optimized?(src, dst)
@@ -0,0 +1,44 @@
1
+ require 'image_optim/worker'
2
+ require 'image_optim/option_helpers'
3
+
4
+ class ImageOptim
5
+ class Worker
6
+ # https://github.com/danielgtaylor/jpeg-archive#jpeg-recompress
7
+ class Jpegrecompress < Worker
8
+ # Initialize only if allow_lossy
9
+ def self.init(image_optim, options = {})
10
+ super if options[:allow_lossy]
11
+ end
12
+
13
+ QUALITY_NAMES = [:low, :medium, :high, :veryhigh]
14
+
15
+ quality_names_desc = QUALITY_NAMES.each_with_index.map do |name, i|
16
+ "`#{i}` - #{name}"
17
+ end.join(', ')
18
+
19
+ QUALITY_OPTION =
20
+ option(:quality, 3, "JPEG quality preset: #{quality_names_desc}") do |v|
21
+ OptionHelpers.limit_with_range(v.to_i, 0...QUALITY_NAMES.length)
22
+ end
23
+
24
+ def used_bins
25
+ [:'jpeg-recompress']
26
+ end
27
+
28
+ # Run first [-1]
29
+ def run_order
30
+ -5
31
+ end
32
+
33
+ def optimize(src, dst)
34
+ args = %W[
35
+ --quality #{QUALITY_NAMES[quality]}
36
+ --no-copy
37
+ #{src}
38
+ #{dst}
39
+ ]
40
+ execute(:'jpeg-recompress', *args) && optimized?(src, dst)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -14,7 +14,7 @@ class ImageOptim
14
14
  end
15
15
 
16
16
  INTERLACE_OPTION =
17
- option(:interlace, false, TrueFalseNil, 'Interlace, '\
17
+ option(:interlace, false, TrueFalseNil, 'Interlace: '\
18
18
  '`true` - interlace on, '\
19
19
  '`false` - interlace off, '\
20
20
  '`nil` - as is in original image') do |v|
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env ruby
2
2
  # encoding: UTF-8
3
3
 
4
- $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__)
4
+ require 'bundler/setup'
5
5
 
6
6
  require 'image_optim'
7
7
 
@@ -17,12 +17,11 @@ def write_worker_options(io, klass)
17
17
  io.puts 'Worker has no options'
18
18
  else
19
19
  klass.option_definitions.each do |option_definition|
20
- io.puts %W[
21
- *
22
- `:#{option_definition.name}`
23
- — #{option_definition.description}
24
- *(defaults to `#{option_definition.default.inspect}`)*
25
- ].join(' ')
20
+ line = "* `:#{option_definition.name}` — #{option_definition.description}"
21
+ unless line['(defaults']
22
+ line << " *(defaults to `#{option_definition.default.inspect}`)*"
23
+ end
24
+ io.puts line
26
25
  end
27
26
  end
28
27
  io.puts
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env ruby
2
2
  # encoding: UTF-8
3
3
 
4
- $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__)
4
+ require 'bundler/setup'
5
5
 
6
6
  require 'image_optim'
7
7
  require 'image_optim/cmd'
@@ -141,12 +141,16 @@ class Analyser
141
141
 
142
142
  # Delegate to worker with short id
143
143
  class WorkerVariant < DelegateClass(ImageOptim::Worker)
144
- attr_reader :klass, :id
144
+ attr_reader :cons_id, :id
145
145
  def initialize(klass, image_optim, options)
146
- @klass = klass
146
+ allow_consecutive_on = Array(options.delete(:allow_consecutive_on))
147
147
  @image_optim = image_optim
148
- @id = "#{klass.bin_sym}#{options unless options.empty?}"
148
+ @id = klass.bin_sym.to_s
149
+ unless options.empty?
150
+ @id << "(#{options.map{ |k, v| "#{k}:#{v.inspect}" }.join(', ')})"
151
+ end
149
152
  __setobj__(klass.new(image_optim, options))
153
+ @cons_id = [klass, allow_consecutive_on.map{ |key| [key, send(key)] }]
150
154
  end
151
155
 
152
156
  def cache_etag
@@ -270,7 +274,7 @@ class Analyser
270
274
 
271
275
  block.call(chain_result)
272
276
 
273
- workers_left = workers.reject{ |w| w.klass == worker.klass }
277
+ workers_left = workers.reject{ |w| w.cons_id == worker.cons_id }
274
278
  run_workers(result_image, workers_left, chain_result, &block)
275
279
  end
276
280
  end
@@ -427,6 +431,7 @@ class Analyser
427
431
  @workers_by_format = Hash.new{ |h, k| h[k] = [] }
428
432
  ImageOptim::Worker.klasses.each do |klass|
429
433
  worker_options_config = option_variants.delete(klass.bin_sym) || {}
434
+ allow_consecutive_on = worker_options_config.delete(:allow_consecutive_on)
430
435
  worker_option_variants = case worker_options_config
431
436
  when Array
432
437
  worker_options_config
@@ -437,6 +442,7 @@ class Analyser
437
442
  end
438
443
  worker_option_variants.each do |options|
439
444
  options = HashHelpers.deep_symbolise_keys(options)
445
+ options[:allow_consecutive_on] = allow_consecutive_on
440
446
  worker = WorkerVariant.new(klass, image_optim, options)
441
447
  puts worker.id
442
448
  worker.image_formats.each do |format|
@@ -517,6 +523,10 @@ Example of `.analysis_variants.yml`:
517
523
  optipng: # 6 worker variants by combining options
518
524
  level: [6, 7]
519
525
  interlace: [true, false, nil]
526
+ gifsicle: # allow variants with different interlace to run consecutively
527
+ allow_consecutive_on: interlace
528
+ interlace: [true, false]
529
+ careful: [true, false]
520
530
  # other workers will be used with default options
521
531
  HELP
522
532
  end
@@ -55,14 +55,12 @@
55
55
  table[data-sortable] tbody tr:hover {
56
56
  background: #ddf;
57
57
  }
58
- tr.unused {
58
+ body:not(.show-unused) tr.unused, tr.filtered-out {
59
59
  display: none;
60
60
  }
61
61
 
62
- input { margin-top: 1.5em; }
63
- #toggle-show-unused:checked ~ table tr.unused { /* without js */
64
- display: table-row;
65
- }
62
+ input { display: block; width: 30%; }
63
+ input[type=checkbox] { display: inline; width: auto; }
66
64
 
67
65
  .number {
68
66
  text-align: right;
@@ -102,6 +100,24 @@
102
100
  :javascript
103
101
  /*! sortable.js 0.6.0 */
104
102
  (function(){var a,b,c,d,e,f,g;a="table[data-sortable]",d=/^-?[£$¤]?[\d,.]+%?$/,g=/^\s+|\s+$/g,f="ontouchstart"in document.documentElement,c=f?"touchstart":"click",b=function(a,b,c){return null!=a.addEventListener?a.addEventListener(b,c,!1):a.attachEvent("on"+b,c)},e={init:function(b){var c,d,f,g,h;for(null==b&&(b={}),null==b.selector&&(b.selector=a),d=document.querySelectorAll(b.selector),h=[],f=0,g=d.length;g>f;f++)c=d[f],h.push(e.initTable(c));return h},initTable:function(a){var b,c,d,f,g,h;if(1===(null!=(h=a.tHead)?h.rows.length:void 0)&&"true"!==a.getAttribute("data-sortable-initialized")){for(a.setAttribute("data-sortable-initialized","true"),d=a.querySelectorAll("th"),b=f=0,g=d.length;g>f;b=++f)c=d[b],"false"!==c.getAttribute("data-sortable")&&e.setupClickableTH(a,c,b);return a}},setupClickableTH:function(a,d,f){var g;return g=e.getColumnType(a,f),"click"===c&&b(d,"mousedown",function(){return event.preventDefault?event.preventDefault():event.returnValue=!1}),b(d,c,function(){var b,c,h,i,j,k,l,m,n,o,p,q,r,s,t,u,v;for("true"===this.getAttribute("data-sorted")?(l=this.getAttribute("data-sorted-direction"),b="ascending"===l?"descending":"ascending"):b=this.getAttribute("data-default-direction")||g.defaultSortDirection,n=this.parentNode.querySelectorAll("th"),o=0,r=n.length;r>o;o++)d=n[o],d.setAttribute("data-sorted","false"),d.removeAttribute("data-sorted-direction");for(this.setAttribute("data-sorted","true"),this.setAttribute("data-sorted-direction",b),m=a.tBodies[0],i=[],u=m.rows,c=p=0,s=u.length;s>p;c=++p)h=u[c],i.push([e.getNodeValue(h.cells[f]),h,c]);for(k="descending"===b?-1:1,i.sort(function(a,b){var c;return c=g.compare(a,b),0!==c?c*k:a[2]-b[2]}),v=[],q=0,t=i.length;t>q;q++)j=i[q],v.push(m.appendChild(j[1]));return v})},getColumnType:function(a,b){var c,f,g,h,i;for(i=a.tBodies[0].rows,g=0,h=i.length;h>g;g++)if(c=i[g],f=e.getNodeValue(c.cells[b]),""!==f){if(f.match(d))return e.types.numeric;if(!isNaN(Date.parse(f)))return e.types.date}return e.types.alpha},getNodeValue:function(a){return a?null!==a.getAttribute("data-value")?a.getAttribute("data-value"):"undefined"!=typeof a.innerText?a.innerText.replace(g,""):a.textContent.replace(g,""):""},types:{numeric:{defaultSortDirection:"descending",compare:function(a,b){var c,d;return c=parseFloat(a[0].replace(/[^0-9.-]/g,""),10),d=parseFloat(b[0].replace(/[^0-9.-]/g,""),10),isNaN(c)&&(c=0),isNaN(d)&&(d=0),c-d}},alpha:{defaultSortDirection:"ascending",compare:function(a,b){return a[0].localeCompare(b[0])}},date:{defaultSortDirection:"ascending",compare:function(a,b){var c,d;return c=Date.parse(a[0]),d=Date.parse(b[0]),isNaN(c)&&(c=0),isNaN(d)&&(d=0),c-d}}}},setTimeout(e.init,0),window.Sortable=e}).call(this);
103
+ :javascript
104
+ function toggleShowUnused(){
105
+ var checked = document.getElementById('toggle-show-unused').checked
106
+ document.body.classList[checked ? 'add' : 'remove']('show-unused')
107
+ }
108
+ function filterTable(){
109
+ var filter = new RegExp(document.getElementById('filter').value)
110
+ var rows = document.querySelectorAll('tbody.filterable tr')
111
+ for (var i = 0, _i = rows.length; i < _i; i++) {
112
+ var row = rows[i]
113
+ if (!row.hasAttribute('data-filter-text')) {
114
+ var text = row.textContent.replace(/\s+/g, ' ')
115
+ row.setAttribute('data-filter-text', text)
116
+ }
117
+ var show = filter.test(row.getAttribute('data-filter-text'))
118
+ row.classList[show ? 'remove' : 'add']('filtered-out')
119
+ }
120
+ }
105
121
  %body
106
122
  .formats
107
123
  - format_links.each do |format, link|
@@ -114,9 +130,14 @@
114
130
  %td.warn-low Max difference >= 0.001
115
131
  %td.warn-medium Max difference >= 0.01
116
132
  %td.warn-high Max difference >= 0.1
117
- %input#toggle-show-unused(type="checkbox")
118
- %label(for="toggle-show-unused")
133
+ %p
134
+ %label
135
+ %input#toggle-show-unused(type="checkbox" onchange="toggleShowUnused()")
119
136
  Show chains with workers failed for every image
137
+ %p
138
+ %label(for="filter")
139
+ Filter chains by regexp
140
+ %input#filter(type="text" onkeyup="filterTable()")
120
141
  %p
121
142
  Images: #{stats.each_chain.first.entry_count},
122
143
  size: #{stats.each_chain.first.original_size}
@@ -133,7 +154,7 @@
133
154
  %th(data-default-direction="ascending") Avg difference
134
155
  %th(data-default-direction="ascending") Max difference
135
156
  %th Speed (B/s)
136
- %tbody
157
+ %tbody.filterable
137
158
  - stats.each_chain do |chain|
138
159
  %tr{class: chain.unused_workers && 'unused'}
139
160
  %td
@@ -144,8 +165,8 @@
144
165
  .worker-success-count(title="successfull count")
145
166
  = worker_stat.success_count
146
167
  %td.number= chain.optimized_size
147
- %td.number= format '%.4f', chain.ratio
148
- %td.number= format '%.4f', chain.avg_ratio
168
+ %td.number= format '%.5f', chain.ratio
169
+ %td.number= format '%.5f', chain.avg_ratio
149
170
  %td.number= format '%.2f', chain.time
150
171
  %td.number= format '%.5f', chain.avg_difference
151
172
  %td.number{class: chain.warn_level && "warn-#{chain.warn_level}"}
@@ -102,7 +102,8 @@ describe ImageOptim::BinResolver do
102
102
  expect(resolver).to receive(:full_path).with(:ls).and_return('/bin/ls')
103
103
  bin = double
104
104
  expect(Bin).to receive(:new).with(:ls, '/bin/ls').and_return(bin)
105
- expect(bin).to receive(:check!).exactly(5).times
105
+ expect(bin).to receive(:check!).once
106
+ expect(bin).to receive(:check_fail!).exactly(5).times
106
107
 
107
108
  5.times do
108
109
  expect(resolver.resolve!(:ls)).to eq(bin)
@@ -149,7 +150,8 @@ describe ImageOptim::BinResolver do
149
150
  bin = double
150
151
  expect(Bin).to receive(:new).
151
152
  with(:image_optim, File.expand_path(path)).and_return(bin)
152
- expect(bin).to receive(:check!).exactly(5).times
153
+ expect(bin).to receive(:check!).once
154
+ expect(bin).to receive(:check_fail!).exactly(5).times
153
155
 
154
156
  at_exit_blocks = []
155
157
  expect(resolver).to receive(:at_exit).once do |&block|
@@ -202,9 +204,10 @@ describe ImageOptim::BinResolver do
202
204
  bin = double
203
205
  expect(Bin).to receive(:new).once.with(:ls, '/bin/ls').and_return(bin)
204
206
 
205
- check_count = 0
207
+ count = 0
206
208
  mutex = Mutex.new
207
- allow(bin).to receive(:check!){ mutex.synchronize{ check_count += 1 } }
209
+ allow(bin).to receive(:check!).once
210
+ allow(bin).to receive(:check_fail!){ mutex.synchronize{ count += 1 } }
208
211
 
209
212
  10.times.map do
210
213
  Thread.new do
@@ -212,29 +215,58 @@ describe ImageOptim::BinResolver do
212
215
  end
213
216
  end.each(&:join)
214
217
 
215
- expect(check_count).to eq(10)
218
+ expect(count).to eq(10)
216
219
  end
217
220
  end
218
221
 
219
- it 'raises if did not got bin version' do
220
- bin = Bin.new(:pngcrush, '/bin/pngcrush')
221
- allow(bin).to receive(:version).and_return(nil)
222
+ describe 'checking version' do
223
+ before do
224
+ allow(resolver).to receive(:full_path){ |name| "/bin/#{name}" }
225
+ end
226
+
227
+ it 'raises every time if did not get bin version' do
228
+ with_env 'PNGCRUSH_BIN', nil do
229
+ bin = Bin.new(:pngcrush, '/bin/pngcrush')
230
+
231
+ expect(Bin).to receive(:new).and_return(bin)
232
+ allow(bin).to receive(:version).and_return(nil)
222
233
 
223
- 5.times do
224
- expect do
225
- bin.check!
226
- end.to raise_error Bin::BadVersion
234
+ 5.times do
235
+ expect do
236
+ resolver.resolve!(:pngcrush)
237
+ end.to raise_error Bin::UnknownVersion
238
+ end
239
+ end
227
240
  end
228
- end
229
241
 
230
- it 'raises on detection of problematic version' do
231
- bin = Bin.new(:pngcrush, '/bin/pngcrush')
232
- allow(bin).to receive(:version).and_return(SimpleVersion.new('1.7.60'))
242
+ it 'raises every time on detection of misbehaving version' do
243
+ with_env 'PNGCRUSH_BIN', nil do
244
+ bin = Bin.new(:pngcrush, '/bin/pngcrush')
245
+
246
+ expect(Bin).to receive(:new).and_return(bin)
247
+ allow(bin).to receive(:version).and_return(SimpleVersion.new('1.7.60'))
233
248
 
234
- 5.times do
235
- expect do
236
- bin.check!
237
- end.to raise_error Bin::BadVersion
249
+ 5.times do
250
+ expect do
251
+ resolver.resolve!(:pngcrush)
252
+ end.to raise_error Bin::BadVersion
253
+ end
254
+ end
255
+ end
256
+
257
+ it 'warns once on detection of problematic version' do
258
+ with_env 'ADVPNG_BIN', nil do
259
+ bin = Bin.new(:advpng, '/bin/advpng')
260
+
261
+ expect(Bin).to receive(:new).and_return(bin)
262
+ allow(bin).to receive(:version).and_return(SimpleVersion.new('1.15'))
263
+
264
+ expect(bin).to receive(:warn).once
265
+
266
+ 5.times do
267
+ resolver.resolve!(:pngcrush)
268
+ end
269
+ end
238
270
  end
239
271
  end
240
272
  end