esvg 4.1.6 → 4.2.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,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