UNPKG

coffee-sprites

Version:

CoffeeScript/JavaScript stylesheet-based sprite image engine.

365 lines (322 loc) 12.2 kB
gd = require 'node-gd' async = require 'async2' fs = require 'fs' path = require 'path' instance = `undefined` spawn = require('child_process').spawn log = (i,m) -> l=instance.o.logger; (l.length is 2 and l i,m) or l m; return class CoffeeSprites constructor: (o) -> instance = @ o = o or {} o.image_path = o.image_path or '' o.sprite_path = o.sprite_path or '' o.sprite_url = o.sprite_url or '' o.logger = o.logger or console.log o.manifest_file = o.manifest_file or path.join o.sprite_path, 'sprite-manifest.json' @o = o @reset() reset: -> @sprites = {} @_read_manifest = false read_manifest: -> # read first time only # call reset() to permit reading again unless @_read_manifest @_read_manifest = true if fs.existsSync @o.manifest_file data = (JSON.parse(fs.readFileSync @o.manifest_file)) or {} for name, sprite of data.sprites @sprites[name] = new Sprite name, sprite.options for i, file of sprite.images abspath = path.join @o.image_path, (sprite.options.path or ''), file+'.png' if fs.existsSync abspath @sprites[name].add file return write_manifest: -> data = sprites: {} count = 0 for name of @sprites data.sprites[name] = options: @sprites[name].o images: [] for file of @sprites[name].images data.sprites[name].images.push file count++ if count fs.writeFileSync @o.manifest_file, JSON.stringify data, null, 2 log 'success', "wrote #{path.relative process.cwd(), @o.manifest_file}" return extend: (engine) -> # CoffeeStylesheets instance g=engine.o.globals generate_placeholder = (key, name, png) => @read_manifest() if typeof png isnt 'undefined' @sprites[name].add png "SPRITE_#{key}_PLACEHOLDER(#{name}, #{png or ''})" # TODO: add validation to ensure these functions are never permitted # to be called with invalid arguments g.sprite_map = (name, options) => @read_manifest() if @sprites[name] # reuse existing instance by same name for k of options # merging new options over existing @sprites[name].o[k] = options[k] else # new sprite instance sprite = new Sprite name, options @sprites[name] = sprite return name g.sprite = (sprite, png) -> generate_placeholder 'URL_AND_IMAGE_POSITION', sprite, png g.sprite_url = (sprite, png) -> generate_placeholder 'URL', sprite, png g.sprite_position = (sprite, png) -> generate_placeholder 'POSITION', sprite, png g.sprite_width = (sprite, png) -> generate_placeholder 'WIDTH', sprite, png g.sprite_height = (sprite, png) -> generate_placeholder 'HEIGHT', sprite, png # coffeestylesheets done compiling engine.on.end = (css, cb) => # save final sprites to disk flow = new async() for name, sprite of @sprites ((sprite)-> flow.series -> sprite.render @)(sprite) flow.finally (err, changes) => return cb err if err # replace placeholders in css css = css.replace /SPRITE_(.+?)_PLACEHOLDER\((.+?), (.*?)\)/g, (match, key, name, png) => sprite = @sprites[name] image = sprite.images[png] switch key when 'POSITION' return image.coords() when 'URL' return sprite.digest_url image.tileset() when 'URL_AND_IMAGE_POSITION' return "url(#{sprite.digest_url image.tileset()}) #{image.coords()}" when 'WIDTH' return image.px image.w when 'HEIGHT' return image.px image.h @write_manifest() instance.reset() # return final css cb null, css return return return class Image constructor: (@sprite, @file) -> @absfile = path.join instance.o.image_path, @file+'.png' @src = `undefined` @x = 0 @y = 0 @w = 0 @h = 0 toString: -> "Image#file=#{@file},x=#{@x},y=#{@y},width=#{@w},height=#{@h}" read: (cb) -> gd.openPng @absfile, (err, src) => return cb err if err @src = src @w = src.width @h = src.height cb null, @ basename: -> path.basename @absfile, '.png' repeat: -> switch repeat = @sprite.o[@basename()+'-repeat'] or 'no-repeat' when 'no-repeat', 'repeat-x', 'repeat-y' # valid else throw err "WARN: #{repeat} is an invalid repeat value" repeat tileset: -> if (repeat = @repeat()) is 'no-repeat' and @sprite.o.layout is 'smart' then 'smart' else repeat px: (i) -> if i is 0 then 0 else i + 'px' coords: -> @px(@x * -1) + ' ' + @px(@y * -1) sprite_count = 0 class Sprite constructor: (name, o) -> if typeof name isnt 'string' o = name name = '' @name = name or 'sprite-'+(++sprite_count) o = o or {} o.layout = o.layout or 'smart' @images = {} @tilesets = {} @tileset_types = ['smart', 'no-repeat', 'repeat-x','repeat-y'] # in order @digest = '' @o = o return add: (file) -> if typeof @images[file] is 'undefined' # cache @images[file] = new Image @, path.join(@o.path or '', file) k = @images[file].tileset() if typeof @tilesets[k] is 'undefined' @tilesets[k] = images: [] digest: '' digest_file: '' src: `undefined` w: 0 h: 0 @tilesets[k].images.push @images[file] # group images into tilesets by repeat type return @images[file] render: (cb) -> sprite = @ count = 0; count++ for k of sprite.images return cb null, "sprite map \"#{sprite.name}\" has no images." if count < 1 # read image w, h dimensions read = => flow = async.new() for type, tileset of sprite.tilesets for k, image of tileset.images ((image)-> flow.series -> image.read @)(image) flow.finally (err) -> return cb err if err position_and_pack() # calculate x, y positions, and md5 digests # grouped by tileset position_and_pack = => sprite.o.spacing = sprite.o.spacing or 0 changes = true for type, tileset of sprite.tilesets if type is 'smart' # means 2d binary packing algorithm for k, image of tileset.images image._w = image.w image.w += sprite.o.spacing image._h = image.h image.h += sprite.o.spacing sort = # sort tileset images w: (a, b) -> b.w - a.w h: (a, b) -> b.h - a.h max: (a, b) -> Math.max(b.w, b.h) - Math.max(a.w, a.h) min: (a, b) -> Math.min(b.w, b.h) - Math.min(a.w, a.h) maxside: (a, b) -> # by multiple criteria c = ["max", "min", "h", "w"] n = 0 while n < c.length diff = sort[c[n]](a, b) return diff unless diff is 0 n++ 0 tileset.images.sort sort.maxside GrowingPacker = require '../vendor/packer.growing.js' packer = new GrowingPacker() packer.fit tileset.images tileset.w = packer.root.w tileset.h = packer.root.h for k, image of tileset.images when image.fit image.x = image.fit.x image.y = image.fit.y image.w = image._w image.h = image._h else if type is 'repeat-y' for k, image of tileset.images tileset.h = Math.max tileset.h, image.h image.x = tileset.w tileset.w += image.w + sprite.o.spacing else if type is 'repeat-x' or type is 'no-repeat' for k, image of tileset.images tileset.w = Math.max tileset.w, image.w image.y = tileset.h tileset.h += image.h + sprite.o.spacing tileset.digest = sprite.calc_digest type changes &= not fs.existsSync tileset.digest_file = sprite.digest_file type return cb null, "no changes in sprite(s)." unless changes render_to_disk() # create up to four new blank sprite canvases # one for each tileset render_to_disk = -> flow = new async for k, type of sprite.tileset_types when tileset = sprite.tilesets[type] ((type, tileset) -> flow.serial -> next = @ tileset.src = gd.createTrueColor tileset.w, tileset.h transparency = tileset.src.colorAllocateAlpha 0, 0, 0, 127 tileset.src.fill 0, 0, transparency tileset.src.colorTransparent transparency tileset.src.alphaBlending 0 tileset.src.saveAlpha 1 count = 0 # compile sprite tileset in memory for k, image of tileset.images count++ switch type when 'no-repeat', 'smart' #console.log "#{image.basename()} over #{type} at #{image.x}, #{image.y} dim #{image.w} x #{image.h}" image.src.copy tileset.src, image.x, image.y, 0, 0, image.w, image.h when 'repeat-x' # stretch it across width of image #console.log "#{image.basename()} over #{type} at #{x}, #{image.y} dim #{image.w} x #{image.h}" for x in [0...tileset.w] by image.w image.src.copy tileset.src, x, image.y, 0, 0, image.w, image.h when 'repeat-y' #console.log "#{image.basename()} over #{type} at #{image.x}, #{y} dim #{image.w} x #{image.h}" # stretch it across height of image for y in [0...tileset.h] by image.h image.src.copy tileset.src, image.x, y, 0, 0, image.w, image.h # delete old sprites off disk pattern = tileset.digest_file.replace /-[\w\d+]+\.png$/, '-*.png' files = require('glob').sync pattern for file in files fs.unlinkSync file # override sprite png on disk outfile = if instance.o.pngcrush tileset.digest_file+'.tmp' else tileset.digest_file outfile = tileset.digest_file+(if instance.o.pngcrush then '.tmp' else '') log 'pending', "writing #{count} images to #{path.relative process.cwd(), tileset.digest_file}..." tileset.src.savePng outfile, 0, => # optimize png if instance.o.pngcrush log 'pending', "pngcrush #{path.relative process.cwd(), tileset.digest_file}..." p = spawn instance.o.pngcrush, ['-rem', 'alla', '-reduce', '-brute', outfile, tileset.digest_file] stdout = '' p.stdout.on 'data', (data) -> stdout = data p.stderr.on 'data', (err) -> return next err if err p.on 'exit', (code) -> fs.unlinkSync outfile if fs.existsSync outfile return next "pngcrush exited with code #{code}. #{stdout}" if code isnt 0 return next null, true else return next null, true )(type, tileset) flow.finally (err) -> return cb err if err cb null return read() return calc_digest: (type) -> # convert to array b = o: [], i: [] for k of @o b.o.push k+':'+@o[k] for k, image of @tilesets[type].images b.i.push image.basename() # sort and concatenate b = b.o.sort().join('|')+'|'+b.i.sort().join('|') # calculate digest hash require('crypto').createHash('md5').update(b).digest('hex').substr(-10) suffix: (s) -> { 'smart': '', 'no-repeat': '', 'repeat-x': '-x', 'repeat-y': '-y' }[s] digest_file: (type) -> path.join instance.o.sprite_path, "#{@name}#{@suffix type}-#{@tilesets[type].digest}.png" digest_url: (type) -> path.join instance.o.sprite_url, "#{@name}#{@suffix type}-#{@tilesets[type].digest}.png" module.exports = (options) -> new CoffeeSprites options (engine) -> instance.extend engine instance