require 'pathname' require 'fileutils' module SpriteFactory class Runner #---------------------------------------------------------------------------- PSEUDO_CLASS_ORDER = [nil, ':link', ':visited', ':focus', ':hover', ':active'] # TODO: allow caller to specify this order ? #---------------------------------------------------------------------------- attr :input attr :config def initialize(input, config = {}) @input = input.to_s[-1] == "/" ? input[0...-1] : input # gracefully ignore trailing slash on input directory name @config = config @config[:style] ||= SpriteFactory.style || :css @config[:layout] ||= SpriteFactory.layout || :horizontal @config[:library] ||= SpriteFactory.library || :rmagick @config[:selector] ||= SpriteFactory.selector || 'img.' @config[:cssurl] ||= SpriteFactory.cssurl @config[:report] ||= SpriteFactory.report @config[:pngcrush] ||= SpriteFactory.pngcrush @config[:nocomments] ||= SpriteFactory.nocomments @config[:separator] ||= SpriteFactory.separator || '_' @config[:glob] ||= SpriteFactory.glob || '*' @config[:sanitizer] ||= SpriteFactory.sanitizer @config[:exclude] ||= SpriteFactory.exclude || [] end #---------------------------------------------------------------------------- def run!(&block) raise RuntimeError, "unknown layout #{layout_name}" if !Layout.respond_to?(layout_name) raise RuntimeError, "unknown style #{style_name}" if !Style.respond_to?(style_name) raise RuntimeError, "unknown library #{library_name}" if !Library.respond_to?(library_name) raise RuntimeError, "input must be a single directory" if input.nil? || input.to_s.empty? || !File.directory?(input) raise RuntimeError, "no image files found" if image_files.empty? raise RuntimeError, "no output file specified" if output.to_s.empty? raise RuntimeError, "no output image file specified" if output_image_file.to_s.empty? raise RuntimeError, "no output style file specified" if output_style_file.to_s.empty? raise RuntimeError, "set :width for fixed width, or :hpadding for horizontal padding, but not both." if width && !hpadding.zero? raise RuntimeError, "set :height for fixed height, or :vpadding for vertical padding, but not both." if height && !vpadding.zero? raise RuntimeError, "set :width for fixed width, or :hmargin for horizontal margin, but not both." if width && !hmargin.zero? raise RuntimeError, "set :height for fixed height, or :vmargin for vertical margin, but not both." if height && !hmargin.zero? raise RuntimeError, "The legacy :csspath attribute is no longer supported, please use :cssurl instead (see README)" unless @config[:csspath].nil? images = load_images max = layout_images(images) header = summary(images, max) report(header) css = [] css << style_comment(header) unless nocomments? # header comment css << style(selector, css_url, images, &block) # generated styles css << IO.read(custom_style_file) if File.exists?(custom_style_file) # custom styles css = css.join("\n") create_sprite(images, max[:width], max[:height]) unless nocss? css_file = File.open(output_style_file, "w+") css_file.puts css css_file.close end if config[:return] == :images images # if caller explicitly asked for detailed images hash instead of generated CSS else css # otherwise, default is to return the generated CSS to caller in string form end end #---------------------------------------------------------------------------- private def selector config[:selector] end def style_name config[:style] end def layout_name config[:layout] end def library_name config[:library] end def hpadding config[:hpadding] || config[:padding] || 0 end def vpadding config[:vpadding] || config[:padding] || 0 end def hmargin config[:hmargin] || config[:margin] || 0 end def vmargin config[:vmargin] || config[:margin] || 0 end def width config[:width] end def height config[:height] end def output config[:output] || input end def output_image_file config[:output_image] || "#{output}.png" end def output_style_file config[:output_style] || "#{output}.#{style_name}" end def nocss? config[:nocss] # set true if you dont want an output style file generated (e.g. you will take the #run! output and store it yourself) end def nocomments? config[:nocomments] # set true if you dont want any comments in the output style file end def separator config[:separator] end def sanitizer config[:sanitizer] end def exclude config[:exclude] end def exclude?(file) Array(exclude).any? { |name| file.include?(name) } end def custom_style_file File.join(input, File.basename(input) + ".#{style_name}") end def css_url base = File.basename(output_image_file) custom = config[:cssurl] if custom if custom.is_a?(Proc) custom.call(base) # allow custom url using a lambda elsif custom.include?('$IMAGE') custom.sub('$IMAGE', base) # allow custom url with simple token replacement else "url(#{File.join(custom, base)})" # allow custom url with simple prepend end else "url(#{base})" # otherwise, just default to basename of the output image file end end #---------------------------------------------------------------------------- def image_files return [] if input.nil? valid_extensions = library::VALID_EXTENSIONS expansions = Array(valid_extensions).map{|ext| File.join(input, "**", "#{config[:glob]}.#{ext}")} files = SpriteFactory.find_files(*expansions) files = files.reject{ |file| exclude?(file) } files end #---------------------------------------------------------------------------- def library @library ||= Library.send(library_name) end def load_images input_path = Pathname.new(input) images = library.load(image_files) images.each do |i| i[:name], i[:ext] = extract_image_filename(i[:filename], input_path) raise RuntimeError, "image #{i[:name]} does not fit within a fixed width of #{width}" if width && (width < i[:width]) raise RuntimeError, "image #{i[:name]} does not fit within a fixed height of #{height}" if height && (height < i[:height]) end images.sort_by {|i| [image_name_without_pseudo_class(i), image_pseudo_class_priority(i)] } end def extract_image_filename(filename, input_path) name = Pathname.new(filename).relative_path_from(input_path).to_s.gsub(File::SEPARATOR, separator) ext = File.extname(name) name = name[0...-ext.length] unless ext.empty? name = sanitize_image_filename(name) [name, ext] end def sanitize_image_filename(name) if sanitizer.is_a?(Proc) sanitizer.call(name) # custom sanitizer elsif sanitizer name.gsub(/[^\w-]/, '') # (opt-in) clean all non-word characters else name.gsub('--', ':').gsub('__', ' ') # legacy behavior end end def image_name_without_pseudo_class(image) image[:name].split(':').first end def image_pseudo_class(image) image[:name].slice(/:.*?\Z/) end def image_pseudo_class_priority(image) PSEUDO_CLASS_ORDER.index(image_pseudo_class(image)) end #---------------------------------------------------------------------------- def create_sprite(images, width, height) library.create(output_image_file, images, width, height) pngcrush(output_image_file) end #---------------------------------------------------------------------------- def layout_strategy @layout_strategy ||= Layout.send(layout_name) end def layout_images(images) layout_strategy.layout(images, :width => width, :height => height, :hpadding => hpadding, :vpadding => vpadding, :hmargin => hmargin, :vmargin => vmargin) end #---------------------------------------------------------------------------- def style(selector, url, images, &block) defaults = Style.generate(style_name, selector, url, images) # must call, even if custom block is given, because it stashes generated css style into image[:style] attributes if block_given? yield images.inject({}) {|h,i| h[i[:name].to_sym] = i; h} # provide custom rule builder a hash by image name else defaults end end def style_comment(comment) Style.comment(style_name, comment) end #---------------------------------------------------------------------------- SUPPORTS_PNGCRUSH = !`which pngcrush`.empty? rescue false # rescue on environments without `which` (windows) def pngcrush(image) if SUPPORTS_PNGCRUSH && config[:pngcrush] crushed = "#{image}.crushed" system('pngcrush', '-q', '-rem alla', '-reduce', '-brute', image, crushed) FileUtils.mv(crushed, image) end end #---------------------------------------------------------------------------- def summary(images, max) return <<-EOF Creating a sprite from following images: \n#{images.map{|i| " #{report_path(i[:filename])} (#{i[:width]}x#{i[:height]})" }.join("\n")} Output files: #{report_path(output_image_file)} #{report_path(output_style_file)} Output size: #{max[:width]}x#{max[:height]} EOF end def report(msg) puts msg if config[:report] end def report_path(path) # always report paths relative to . to avoid machine specific information in report (to avoid DIFF issues in tests and version control) @cwd ||= Pathname.new(File.expand_path('.')) path = Pathname.new(path) path = path.relative_path_from(@cwd) if path.absolute? path.to_s end #---------------------------------------------------------------------------- end # class Runner end # module SpriteFactory