esvg 4.1.6 → 4.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,717 @@
1
+ require 'yaml'
2
+ require 'json'
3
+
4
+ module Esvg
5
+ class SVG
6
+ attr_accessor :svgs, :last_read, :svg_symbols
7
+
8
+ include Esvg::Utils
9
+
10
+ CONFIG = {
11
+ filename: 'svgs',
12
+ class: 'svg-symbol',
13
+ namespace: 'svg',
14
+ core: true,
15
+ namespace_before: true,
16
+ optimize: false,
17
+ gzip: false,
18
+ fingerprint: true,
19
+ throttle_read: 4,
20
+ flatten: [],
21
+ alias: {}
22
+ }
23
+
24
+ CONFIG_RAILS = {
25
+ source: "app/assets/svgs",
26
+ assets: "app/assets/javascripts",
27
+ build: "public/javascripts",
28
+ temp: "tmp"
29
+ }
30
+
31
+ def initialize(options={})
32
+ config(options)
33
+
34
+ @modules = {}
35
+ @last_read = nil
36
+
37
+ read_cache
38
+ read_files
39
+ end
40
+
41
+ def config(options={})
42
+ @config ||= begin
43
+ paths = [options[:config_file], 'config/esvg.yml', 'esvg.yml'].compact
44
+
45
+ config = CONFIG.dup
46
+
47
+ if Esvg.rails? || options[:rails]
48
+ config.merge!(CONFIG_RAILS)
49
+ end
50
+
51
+ if path = paths.select{ |p| File.exist?(p)}.first
52
+ config.merge!(symbolize_keys(YAML.load(File.read(path) || {})))
53
+ end
54
+
55
+ config.merge!(options)
56
+
57
+ config[:filename] = File.basename(config[:filename], '.*')
58
+
59
+ config[:pwd] = File.expand_path Dir.pwd
60
+ config[:source] = File.expand_path config[:source] || config[:pwd]
61
+ config[:build] = File.expand_path config[:build] || config[:pwd]
62
+ config[:assets] = File.expand_path config[:assets] || config[:pwd]
63
+
64
+ config[:temp] = config[:pwd] if config[:temp].nil?
65
+ config[:temp] = File.expand_path File.join(config[:temp], '.esvg-cache')
66
+
67
+ config[:aliases] = load_aliases(config[:alias])
68
+ config[:flatten] = [config[:flatten]].flatten.map { |dir| File.join(dir, '/') }.join('|')
69
+
70
+ config
71
+ end
72
+ end
73
+
74
+ def read_files
75
+ if !@last_read.nil? && (Time.now.to_i - @last_read) < config[:throttle_read]
76
+ return
77
+ end
78
+
79
+ # Get a list of svg files and modification times
80
+ #
81
+ find_files
82
+
83
+ @last_read = Time.now.to_i
84
+
85
+ puts "Read #{svgs.size} files from #{config[:source]}" if config[:print]
86
+
87
+ if svgs.empty? && config[:print]
88
+ puts "No svgs found at #{config[:source]}"
89
+ end
90
+ end
91
+
92
+ def find_files
93
+ files = Dir[File.join(config[:source], '**/*.svg')].uniq.sort
94
+ @svg_symbols = {}
95
+
96
+ # Remove deleted files from svg cache
97
+ (svgs.keys - file_keys(files)).each { |f| svgs.delete(f) }
98
+
99
+ dirs = {}
100
+
101
+ files.each do |path|
102
+ mtime = File.mtime(path).to_i
103
+ key = file_key path
104
+ dkey = dir_key path
105
+
106
+ # Use cache if possible
107
+ if svgs[key].nil? || svgs[key][:last_modified] != mtime
108
+ svgs[key] = process_file(path, mtime)
109
+ end
110
+
111
+ # Name may have changed due to flatten config
112
+ svgs[key][:name] = file_name(path)
113
+ svgs[key][:attr][:name] = id(svgs[key][:name])
114
+
115
+ dirs[dkey] ||= {}
116
+ (dirs[dkey][:files] ||= []) << key
117
+
118
+ if dirs[dkey][:last_modified].nil? || dirs[dkey][:last_modified] < mtime
119
+ dirs[dkey][:last_modified] = mtime
120
+ end
121
+ end
122
+
123
+ dirs = sort(dirs)
124
+
125
+ dirs.each do |dir, data|
126
+
127
+ # overwrite cache if
128
+ if svg_symbols[dir].nil? || # No cache for this dir yet
129
+ svg_symbols[dir][:last_modified] != data[:last_modified] || # New or updated file
130
+ svg_symbols[dir][:files] != data[:files] # Changed files
131
+
132
+ attributes = data[:files].map { |f| svgs[f][:attr] }
133
+ mtimes = data[:files].map { |f| svgs[f][:last_modified] }.join
134
+
135
+ svg_symbols[dir] = data.merge({
136
+ name: dir,
137
+ asset: File.basename(dir).start_with?('_'),
138
+ version: config[:version] || Digest::MD5.hexdigest(mtimes)
139
+ })
140
+
141
+ svg_symbols[dir][:path] = write_path(dir)
142
+ end
143
+ end
144
+
145
+ @svg_symbols = sort(@svg_symbols)
146
+ @svgs = sort(@svgs)
147
+ end
148
+
149
+ def read_cache
150
+ @svgs = YAML.load(read_tmp '.svgs') || {}
151
+ end
152
+
153
+ def write_cache
154
+ return if production?
155
+
156
+ write_tmp '.svgs', sort(@svgs).to_yaml
157
+ end
158
+
159
+ def sort(hash)
160
+ sorted = {}
161
+ hash.sort.each do |h|
162
+ sorted[h.first] = h.last
163
+ end
164
+ sorted
165
+ end
166
+
167
+ def embed_script(key=nil)
168
+ if script = js(key)
169
+ "<script>#{script}</script>"
170
+ else
171
+ ''
172
+ end
173
+ end
174
+
175
+ def build_paths(keys=nil)
176
+ build_files(keys).map { |s| File.basename(s[:path]) }
177
+ end
178
+
179
+ def build_files(keys=nil)
180
+ valid_keys(keys).reject do |k|
181
+ svg_symbols[k][:asset]
182
+ end.map { |k| svg_symbols[k] }
183
+ end
184
+
185
+ def asset_files(keys=nil)
186
+ valid_keys(keys).select do |k|
187
+ svg_symbols[k][:asset]
188
+ end.map { |k| svg_symbols[k] }
189
+ end
190
+
191
+ def process_file(path, mtime)
192
+ content = File.read(path)
193
+ id = id(file_key(path))
194
+ size_attr = dimensions(content)
195
+
196
+ {
197
+ path: path,
198
+ use: %Q{<use xlink:href="##{id}"/>},
199
+ last_modified: mtime,
200
+ attr: { id: id }.merge(size_attr),
201
+ content: content
202
+ }
203
+ end
204
+
205
+ def use(file, options={})
206
+ if svg = find_svg(file, options[:fallback])
207
+
208
+ if options[:color]
209
+ options[:style] ||= ''
210
+ options[:style] += "color:#{options[:color]};#{options[:style]}"
211
+ end
212
+
213
+ attr = {
214
+ fill: options[:fill],
215
+ style: options[:style],
216
+ viewBox: svg[:attr][:viewBox],
217
+ class: [config[:class], id(svg[:name]), options[:class]].compact.join(' ')
218
+ }
219
+
220
+ # If user doesn't pass a size or set scale: true
221
+ if !(options[:width] || options[:height] || options[:scale])
222
+
223
+ # default to svg dimensions
224
+ attr[:width] = svg[:attr][:width]
225
+ attr[:height] = svg[:attr][:height]
226
+ else
227
+
228
+ # Add sizes (nil options will be stripped)
229
+ attr[:width] = options[:width]
230
+ attr[:height] = options[:height]
231
+ end
232
+
233
+ use = %Q{<svg #{attributes(attr)}>#{svg[:use]}#{title(options)}#{desc(options)}#{options[:content]||''}</svg>}
234
+
235
+ if Esvg.rails?
236
+ use.html_safe
237
+ else
238
+ use
239
+ end
240
+ else
241
+ if production?
242
+ return ''
243
+ else
244
+ raise "no svg named '#{get_alias(file)}' exists at #{config[:source]}"
245
+ end
246
+ end
247
+ end
248
+
249
+ alias :svg_icon :use
250
+
251
+ def dimensions(input)
252
+ viewbox = input.scan(/<svg.+(viewBox=["'](.+?)["'])/).flatten.last
253
+ if viewbox
254
+ coords = viewbox.split(' ')
255
+
256
+ {
257
+ viewBox: viewbox,
258
+ width: coords[2].to_i - coords[0].to_i,
259
+ height: coords[3].to_i - coords[1].to_i
260
+ }
261
+ else
262
+ {}
263
+ end
264
+ end
265
+
266
+ def attributes(hash)
267
+ att = []
268
+ hash.each do |key, value|
269
+ att << %Q{#{key}="#{value}"} unless value.nil?
270
+ end
271
+ att.join(' ')
272
+ end
273
+
274
+ def exist?(name, fallback=nil)
275
+ !find_svg(name, fallback).nil?
276
+ end
277
+
278
+ def find_svg(name, fallback=nil)
279
+ name = get_alias dasherize(name)
280
+
281
+ if svg = svgs.values.find { |v| v[:name] == name }
282
+ svg
283
+ elsif fallback
284
+ find_svg(fallback)
285
+ end
286
+ end
287
+
288
+ alias_method :exists?, :exist?
289
+
290
+ def id(name)
291
+ name = name_key(name)
292
+ if config[:namespace_before]
293
+ dasherize "#{config[:namespace]}-#{name}"
294
+ else
295
+ dasherize "#{name}-#{config[:namespace]}"
296
+ end
297
+ end
298
+
299
+ def title(options)
300
+ if options[:title]
301
+ "<title>#{options[:title]}</title>"
302
+ else
303
+ ''
304
+ end
305
+ end
306
+
307
+ def desc(options)
308
+ if options[:desc]
309
+ "<desc>#{options[:desc]}</desc>"
310
+ else
311
+ ''
312
+ end
313
+ end
314
+
315
+ def version(key)
316
+ svg_symbols[key][:version]
317
+ end
318
+
319
+ def build
320
+ paths = write_files svg_symbols.values
321
+
322
+ if config[:core]
323
+ path = File.join config[:assets], "_esvg.js"
324
+ write_file(path, js_core)
325
+ paths << path
326
+ end
327
+
328
+ paths
329
+ end
330
+
331
+ def write_files(files)
332
+ paths = []
333
+
334
+ files.each do |file|
335
+ content = js(file[:name])
336
+
337
+ write_file(file[:path], content)
338
+ puts "Writing #{file[:path]}" if config[:print]
339
+ paths << file[:path]
340
+
341
+ if !file[:asset] && gz = compress(file[:path])
342
+ puts "Writing #{gz}" if config[:print]
343
+ paths << gz
344
+ end
345
+ end
346
+
347
+ write_cache
348
+
349
+ paths
350
+ end
351
+
352
+ def symbols(keys)
353
+ symbols = valid_keys(keys).map { |key|
354
+ # Build on demand
355
+ build_symbols(svg_symbols[key][:files])
356
+ }.join.gsub(/\n/,'')
357
+
358
+ %Q{<svg id="esvg-#{key_id(keys)}" version="1.1" style="height:0;position:absolute">#{symbols}</svg>}
359
+ end
360
+
361
+ def build_symbols(files)
362
+ files.map { |file|
363
+ if svgs[file][:optimized_at].nil? || svgs[file][:optimized_at] < svgs[file][:last_modified]
364
+ svgs[file][:optimized_content] = optimize(svgs[file])
365
+ end
366
+ svgs[file][:optimized_content]
367
+ }.join.gsub(/\n/,'')
368
+ end
369
+
370
+ def js(key)
371
+ keys = valid_keys(key)
372
+ return if keys.empty?
373
+
374
+ script key_id(keys), symbols(keys).gsub('/n','').gsub("'"){"\\'"}
375
+ end
376
+
377
+ def script(id, symbols)
378
+ %Q{(function(){
379
+
380
+ function embed() {
381
+ if (!document.querySelector('#esvg-#{id}')) {
382
+ document.querySelector('body').insertAdjacentHTML('afterbegin', '#{symbols}')
383
+ }
384
+ }
385
+
386
+ // If DOM is already ready, embed SVGs
387
+ if (document.readyState == 'interactive') { embed() }
388
+
389
+ // Handle Turbolinks page change events
390
+ if ( window.Turbolinks ) {
391
+ document.addEventListener("turbolinks:load", function(event) { embed() })
392
+ }
393
+
394
+ // Handle standard DOM ready events
395
+ document.addEventListener("DOMContentLoaded", function(event) { embed() })
396
+ })()}
397
+ end
398
+
399
+ def js_core
400
+ %Q{(function(){
401
+ var names
402
+
403
+ function attr( source, name ){
404
+ if (typeof source == 'object')
405
+ return name+'="'+source.getAttribute(name)+'" '
406
+ else
407
+ return name+'="'+source+'" ' }
408
+
409
+ function dasherize( input ) {
410
+ return input.replace(/[\\W,_]/g, '-').replace(/-{2,}/g, '-')
411
+ }
412
+
413
+ function svgName( name ) {
414
+ #{if config[:namespace_before]
415
+ %Q{return "#{config[:namespace]}-"+dasherize( name )}
416
+ else
417
+ %Q{return dasherize( name )+"-#{config[:namespace]}"}
418
+ end}
419
+ }
420
+
421
+ function use( name, options ) {
422
+ options = options || {}
423
+ var id = dasherize( svgName( name ) )
424
+ var symbol = svgs()[id]
425
+
426
+ if ( symbol ) {
427
+ var svg = document.createRange().createContextualFragment( '<svg><use xlink:href="#'+id+'"/></svg>' ).firstChild;
428
+ svg.setAttribute( 'class', '#{config[:class]} '+id+' '+( options.classname || '' ).trim() )
429
+ svg.setAttribute( 'viewBox', symbol.getAttribute( 'viewBox' ) )
430
+
431
+ if ( !( options.width || options.height || options.scale ) ) {
432
+
433
+ svg.setAttribute('width', symbol.getAttribute('width'))
434
+ svg.setAttribute('height', symbol.getAttribute('height'))
435
+
436
+ } else {
437
+
438
+ if ( options.width ) svg.setAttribute( 'width', options.width )
439
+ if ( options.height ) svg.setAttribute( 'height', options.height )
440
+ }
441
+
442
+ return svg
443
+ } else {
444
+ console.error('Cannot find "'+name+'" svg symbol. Ensure that svg scripts are loaded')
445
+ }
446
+ }
447
+
448
+ function svgs(){
449
+ if ( !names ) {
450
+ names = {}
451
+ for( var symbol of document.querySelectorAll( 'svg[id^=esvg] symbol' ) ) {
452
+ names[symbol.getAttribute('name')] = symbol
453
+ }
454
+ }
455
+ return names
456
+ }
457
+
458
+ var esvg = {
459
+ svgs: svgs,
460
+ use: use
461
+ }
462
+
463
+ // Handle Turbolinks page change events
464
+ if ( window.Turbolinks ) {
465
+ document.addEventListener( "turbolinks:load", function( event ) { names = null; esvg.svgs() })
466
+ }
467
+
468
+ if( typeof( module ) != 'undefined' ) { module.exports = esvg }
469
+ else window.esvg = esvg
470
+
471
+ })()}
472
+ end
473
+
474
+ private
475
+
476
+ def file_key(path)
477
+ dasherize sub_path(config[:source], path).sub('.svg','')
478
+ end
479
+
480
+ def dir_key(path)
481
+ dir = File.dirname(flatten_path(path))
482
+
483
+ # Flattened paths which should be treated as assets will use '_' as their dir key
484
+ # - flatten: _foo - _foo/icon.svg will have a dirkey of _
485
+ # - filename: _icons - treats all root or flattened files as assets
486
+ if dir == '.' && ( sub_path(config[:source], path).start_with?('_') || config[:filename].start_with?('_') )
487
+ '_'
488
+ else
489
+ dir
490
+ end
491
+ end
492
+
493
+ def file_name(path)
494
+ dasherize flatten_path(path).sub('.svg','')
495
+ end
496
+
497
+ def flatten_path(path)
498
+ sub_path(config[:source], path).sub(Regexp.new(config[:flatten]), '')
499
+ end
500
+
501
+ def file_keys(paths)
502
+ paths.flatten.map { |p| file_key(p) }
503
+ end
504
+
505
+ def name_key(key)
506
+ if key == '_' # Root level asset file
507
+ "_#{config[:filename]}".sub(/_+/, '_')
508
+ elsif key == '.' # Root level build file
509
+ config[:filename]
510
+ else
511
+ "#{key}"
512
+ end
513
+ end
514
+
515
+ def write_path(key)
516
+ name = name_key(key)
517
+
518
+ if name.start_with?('_') # Is it an asset?
519
+ File.join config[:assets], "#{name}.js"
520
+ else # or a build file?
521
+
522
+ # User doesn't want a fingerprinted build file and hasn't set a version
523
+ if !config[:fingerprint] && !config[:version]
524
+ File.join config[:build], "#{name}.js"
525
+ else
526
+ File.join config[:build], "#{name}-#{version(key)}.js"
527
+ end
528
+ end
529
+ end
530
+
531
+ def svgo?
532
+ !!(config[:optimize] && svgo_cmd)
533
+ end
534
+
535
+ def optimize(svg)
536
+ svg[:optimized_content] = pre_optimize svg[:content]
537
+ svg[:optimized_content] = sub_def_ids svg
538
+
539
+ if svgo?
540
+ response = Open3.capture3(%Q{#{} --disable=removeUselessDefs -s '#{svg[:content]}' -o -})
541
+ svg[:optimized_content] = response[0] if response[2].success?
542
+ end
543
+
544
+ svg[:optimized_at] = Time.now.to_i
545
+ svg[:optimized_content] = post_optimize svg
546
+ end
547
+
548
+ def pre_optimize(svg)
549
+ reg = %w(xmlns xmlns:xlink xml:space version).map { |m| "#{m}=\".+?\"" }.join('|')
550
+ svg.gsub(Regexp.new(reg), '') # Remove unwanted attributes
551
+ .sub(/.+?<svg/,'<svg') # Get rid of doctypes and comments
552
+ .gsub(/style="([^"]*?)fill:(.+?);/m, 'fill="\2" style="\1') # Make fill a property instead of a style
553
+ .gsub(/style="([^"]*?)fill-opacity:(.+?);/m, 'fill-opacity="\2" style="\1') # Move fill-opacity a property instead of a style
554
+ .gsub(/\n/, '') # Remove endlines
555
+ .gsub(/\s{2,}/, ' ') # Remove whitespace
556
+ .gsub(/>\s+</, '><') # Remove whitespace between tags
557
+ .gsub(/\s?fill="(#0{3,6}|black|rgba?\(0,0,0\))"/,'') # Strip black fill
558
+ end
559
+
560
+ def post_optimize(svg)
561
+ svg[:optimized_content] = set_attributes(svg)
562
+ .gsub(/<\/svg/,'</symbol') # Replace svgs with symbols
563
+ .gsub(/class="def-/,'id="def-') # Replace <def> classes with ids (classes are generated in sub_def_ids)
564
+ .gsub(/\w+=""/,'') # Remove empty attributes
565
+ end
566
+
567
+ def set_attributes(svg)
568
+ svg[:attr].keys.each { |key| svg[:optimized_content].sub!(/ #{key}=".+?"/,'') }
569
+ svg[:optimized_content].sub(/<svg/, "<symbol #{attributes(svg[:attr])}")
570
+ end
571
+
572
+ def svgo_cmd
573
+ find_node_module('svgo')
574
+ end
575
+
576
+ # Scans <def> blocks for IDs
577
+ # If urls(#id) are used, ensure these IDs are unique to this file
578
+ # Only replace IDs if urls exist to avoid replacing defs
579
+ # used in other svg files
580
+ #
581
+ def sub_def_ids(svg)
582
+ content = svg[:optimized_content]
583
+ name = svg[:attr][:id]
584
+
585
+ return content unless !!content.match(/<defs>/)
586
+
587
+ content.scan(/<defs>.+<\/defs>/m).flatten.each do |defs|
588
+ defs.scan(/id="(.+?)"/).flatten.uniq.each_with_index do |id, index|
589
+
590
+ if content.match(/url\(##{id}\)/)
591
+ new_id = "def-#{name}-#{index}"
592
+
593
+ content = content.gsub(/id="#{id}"/, %Q{class="#{new_id}"})
594
+ .gsub(/url\(##{id}\)/, "url(##{new_id})" )
595
+ else
596
+ content = content.gsub(/id="#{id}"/, %Q{class="#{id}"})
597
+ end
598
+ end
599
+ end
600
+
601
+ content
602
+ end
603
+
604
+ def compress(file)
605
+ return if !config[:gzip]
606
+
607
+ mtime = File.mtime(file)
608
+ gz_file = "#{file}.gz"
609
+
610
+ return if (File.exist?(gz_file) && File.mtime(gz_file) >= mtime)
611
+
612
+ File.open(gz_file, "wb") do |dest|
613
+ gz = ::Zlib::GzipWriter.new(dest, Zlib::BEST_COMPRESSION)
614
+ gz.mtime = mtime.to_i
615
+ IO.copy_stream(open(file), gz)
616
+ gz.close
617
+ end
618
+
619
+ File.utime(mtime, mtime, gz_file)
620
+
621
+ gz_file
622
+ end
623
+
624
+ def write_tmp(name, content)
625
+ path = File.join(config[:temp], name)
626
+ FileUtils.mkdir_p(File.dirname(path))
627
+ write_file path, content
628
+ path
629
+ end
630
+
631
+ def read_tmp(name)
632
+ path = File.join(config[:temp], name)
633
+ if File.exist? path
634
+ File.read path
635
+ else
636
+ ''
637
+ end
638
+ end
639
+
640
+ def log_path(path)
641
+ File.expand_path(path).sub(config[:pwd], '').sub(/^\//,'')
642
+ end
643
+
644
+ def write_file(path, contents)
645
+ FileUtils.mkdir_p(File.expand_path(File.dirname(path)))
646
+ File.open(path, 'w') do |io|
647
+ io.write(contents)
648
+ end
649
+ end
650
+
651
+ def key_id(keys)
652
+ keys.map do |key|
653
+ (key == '.') ? 'symbols' : id(key)
654
+ end.join('-')
655
+ end
656
+
657
+ # Determine if an NPM module is installed by checking paths with `npm bin`
658
+ # Returns path to binary if installed
659
+ def find_node_module(cmd)
660
+ require 'open3'
661
+
662
+ return @modules[cmd] unless @modules[cmd].nil?
663
+
664
+ @modules[cmd] = begin
665
+ local = "$(npm bin)/#{cmd}"
666
+ global = "$(npm -g bin)/#{cmd}"
667
+
668
+ if Open3.capture3(local)[2].success?
669
+ local
670
+ elsif Open3.capture3(global)[2].success?
671
+ global
672
+ else
673
+ false
674
+ end
675
+ end
676
+ end
677
+
678
+ # Return non-empty key names for groups of svgs
679
+ def valid_keys(keys)
680
+ if keys.nil? || keys.empty?
681
+ svg_symbols.keys
682
+ else
683
+ keys = [keys].flatten.map { |k| dasherize k }
684
+ svg_symbols.keys.select { |k| keys.include? dasherize(k) }
685
+ end
686
+ end
687
+
688
+ # Load aliases from configuration.
689
+ # returns a hash of aliasees mapped to a name.
690
+ # Converts configuration YAML:
691
+ # alias:
692
+ # foo: bar
693
+ # baz: zip, zop
694
+ # To output:
695
+ # { :bar => "foo", :zip => "baz", :zop => "baz" }
696
+ #
697
+ def load_aliases(aliases)
698
+ a = {}
699
+ aliases.each do |name,alternates|
700
+ alternates.split(',').each do |val|
701
+ a[dasherize(val.strip).to_sym] = dasherize(name.to_s)
702
+ end
703
+ end
704
+ a
705
+ end
706
+
707
+ def get_alias(name)
708
+ config[:aliases][dasherize(name).to_sym] || name
709
+ end
710
+
711
+ def production?
712
+ config[:produciton] || if Esvg.rails?
713
+ Rails.env.production?
714
+ end
715
+ end
716
+ end
717
+ end