image_optim 0.18.0 → 0.19.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.
@@ -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