BogaudioModules

BogaudioModules for VCV Rack
Log | Files | Refs | README | LICENSE

svg_preprocess.rb (12480B)


      1 #!/usr/bin/env ruby
      2 
      3 require 'css_parser'
      4 require 'listen'
      5 require 'nokogiri'
      6 require 'optparse'
      7 
      8 options = {
      9   reprocess: false,
     10   listen: false,
     11   module_prefixes: [],
     12   debug: false
     13 }
     14 option_parser = OptionParser.new do |opts|
     15   opts.banner = "Usage: #{$0} [options] [res-src/MODULE-src.svg]"
     16   opts.on('--reprocess', 'Process everything immediately, if no file specfied') do
     17     options[:reprocess] = true
     18   end
     19   opts.on('--listen', 'Listen for changes, if no file specfied; may be used with --reprocess') do
     20     options[:listen] = true
     21   end
     22   opts.on('--filter=[module prefix]', 'Limit --reprocess or --listen to modules matching prefix; may be used multiply') do |v|
     23     options[:module_prefixes] << v if v
     24   end
     25   opts.on('--debug', 'Print directory and file status information and exit') do
     26     options[:debug] = true
     27   end
     28   opts.on_tail('-h', '--help', 'Show this message') do
     29     puts opts
     30     exit
     31   end
     32 end
     33 begin
     34   option_parser.parse!
     35 rescue => e
     36   STDERR.puts e.to_s
     37   STDERR.puts "\n"
     38   STDERR.puts option_parser.help
     39   exit 1
     40 end
     41 
     42 def parse_xml(s)
     43   Nokogiri::XML(s) do |config|
     44     config.norecover.strict
     45   end
     46 end
     47 
     48 def read_xml(fn)
     49   parse_xml(File.open(fn))
     50 end
     51 
     52 $src_dir = nil
     53 $pp_dir = nil
     54 def load_directories()
     55   unless $src_dir
     56     $src_dir = File.absolute_path(File.join(File.dirname($0), '..', 'res-src'))
     57     unless Dir.exist?($src_dir)
     58       STDERR.puts "Source directory doesn't exist: #{$src_dir}"
     59       exit 1
     60     end
     61   end
     62 
     63   unless $pp_dir
     64     $pp_dir = File.absolute_path(File.join(File.dirname($0), '..', 'res-pp'))
     65     unless Dir.exist?($pp_dir)
     66       STDERR.puts "Preprocess directory doesn't exist: #{$pp_dir}"
     67       exit 1
     68     end
     69   end
     70 end
     71 
     72 def main_styles_name()
     73   load_directories()
     74   File.join($src_dir, 'styles.css')
     75 end
     76 
     77 $styles_loaded = false
     78 $main_styles = nil
     79 $skins = {}
     80 def load_styles(force = false)
     81   if force
     82     $styles_loaded = false
     83     $main_styles = nil
     84     $skins = {}
     85   end
     86 
     87   unless $styles_loaded
     88     $styles_loaded = true
     89 
     90     fn = main_styles_name()
     91     if File.readable?(fn)
     92       $main_styles = File.read(fn)
     93     end
     94 
     95     load_directories()
     96     Dir.glob(File.join($src_dir, 'skin-*.css')).each do |fn|
     97       m = File.basename(fn).match(/^skin-(.*)\.css/)
     98       $skins[m[1]] = [fn, File.read(fn)]
     99     end
    100   end
    101 end
    102 
    103 def defs_name()
    104   load_directories()
    105   File.join($src_dir, 'defs.svg')
    106 end
    107 
    108 $defs_loaded = false
    109 $defs = {}
    110 def load_defs(force = false)
    111   if force
    112     $defs_loaded = false
    113     $defs = {}
    114   end
    115 
    116   unless $defs_loaded
    117     $defs_loaded = true
    118 
    119     fn = defs_name()
    120     if File.readable?(fn)
    121       doc = read_xml(fn)
    122       doc.css('defs symbol').each do |n|
    123         id = n['id'].to_s
    124         unless id && !id.empty?
    125           STDERR.puts "Symbol in #{fn} with missing ID: #{n.to_s}"
    126           exit 1
    127         end
    128         $defs[id] = n
    129       end
    130     end
    131   end
    132 end
    133 
    134 def widget_from_filename(fn)
    135   File.basename(fn).sub(/^(.*)-src\.svg$/, '\1')
    136 end
    137 
    138 $widget_names_loaded = false
    139 $widget_names = []
    140 def load_widget_names(force = false)
    141   if force
    142     $widget_names_loaded = false
    143     $widget_names = []
    144   end
    145 
    146   unless $widget_names_loaded
    147     $widget_names_loaded = true
    148 
    149     load_directories()
    150     $widget_names = Dir.glob(File.join($src_dir, '*-src.svg')).map do |fn|
    151       widget_from_filename(fn)
    152     end.sort
    153   end
    154 end
    155 
    156 def debug()
    157   puts
    158 
    159   load_directories()
    160   puts "Source directory: #{$src_dir}"
    161   puts "Preprocess directory: #{$pp_dir}"
    162   puts
    163 
    164   load_styles()
    165   puts "Main stylesheet (#{main_styles_name()}): #{$main_styles ? "#{$main_styles.size} bytes" : 'missing'}"
    166   if $skins.empty?
    167     puts "Skins: none"
    168   else
    169     $skins.keys.sort.each do |skin_name|
    170       skin = $skins[skin_name]
    171       puts "  - #{skin_name} (#{skin[0]}): #{skin[1].size} bytes"
    172     end
    173   end
    174   puts
    175 
    176   load_defs()
    177   if $defs.empty?
    178     puts "Defs (#{defs_name()}): missing"
    179   else
    180     puts "Defs (#{defs_name()}):"
    181     $defs.keys.sort.each do |def_id|
    182       puts "  - #{def_id}"
    183     end
    184   end
    185   puts
    186 
    187   load_widget_names()
    188   puts "Modules:"
    189   $widget_names.each do |name|
    190     puts "  - #{name}"
    191   end
    192   puts
    193 end
    194 
    195 def process_def(fn, doc, n, local_defs)
    196   id = n.attribute('href').to_s
    197   if id
    198     id.sub!(/^#/, '')
    199     d = local_defs[id]
    200     d = $defs[id] unless d
    201     if d
    202       nn = d.dup
    203       nn.node_name = 'svg'
    204       nn.delete('viewBox')
    205       nn.delete('id')
    206 
    207       g = Nokogiri::XML::Node.new('g', doc)
    208       g.add_child(nn)
    209       n.replace(g)
    210 
    211       n.each do |k, v|
    212         case k
    213         when 'id'
    214           if !v.to_s.empty?
    215             nn['id'] = v
    216           end
    217         when 'transform'
    218           if !v.to_s.empty?
    219             g['transform'] = v
    220           end
    221         when /^var-(\w+)$/
    222           g[k] = v
    223         end
    224       end
    225 
    226       nn.css('def').each do |dn|
    227         process_def(fn, doc, dn, local_defs)
    228       end
    229     else
    230       puts "WARN: no def defined for def ID '#{id}' in #{fn}"
    231       n.remove
    232     end
    233   else
    234     puts "WARN: def without ID in #{fn}: #{n.to_s}"
    235     n.remove
    236   end
    237 end
    238 
    239 def variable_value(k, vars, defaults)
    240   v = nil
    241   vars.reverse_each do |vs|
    242     if vs.key?(k)
    243       v = vs[k]
    244       break
    245     end
    246   end
    247 
    248   if v.nil?
    249     if defaults.key?(k)
    250       v = defaults[k]
    251     else
    252       v = 0.0
    253     end
    254   end
    255 
    256   v
    257 end
    258 
    259 def eval_expressions(s, vars, defaults)
    260   s.gsub!(/\$\{(\w+)\}/) do
    261     variable_value($1, vars, defaults)
    262   end
    263 
    264   s.gsub!(/\$(\w+)/) do
    265     variable_value($1, vars, defaults)
    266   end
    267 
    268   if s =~ /[\+\-\*\/]/
    269     s.gsub!(/\-?\d+(\.\d+)?([\+\-\*\%\/]+\d+(\.\d+)?)*/) do |m|
    270       eval(m)
    271     end
    272   end
    273 
    274   s
    275 end
    276 
    277 def process_variables(n, vars)
    278   n.each do |k, v|
    279     if k =~ /^(var|default)-(\w+)$/
    280       n[k] = eval_expressions(v.to_s, vars, {})
    281     end
    282   end
    283 
    284   vs = {}
    285   n.each do |k, v|
    286     if k =~ /^var-(\w+)$/
    287       vs[$1] = v
    288       n.delete(k)
    289     end
    290   end
    291   vars.push(vs)
    292 
    293   default_vars = {}
    294   n.each do |k, v|
    295     if k =~ /^default-(\w+)$/
    296       default_vars[$1] = v
    297       n.delete(k)
    298     end
    299   end
    300 
    301   n.each do |k, v|
    302     n[k] = eval_expressions(v.to_s, vars, default_vars)
    303   end
    304 
    305   case n.node_name
    306   when 'text'
    307     n.traverse do |t|
    308       if t.text?
    309         t.content = eval_expressions(t.content, vars, default_vars)
    310       end
    311     end
    312   end
    313 
    314   n.elements.each do |nn|
    315     process_variables(nn, vars)
    316   end
    317 
    318   vars.pop
    319 end
    320 
    321 def write_output(name, doc, styles)
    322   doc.css('style').each do |n|
    323     n.content = styles
    324   end
    325 
    326   doc.css('localstyle').each do |n|
    327     n.node_name = 'style'
    328     styles += n.content
    329   end
    330 
    331   # hack to inline the path stroke on each path; rendering through Inkscape doesn't handle styles on paths correctly.
    332   parser = CssParser::Parser.new
    333   parser.load_string!(styles)
    334 
    335   stroke = '#333'
    336   if parser.find_by_selector('path').last =~ /stroke:\s+(#\w+);/
    337     stroke = $1
    338   end
    339 
    340   input_stroke = '#333'
    341   if parser.find_by_selector('path.input-label').last =~ /stroke:\s+(#\w+);/
    342     input_stroke = $1
    343   end
    344 
    345   output_stroke = '#333'
    346   if parser.find_by_selector('path.output-label').last =~ /stroke:\s+(#\w+);/
    347     output_stroke = $1
    348   end
    349 
    350   doc.css('path').each do |n|
    351     n['stroke'] = stroke
    352   end
    353   doc.css('path[@class="input-label"]').each do |n|
    354     n['stroke'] = input_stroke
    355   end
    356   doc.css('path[@class="output-label"]').each do |n|
    357     n['stroke'] = output_stroke
    358   end
    359   # end hack
    360 
    361   fn = File.join($pp_dir, "#{name}-pp.svg")
    362   File.write(fn, doc.to_xml)
    363   puts "Wrote #{fn}"
    364 end
    365 
    366 def process(name)
    367   load_directories()
    368   load_styles()
    369   load_defs()
    370 
    371   fn = File.join($src_dir, File.basename(name))
    372   unless File.readable?(fn)
    373       STDERR.puts "No such file: #{fn}"
    374       exit 1
    375   end
    376 
    377   doc = read_xml(fn)
    378   noskin = false
    379   globals = {}
    380 
    381   root = doc.at_css(':root')
    382   if root.node_name == 'module'
    383     hp = 3
    384     if root['hp'] && !root['hp'].to_s.empty?
    385       hp = root['hp'].to_s.to_i
    386     end
    387     root.delete('hp')
    388     if root['noskin'] && root['noskin'].to_s == 'true'
    389       noskin = true
    390     end
    391     root.delete('noskin')
    392     globals['hp'] = hp
    393     globals['width'] = hp * 15.0
    394     globals['height'] = 380.0
    395 
    396     root.node_name = 'svg'
    397     root['xmlns'] = 'http://www.w3.org/2000/svg'
    398     root['xmlns:xlink'] = 'http://www.w3.org/1999/xlink'
    399     root['version'] = '1.1'
    400     root['width'] = globals['width']
    401     root['height'] = globals['height']
    402     root['viewBox'] = "0 0 #{root['width']} #{root['height']}"
    403 
    404     doc = parse_xml(doc.to_xml)
    405   elsif root.node_name == 'widget'
    406     if root['noskin'] && root['noskin'].to_s == 'true'
    407       noskin = true
    408     end
    409     root.delete('noskin')
    410 
    411     width = 30.0
    412     if root['width'] && !root['width'].to_s.empty?
    413       width = root['width'].to_f
    414     end
    415     height = 30.0
    416     if root['height'] && !root['height'].to_s.empty?
    417       height = root['height'].to_f
    418     end
    419     globals['width'] = width
    420     globals['height'] = height
    421 
    422     root.node_name = 'svg'
    423     root['xmlns'] = 'http://www.w3.org/2000/svg'
    424     root['xmlns:xlink'] = 'http://www.w3.org/1999/xlink'
    425     root['version'] = '1.1'
    426     root['width'] = globals['width']
    427     root['height'] = globals['height']
    428     root['viewBox'] = "0 0 #{root['width']} #{root['height']}"
    429 
    430     doc = parse_xml(doc.to_xml)
    431   end
    432 
    433   local_defs = {}
    434   doc.css('defs symbol').each do |n|
    435     id = n['id'].to_s
    436     unless id && !id.empty?
    437       STDERR.puts "Symbol in #{fn} with missing ID: #{n.to_s}"
    438       exit 1
    439     end
    440     local_defs[id] = n
    441   end
    442 
    443   doc.css('defs import').each do |n|
    444     id = n.attribute('id').to_s
    445     if id
    446       d = $defs[id]
    447        if d
    448         n.replace(d)
    449       else
    450         puts "WARN: no def for import ID '#{id}' in #{fn}"
    451         n.remove
    452       end
    453     else
    454       puts "WARN: import without ID in #{fn}: #{n.to_s}"
    455       n.remove
    456     end
    457   end
    458 
    459   doc.css('def').each do |n|
    460     process_def(fn, doc, n, local_defs)
    461   end
    462 
    463   doc.xpath('//comment()').each(&:remove)
    464 
    465   vars = [globals]
    466   process_variables(doc.at_css(':root'), vars)
    467 
    468   doc.css('g').each do |n|
    469     n.replace(n.children) if n.keys.empty?
    470   end
    471   doc.css('svg').each do |n|
    472     n.replace(n.children) if n.keys.empty?
    473   end
    474 
    475   name = widget_from_filename(fn)
    476   write_output(name, doc, $main_styles)
    477   unless noskin
    478     $skins.each do |skin_name, skin|
    479       write_output("#{name}-#{skin_name}", doc, "#{$main_styles}\n\n#{skin[1]}")
    480     end
    481   end
    482 end
    483 
    484 def reprocess(prefixes)
    485   load_directories()
    486   load_widget_names()
    487 
    488   name_re = if prefixes.empty?
    489     /./
    490   else
    491     /^(#{prefixes.join('|')})/
    492   end
    493   $widget_names.each do |name|
    494     if name =~ name_re
    495       process("#{name}-src.svg")
    496     end
    497   end
    498 end
    499 
    500 def listen(prefixes)
    501   load_directories()
    502 
    503   puts "Listening for changes in #{$src_dir}..."
    504   unless prefixes.empty?
    505     puts "(filering on #{prefixes.join(', ')})"
    506   end
    507 
    508   name_re = if prefixes.empty?
    509     /-src.svg$/
    510   else
    511     /^(#{prefixes.join('|')}).*-src.svg$/
    512   end
    513 
    514   listener = Listen.to($src_dir) do |modified, added, removed|
    515     modified.each do |fn|
    516       begin
    517         bn = File.basename(fn)
    518         if bn =~ name_re
    519           process(fn)
    520         elsif bn == 'styles.css' || bn =~ /^skin-.*\.css$/
    521           load_styles(true)
    522           reprocess(prefixes)
    523         elsif bn == 'defs.svg'
    524           load_defs(true)
    525           reprocess(prefixes)
    526         end
    527       rescue => e
    528         STDERR.puts "Error processing #{fn}:\n#{e}"
    529       end
    530     end
    531 
    532     added.each do |fn|
    533       begin
    534         bn = File.basename(fn)
    535         if bn =~ /-src\.svg$/
    536           load_widget_names(true)
    537         end
    538 
    539         if bn =~ name_re
    540           process(fn)
    541         end
    542       rescue => e
    543         STDERR.puts "Error processing #{fn}:\n#{e}"
    544       end
    545     end
    546 
    547     removed.each do |fn|
    548       begin
    549         bn = File.basename(fn)
    550         if bn =~ /-src\.svg$/
    551           load_widget_names(true)
    552         end
    553 
    554         if bn =~ name_re
    555           fs = [bn.sub(/-src.svg/, '-pp.svg')]
    556           $skins.keys.each do |skin_name|
    557             fs << bn.sub(/-src.svg/, "-#{skin_name}-pp.svg")
    558           end
    559           fs.each do |f|
    560             begin
    561               f = File.join($pp_dir, f)
    562               File.unlink(f)
    563             rescue Errno::ENOENT => e
    564             end
    565           end
    566         end
    567       rescue => e
    568         STDERR.puts "Error processing #{fn}:\n#{e}"
    569       end
    570     end
    571   end
    572 
    573   listener.start
    574   begin
    575     sleep
    576   rescue Interrupt => e
    577   end
    578 end
    579 
    580 if options[:debug]
    581   debug()
    582 elsif ARGV.size >= 1
    583   process(ARGV[0])
    584 elsif options[:reprocess] || options[:listen]
    585   reprocess(options[:module_prefixes]) if options[:reprocess]
    586   listen(options[:module_prefixes]) if options[:listen]
    587 else
    588   STDERR.puts option_parser.help
    589   exit 1
    590 end