├── .travis.yml ├── .gitignore ├── test ├── lcm.js ├── walker.js ├── image.js ├── composite.js ├── git.js ├── processor.js └── layout.js ├── package.json ├── lib ├── lcm.js ├── image.js ├── walker.js ├── composite.js ├── utils.js ├── git.js ├── main.js ├── layout.js └── processor.js ├── CONTRIBUTING.md ├── README.md ├── bin └── shalam └── LICENSE /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.10" 4 | script: 5 | - "npm test" 6 | before_install: sudo apt-get install libcairo2-dev libjpeg8-dev libpango1.0-dev libgif-dev build-essential g++ 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.pyc 3 | tags 4 | TAGS 5 | *.swp 6 | *.egg-info 7 | .emacs.desktop 8 | .#* 9 | build 10 | dist 11 | *~ 12 | .buildout 13 | .emacs.d/.autosaves 14 | .emacs.d/.emacs-places 15 | .emacs.d/tramp 16 | .zsh/cache 17 | *.elc 18 | .emacs.d/history 19 | .emacs.d/site/nxhtml 20 | .virtualenvs 21 | .ipython 22 | *.stackdump 23 | node_modules/ 24 | Thumbs.db 25 | desktop.ini 26 | *.sublime-project 27 | coverage/ 28 | -------------------------------------------------------------------------------- /test/lcm.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | 3 | var lcm = require('../lib/lcm').lcm; 4 | 5 | 6 | describe('lcm', function() { 7 | 8 | [ 9 | {ints: [1, 2], result: 2}, 10 | {ints: [2, 3, 5], result: 30}, 11 | {ints: [2, 3, 5, 6], result: 30}, 12 | {ints: [1, 2, 4], result: 4}, 13 | {ints: [1, 2, 3], result: 6}, 14 | ].forEach(function(params) { 15 | it('should find lowest common multiple for ' + JSON.stringify(params), function() { 16 | var out = lcm(params.ints); 17 | assert.equal(params.result, out); 18 | }); 19 | }); 20 | 21 | }); 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shalam", 3 | "version": "1.0.8", 4 | "description": "A tool for friendly CSS spriting", 5 | "bin": { 6 | "shalam": "./bin/shalam" 7 | }, 8 | "scripts": { 9 | "test": "mocha" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git@github.com:box/shalam.git" 14 | }, 15 | "keywords": [ 16 | "css", 17 | "spriting", 18 | "retina" 19 | ], 20 | "author": "Matt Basta ", 21 | "license": "Apache-2.0", 22 | "dependencies": { 23 | "canvas": "1.1.6", 24 | "crass": "^0.7.6", 25 | "minimist": "^1.1.0", 26 | "semver": "^5.0.1" 27 | }, 28 | "devDependencies": { 29 | "mocha": "^1.21.4", 30 | "mockery": "^1.4.0", 31 | "sinon": "^1.10.3" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /lib/lcm.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Code to find least common multiple 3 | * @author basta 4 | */ 5 | 6 | 'use strict'; 7 | 8 | 9 | //------------------------------------------------------------------------------ 10 | // Public 11 | //------------------------------------------------------------------------------ 12 | 13 | 14 | /** 15 | * Finds least common multiple of a set of numbers 16 | * 17 | * Adapted from resettacode 18 | * 19 | * @param {int[]} arr Array of integers to find the LCM of 20 | * @returns {int} 21 | */ 22 | exports.lcm = function lcm(arr) { 23 | var n = arr.length; 24 | var a = Math.abs(arr[0]); 25 | 26 | var b; 27 | var c; 28 | for (var i = 1; i < n; i++) { 29 | b = Math.abs(arr[i]); 30 | c = a; 31 | while (a && b) { 32 | if (a > b) { 33 | a %= b; 34 | } else { 35 | b %= a; 36 | } 37 | } 38 | a = Math.abs(c * arr[i]) / (a + b); 39 | } 40 | return a; 41 | }; 42 | -------------------------------------------------------------------------------- /lib/image.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Simple way of reading and decoding images from disk 3 | * @author basta 4 | */ 5 | 6 | 'use strict'; 7 | 8 | 9 | //------------------------------------------------------------------------------ 10 | // Public 11 | //------------------------------------------------------------------------------ 12 | 13 | /** 14 | * Fetchs an Image object from a path on the disk. 15 | * @param {string} imgPath The path to the image to fetch. 16 | * @returns {Image} The fetched image. 17 | */ 18 | exports.fetch = function fetch(imgPath) { 19 | // Require calls are inline to facilitate mocking. 20 | var fs = require('fs'); 21 | var path = require('path'); 22 | 23 | var Image = require('canvas').Image; 24 | var img = new Image(); 25 | 26 | img.src = fs.readFileSync(path.resolve(process.cwd(), imgPath)); 27 | return img; 28 | }; 29 | 30 | /** 31 | * Returns a hash for an image based on its size 32 | * @param {number} height 33 | * @param {number} width 34 | * @return {string} 35 | */ 36 | exports.getSizeHash = function getSizeHash(height, width) { 37 | return height + 'x' + width; 38 | }; 39 | -------------------------------------------------------------------------------- /test/walker.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | 3 | var crass = require('crass'); 4 | 5 | var walker = require('../lib/walker'); 6 | 7 | 8 | describe('Walker', function() { 9 | 10 | describe('walk()', function() { 11 | 12 | function countHits(stylesheet, expectedCount) { 13 | var hits = 0; 14 | walker.walk(crass.parse(stylesheet), function() { 15 | hits += 1; 16 | }); 17 | assert.equal(hits, expectedCount); 18 | } 19 | 20 | it('should find rulesets in the root of a stylesheet', function() { 21 | countHits('x {foo: bar} y {zip: zap}', 2); 22 | }); 23 | 24 | it('should find rulesets in media queries', function() { 25 | countHits('@media (min-width: 960px) {x {foo: bar} y {zip: zap}}', 2); 26 | }); 27 | 28 | it('should find rulesets in support blocks', function() { 29 | countHits('@supports (transform: rotateX(10deg)) {x {foo: bar} y {zip: zap}}', 2); 30 | }); 31 | 32 | it('should find page margins', function() { 33 | countHits('@page :first {@top-right {foo: bar}}', 1); 34 | }); 35 | 36 | it('should find keyframes', function() { 37 | countHits('@keyframes foo {to{foo: bar} from{zip:zap}}', 2); 38 | }); 39 | 40 | }); 41 | 42 | }); 43 | -------------------------------------------------------------------------------- /test/image.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | 3 | var mockery = require('mockery'); 4 | var sinon = require('sinon'); 5 | 6 | 7 | describe('Image', function() { 8 | var image; 9 | var sandbox; 10 | 11 | before(function() { 12 | mockery.enable({ 13 | warnOnUnregistered: false, 14 | }); 15 | }); 16 | 17 | beforeEach(function() { 18 | sandbox = sinon.sandbox.create(); 19 | }); 20 | 21 | afterEach(function() { 22 | mockery.resetCache(); 23 | 24 | sandbox.verifyAndRestore(); 25 | }); 26 | 27 | after(function() { 28 | mockery.deregisterAll(); 29 | mockery.disable(); 30 | }); 31 | 32 | describe('fetch()', function() { 33 | 34 | it('should fetch an image at the given path', function() { 35 | var mockImg = { 36 | height: 123, 37 | width: 456 38 | }; 39 | mockery.registerMock('canvas', { 40 | Image: sandbox.mock().once().returns(mockImg), 41 | }); 42 | 43 | mockery.registerMock('fs', { 44 | readFileSync: sandbox.mock().once().withArgs('resolved').returns('read'), 45 | }); 46 | 47 | mockery.registerMock('path', { 48 | resolve: sandbox.mock().once().withArgs(process.cwd(), 'path').returns('resolved'), 49 | }); 50 | 51 | 52 | image = require('../lib/image'); 53 | 54 | var output = image.fetch('path'); 55 | 56 | assert.equal(mockImg.src, 'read'); 57 | 58 | assert.equal(output.height, 123); 59 | assert.equal(output.width, 456); 60 | }); 61 | 62 | }); 63 | 64 | }); 65 | -------------------------------------------------------------------------------- /lib/walker.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Helper function for walking a stylesheet from Crass 3 | * @author basta 4 | */ 5 | 6 | 'use strict'; 7 | 8 | //------------------------------------------------------------------------------ 9 | // Requirements 10 | //------------------------------------------------------------------------------ 11 | 12 | var crassObjects = require('crass').objects; 13 | 14 | 15 | //------------------------------------------------------------------------------ 16 | // Public 17 | //------------------------------------------------------------------------------ 18 | 19 | /** 20 | * Walks a CSS parse tree and calls a callback when any ruleset-like 21 | * node is encountered. 22 | * @param {Stylesheet} stylesheet A CSS stylesheet object to traverse 23 | * @param {function} cb A callback to fire when a ruleset is encountered. 24 | * @returns {void} 25 | */ 26 | exports.walk = function walk(stylesheet, cb) { 27 | 28 | function walkObject(obj) { 29 | if (obj instanceof crassObjects.Media) { 30 | obj.content.forEach(walkObject); 31 | } else if (obj instanceof crassObjects.Page) { 32 | obj.content.forEach(walkObject); 33 | } else if (obj instanceof crassObjects.FontFace) { 34 | obj.content.forEach(walkObject); 35 | } else if (obj instanceof crassObjects.Keyframes) { 36 | obj.content.forEach(walkObject); 37 | } else if (obj instanceof crassObjects.Supports) { 38 | obj.blocks.forEach(walkObject); 39 | } else if (obj instanceof crassObjects.Ruleset || 40 | obj instanceof crassObjects.PageMargin || 41 | obj instanceof crassObjects.Keyframe) { 42 | cb(obj); 43 | } 44 | } 45 | 46 | stylesheet.content.forEach(walkObject); 47 | 48 | }; 49 | -------------------------------------------------------------------------------- /lib/composite.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Method for compositing layouts into an image 3 | * @author basta 4 | */ 5 | 6 | 'use strict'; 7 | 8 | 9 | //------------------------------------------------------------------------------ 10 | // Requirements 11 | //------------------------------------------------------------------------------ 12 | 13 | var fs = require('fs'); 14 | 15 | var Canvas = require('canvas'); 16 | 17 | var utils = require('./utils'); 18 | 19 | 20 | //------------------------------------------------------------------------------ 21 | // Public 22 | //------------------------------------------------------------------------------ 23 | 24 | /** 25 | * Composites a layout into a sprite image. 26 | * @param {Layout} layout The layout object to composite. 27 | * @param {string} destination The final location of the sprite file. 28 | * @param {function} cb A callback to execute on completion or error. 29 | * @returns void 30 | */ 31 | exports.composite = function composite(layout, destination, cb) { 32 | // Create a new canvas object 33 | var canvas = new Canvas(layout.width, layout.height); 34 | var ctx = canvas.getContext('2d'); 35 | 36 | // Iterate each image and draw it at the appropraite location. 37 | layout.images.forEach(function(img) { 38 | ctx.drawImage( 39 | img.imageResource, 40 | // Native image dimensions at origin 41 | 0, 0, img.imageResource.width, img.imageResource.height, 42 | // Sprited image dimensions and location 43 | img.x, img.y, img.width, img.height 44 | ); 45 | 46 | if (utils.isDebug()) { 47 | console.log('Compositing "' + img.path + '":'); 48 | console.log(' orig => ' + img.imageResource.width + 'x' + img.imageResource.height); 49 | console.log(' dest => ' + img.width + 'x' + img.height + ' @ ' + img.x + ',' + img.y); 50 | } 51 | }); 52 | 53 | // Write the composited file to disk. 54 | var out = fs.createWriteStream(destination); 55 | var stream = canvas.pngStream(); 56 | stream.on('data', function(chunk) { 57 | out.write(chunk); 58 | }); 59 | 60 | stream.on('end', cb); 61 | }; 62 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Generic utilities 3 | * @author basta 4 | */ 5 | 6 | 'use strict'; 7 | 8 | //------------------------------------------------------------------------------ 9 | // Requirements 10 | //------------------------------------------------------------------------------ 11 | 12 | var fs = require('fs'); 13 | var path = require('path'); 14 | 15 | var minimist = require('minimist'); 16 | 17 | var args = minimist(process.argv.slice(2)); 18 | 19 | 20 | //------------------------------------------------------------------------------ 21 | // Public 22 | //------------------------------------------------------------------------------ 23 | 24 | /** 25 | * Walks a directory and fires a callback on each file with a matching extension. 26 | * @param {string} path_ The starting path to traverse 27 | * @param {string} ext The file extension to search for (or `*`) 28 | * @param {function} callback A callback fired on each match 29 | * @param {function} doneCallback A callback to fire when traversal is complete 30 | * @returns {void} 31 | */ 32 | exports.globEach = function globEach(path_, ext, callback, doneCallback) { 33 | var wildcard = ext === '*'; 34 | if (!doneCallback) { 35 | doneCallback = function() {}; 36 | } 37 | 38 | fs.readdir(path_, function(err, list) { 39 | if (err) return doneCallback(err); 40 | var pending = list.length; 41 | if (!pending) return doneCallback(null); 42 | list.forEach(function(file) { 43 | file = path.resolve(path_, file); 44 | fs.stat(file, function(err, stat) { 45 | if (stat && stat.isDirectory()) { 46 | exports.globEach(file, ext, callback, function(err) { 47 | if (!--pending) doneCallback(err); 48 | }); 49 | } else { 50 | // If it's got the right extension, add it to the list. 51 | if(wildcard || file.substr(file.length - ext.length) === ext) 52 | callback(path.normalize(file)); 53 | if (!--pending) doneCallback(null); 54 | } 55 | }); 56 | }); 57 | }); 58 | 59 | }; 60 | 61 | 62 | /** 63 | * Returns whether Shalam is being run in Debug mode 64 | * @return {bool} 65 | */ 66 | exports.isDebug = function isDebug() { 67 | return 'debug' in args; 68 | }; 69 | -------------------------------------------------------------------------------- /lib/git.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Abstraction on top of Git 3 | * @author basta 4 | */ 5 | 6 | 'use strict'; 7 | 8 | //------------------------------------------------------------------------------ 9 | // Requirements 10 | //------------------------------------------------------------------------------ 11 | 12 | var child_process = require('child_process'); 13 | var fs = require('fs'); 14 | var os = require('os'); 15 | var path = require('path'); 16 | 17 | 18 | //------------------------------------------------------------------------------ 19 | // Public 20 | //------------------------------------------------------------------------------ 21 | 22 | /** 23 | * Returns whether the passed URI is probably for a Git repository. 24 | * @param {string} uri The URI to test. 25 | * @returns {bool} 26 | */ 27 | exports.isGitURI = function isGitURI(uri) { 28 | return !!/^\w+@.+:.*/.exec(uri) || 29 | !!/ssh:\/\/.*/.exec(uri) || 30 | !!/https:\/\/.*\.git/.exec(uri); 31 | }; 32 | 33 | var gitURICache = {}; 34 | /** 35 | * Clones a Git URI to a temporary directory. Calls the callback 36 | * function with the temporary directory's path if the clone is 37 | * successful. 38 | * @param {string} gitURI The URI of the Git repo. 39 | * @param {function} cb The callback to fire on completion or error. 40 | * @returns {void} 41 | */ 42 | exports.cloneGitURI = function cloneGitURI(gitURI, cb) { 43 | // If the URI has already been cloned, return the path. 44 | if (gitURICache[gitURI]) { 45 | cb(null, gitURICache[gitURI]); 46 | return; 47 | } 48 | 49 | // Get a temporary directory name. 50 | var tmpDir = os.tmpdir(); 51 | var uniqName = 'shalam' + (Math.random() * 1000000 | 0); 52 | var newTmpDir = path.resolve(tmpDir, uniqName); 53 | 54 | // Create the temporary directory and add it to the cache. 55 | fs.mkdirSync(newTmpDir); 56 | gitURICache[gitURI] = newTmpDir; 57 | 58 | // Spawn a `git clone` process and perform the clone operation. 59 | var spawned = child_process.spawn('git', ['clone', gitURI, newTmpDir]); 60 | 61 | spawned.on('close', function(code) { 62 | // Handle non-zero exit codes from `git clone` 63 | if (code !== 0) { 64 | cb('Git returned non-zero exit code'); 65 | return; 66 | } 67 | // Fire the success callback with the temporary directory path. 68 | cb(null, newTmpDir); 69 | }); 70 | 71 | }; 72 | -------------------------------------------------------------------------------- /test/composite.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | 3 | var mockery = require('mockery'); 4 | var sinon = require('sinon'); 5 | 6 | 7 | describe('Compositor', function() { 8 | var composite; 9 | var sandbox; 10 | 11 | before(function() { 12 | mockery.enable({ 13 | warnOnUnregistered: false 14 | }); 15 | }); 16 | 17 | beforeEach(function() { 18 | sandbox = sinon.sandbox.create(); 19 | }); 20 | 21 | afterEach(function() { 22 | mockery.resetCache(); 23 | 24 | sandbox.verifyAndRestore(); 25 | }); 26 | 27 | after(function() { 28 | mockery.deregisterAll(); 29 | mockery.disable(); 30 | }); 31 | 32 | describe('composite()', function() { 33 | 34 | it('should composite to a new canvas with the given data', function() { 35 | 36 | var baseImage = { 37 | width: 123, 38 | height: 456, 39 | }; 40 | 41 | mockery.registerMock('canvas', function(width, height) { 42 | assert.equal(width, 123); 43 | assert.equal(height, 456); 44 | 45 | return { 46 | getContext: sandbox.mock().once().withArgs('2d').returns({ 47 | drawImage: sandbox.mock().withArgs( 48 | baseImage, 49 | 0, 0, 50 | 123, 456, 51 | 12, 34, 52 | 120, 340 53 | ), 54 | }), 55 | pngStream: sandbox.mock().once().returns({ 56 | on: sandbox.mock().twice() 57 | }) 58 | }; 59 | }); 60 | 61 | mockery.registerMock('fs', { 62 | createWriteStream: sandbox.mock().once().withArgs('destination') 63 | }); 64 | 65 | 66 | composite = require('../lib/composite'); 67 | 68 | composite.composite( 69 | { 70 | width: 123, 71 | height: 456, 72 | images: [{ 73 | imageResource: baseImage, 74 | x: 12, 75 | y: 34, 76 | width: 120, 77 | height: 340, 78 | }] 79 | }, 80 | 'destination', 81 | sandbox.mock().never() 82 | ); 83 | }); 84 | 85 | }); 86 | 87 | }); 88 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | All contributions are welcome to this project. 4 | 5 | ## Contributor License Agreement 6 | 7 | Before a contribution can be merged into this project, please fill out the Contributor License Agreement (CLA) located at: 8 | 9 | http://opensource.box.com/cla 10 | 11 | To learn more about CLAs and why they are important to open source projects, please see the [Wikipedia entry](http://en.wikipedia.org/wiki/Contributor_License_Agreement). 12 | 13 | ## How to contribute 14 | 15 | * **File an issue** - if you found a bug, want to request an enhancement, or want to implement something (bug fix or feature). 16 | * **Send a pull request** - if you want to contribute code. Please be sure to file an issue first. 17 | 18 | ## Pull request best practices 19 | 20 | We want to accept your pull requests. Please follow these steps: 21 | 22 | ### Step 1: File an issue 23 | 24 | Before writing any code, please file an issue stating the problem you want to solve or the feature you want to implement. This allows us to give you feedback before you spend any time writing code. There may be a known limitation that can't be addressed, or a bug that has already been fixed in a different way. The issue allows us to communicate and figure out if it's worth your time to write a bunch of code for the project. 25 | 26 | ### Step 2: Fork this repository in GitHub 27 | 28 | This will create your own copy of our repository. 29 | 30 | ### Step 3: Add the upstream source 31 | 32 | The upstream source is the project under the Box organization on GitHub. To add an upstream source for this project, type: 33 | 34 | ``` 35 | git remote add upstream git@github.com:box/brainy.git 36 | ``` 37 | 38 | This will come in useful later. 39 | 40 | ### Step 4: Create a feature branch 41 | 42 | Create a branch with a descriptive name, such as `add-search`. 43 | 44 | ### Step 5: Push your feature branch to your fork 45 | 46 | As you develop code, continue to push code to your remote feature branch. Please make sure to include the issue number you're addressing in your commit message, such as: 47 | 48 | ``` 49 | git commit -m "Adding search (fixes #123)" 50 | ``` 51 | 52 | This helps us out by allowing us to track which issue your commit relates to. 53 | 54 | Keep a separate feature branch for each issue you want to address. 55 | 56 | ### Step 6: Rebase 57 | 58 | Before sending a pull request, rebase against upstream, such as: 59 | 60 | ``` 61 | git fetch upstream 62 | git rebase upstream/master 63 | ``` 64 | 65 | This will add your changes on top of what's already in upstream, minimizing merge issues. 66 | 67 | ### Step 7: Run the tests 68 | 69 | Make sure that all tests are passing before submitting a pull request. 70 | 71 | ### Step 8: Send the pull request 72 | 73 | Send the pull request from your feature branch to us. Be sure to include a description that lets us know what work you did. 74 | 75 | Keep in mind that we like to see one issue addressed per pull request, as this helps keep our git history clean and we can more easily track down issues. 76 | -------------------------------------------------------------------------------- /test/git.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | 3 | var mockery = require('mockery'); 4 | var sinon = require('sinon'); 5 | 6 | 7 | describe('isGitURI()', function() { 8 | 9 | var gitLib = require('../lib/git'); 10 | 11 | it('should accept common SSH protocol URIs', function() { 12 | assert.ok(gitLib.isGitURI('ssh://user@server/project.git')); 13 | assert.ok(gitLib.isGitURI('ssh://git@github.com/mattbasta/foobar.git')); 14 | }); 15 | 16 | it('should accept HTTPS protocol URIs', function() { 17 | assert.ok(gitLib.isGitURI('https://github.com/mattbasta/shalam.git')); 18 | }); 19 | 20 | it('should accept git protocol URIs', function() { 21 | assert.ok(gitLib.isGitURI('user@server:project.git')); 22 | assert.ok(gitLib.isGitURI('git@github.com:mattbasta/test.git')); 23 | assert.ok(gitLib.isGitURI('git@github-other.com:mattbasta/test.git')); 24 | }); 25 | 26 | it('should ignore non-git URIs', function() { 27 | assert.ok(!gitLib.isGitURI('ftp://ftp.box.com/foo.bar')); 28 | assert.ok(!gitLib.isGitURI('http://mattbasta.com/')); 29 | assert.ok(!gitLib.isGitURI('/tmp/foo/bar/temp.txt')); 30 | assert.ok(!gitLib.isGitURI('../path/to/file')); 31 | assert.ok(!gitLib.isGitURI('path/to/file')); 32 | }); 33 | 34 | }); 35 | 36 | 37 | describe('cloneGitURI()', function() { 38 | var gitLib; 39 | var sandbox; 40 | 41 | before(function() { 42 | mockery.enable({ 43 | warnOnUnregistered: false, 44 | useCleanCache: true, 45 | }); 46 | }); 47 | 48 | beforeEach(function() { 49 | sandbox = sinon.sandbox.create(); 50 | 51 | mockery.registerMock('fs', { 52 | mkdirSync: sandbox.mock().once().withArgs('resolved path'), 53 | }); 54 | 55 | mockery.registerMock('os', { 56 | tmpdir: sandbox.mock().once().returns('/tmp'), 57 | }); 58 | 59 | mockery.registerMock('path', { 60 | resolve: sandbox.mock().once().withArgs('/tmp').returns('resolved path'), 61 | }); 62 | 63 | }); 64 | 65 | afterEach(function() { 66 | mockery.resetCache(); 67 | 68 | sandbox.verifyAndRestore(); 69 | 70 | mockery.deregisterAll(); 71 | }); 72 | 73 | after(function() { 74 | mockery.disable(); 75 | }); 76 | 77 | it('should set up clone URLs properly', function() { 78 | var cloneObj = { 79 | on: function() {}, 80 | }; 81 | 82 | mockery.registerMock('child_process', { 83 | spawn: sandbox.mock().once().withArgs('git', ['clone', 'git uri', 'resolved path']).returns(cloneObj), 84 | }); 85 | 86 | 87 | gitLib = require('../lib/git'); 88 | gitLib.cloneGitURI('git uri', function() {}); 89 | }); 90 | 91 | it('should call the completion callback appropriately', function() { 92 | var registeredCallbacks = {}; 93 | var cloneObj = { 94 | on: function(name, cb) { 95 | registeredCallbacks[name] = cb; 96 | }, 97 | }; 98 | 99 | mockery.registerMock('child_process', { 100 | spawn: sandbox.mock().once().withArgs('git', ['clone', 'git uri', 'resolved path']).returns(cloneObj), 101 | }); 102 | 103 | gitLib = require('../lib/git'); 104 | gitLib.cloneGitURI('git uri', sandbox.mock().once().withArgs(null, 'resolved path')); 105 | 106 | registeredCallbacks.close(0); 107 | }); 108 | 109 | it('should fail on error', function() { 110 | var registeredCallbacks = {}; 111 | var cloneObj = { 112 | on: function(name, cb) { 113 | registeredCallbacks[name] = cb; 114 | }, 115 | }; 116 | 117 | mockery.registerMock('child_process', { 118 | spawn: sandbox.mock().once().withArgs('git', ['clone', 'git uri', 'resolved path']).returns(cloneObj), 119 | }); 120 | 121 | gitLib = require('../lib/git'); 122 | gitLib.cloneGitURI('git uri', sandbox.mock().once().withArgs('Git returned non-zero exit code')); 123 | 124 | registeredCallbacks.close(1); 125 | }); 126 | 127 | }); 128 | -------------------------------------------------------------------------------- /lib/main.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Starting point for Shalam execution 3 | * @author basta 4 | */ 5 | 6 | 'use strict'; 7 | 8 | //------------------------------------------------------------------------------ 9 | // Requirements 10 | //------------------------------------------------------------------------------ 11 | 12 | var path = require('path'); 13 | 14 | var composite = require('./composite'); 15 | var layout = require('./layout'); 16 | var Processor = require('./processor'); 17 | var utils = require('./utils'); 18 | 19 | 20 | //------------------------------------------------------------------------------ 21 | // Public 22 | //------------------------------------------------------------------------------ 23 | 24 | /** 25 | * Runs Shalam against a CSS, image, and sprite path and calls a callback upon completion. 26 | * @param {string} cssDirectory The path to the CSS sources 27 | * @param {string} imageSource The path to the image sources 28 | * @param {string} spriteDestination The location that sprite images should be generated at 29 | * @param {function} [callback] 30 | * @returns {void} 31 | */ 32 | exports.run = function run(cssDirectory, imageSource, spriteDestination, callback) { 33 | var cwd = process.cwd(); 34 | // Normalize all of the paths 35 | cssDirectory = path.resolve(cwd, cssDirectory); 36 | imageSource = path.resolve(cwd, imageSource); 37 | spriteDestination = path.resolve(cwd, spriteDestination); 38 | 39 | var activeProcs = []; 40 | var imageDirectory; 41 | var spriteLayout; 42 | var spriteLayoutCompat; 43 | 44 | var successes = 0; 45 | var failures = 0; 46 | 47 | utils.globEach( 48 | // Iterate each file in the CSS directory 49 | cssDirectory, 50 | // Select only CSS files 51 | '.css', 52 | // This will run on each file encountered 53 | function processIndividualCSSFile(file) { 54 | var proc; 55 | // Try to parse each CSS file 56 | try { 57 | proc = new Processor(file); 58 | } catch (e) { 59 | // If it failed, print the error and continue. 60 | console.error('Could not parse CSS file: ' + file + '\n' + e.toString()); 61 | failures++; 62 | return; 63 | } 64 | 65 | if (proc.hasMatches()) { 66 | activeProcs.push(proc); 67 | successes++; 68 | } 69 | }, 70 | layoutAndComposite 71 | ); 72 | 73 | 74 | var activeCompositeOperations = 0; 75 | 76 | /** 77 | * Runs the layout engine and compositor against an array of processed CSS files. 78 | */ 79 | function layoutAndComposite() { 80 | // Bail early if no CSS files were processed. 81 | if (failures && !successes) { 82 | return; 83 | } 84 | 85 | // Get the directory of images that need to be created 86 | imageDirectory = layout.getImageDirectory(activeProcs, imageSource); 87 | 88 | // Create the layout and compat layout 89 | spriteLayout = layout.performLayout(imageDirectory); 90 | spriteLayoutCompat = layout.performLayoutCompat(imageDirectory); 91 | 92 | // Composite the layouts into sprite files 93 | activeCompositeOperations = 2; 94 | composite.composite(spriteLayout, spriteDestination + '.png', writeStylesheets); 95 | composite.composite(spriteLayoutCompat, spriteDestination + '.compat.png', writeStylesheets); 96 | } 97 | 98 | /** 99 | * Rewrites the files which include images that were included in the sprite. 100 | */ 101 | function writeStylesheets(err) { 102 | activeCompositeOperations--; 103 | // If there was an error, print it and exit. 104 | if (err) { 105 | console.error(err); 106 | return; 107 | } 108 | // If there are still active composite operations, return and wait until they finish. 109 | if (activeCompositeOperations) { 110 | return; 111 | } 112 | 113 | // Output any warnings that were generated in the previous step. 114 | imageDirectory.forEach(function(img) { 115 | if (!img.warnings.length) return; 116 | img.warnings.forEach(function(warning) { 117 | console.warn(warning); 118 | }); 119 | }); 120 | 121 | // Perform the CSS file rewrites. 122 | activeProcs.forEach(function(proc) { 123 | proc.applyLayout(spriteLayout, spriteLayoutCompat, spriteDestination); 124 | }); 125 | 126 | // If there's a callback, call it. 127 | if (callback) callback(); 128 | } 129 | 130 | }; 131 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Shalam - Deprecated 2 | 3 | [![Project Status](http://opensource.box.com/badges/eol.svg)](http://opensource.box.com/badges) 4 | 5 | Shalam has been EOL'd and is no longer being maintained. It is kept here for reference only and will not be updated. 6 | 7 | We have modernized our stack to use inline SVGs instead of sprited images. 8 | 9 | ## About 10 | 11 | A friendly tool for CSS spriting. Shalam allows you to add Retina-friendly, 12 | high-quality image sprites to your website without modifying any markup. 13 | 14 | 15 | ## Installation 16 | 17 | ### Mac OS X 18 | 19 | First, you'll need to make sure that your system is ready. If you're running 20 | OS X, you'll need Cairo installed. Cairo depends on XQuartz. You'll want to 21 | download and install XQuartz from here: 22 | 23 | https://xquartz.macosforge.org/landing/ 24 | 25 | Then install Cairo and pkgconfig with [Homebrew](http://brew.sh): 26 | 27 | ```bash 28 | brew install cairo pkgconfig 29 | npm install -g shalam 30 | ``` 31 | 32 | If you get an error about `xcb-shm`, try running the following before running 33 | `npm install`: 34 | 35 | ```bash 36 | export PKG_CONFIG_PATH=/opt/X11/lib/pkgconfig 37 | ``` 38 | 39 | ### Linux 40 | 41 | If you'd like to use Shalam on Linux, you can run a similar set of commands: 42 | 43 | ```bash 44 | yum install cairo cairo-devel cairomm-devel libjpeg-turbo-devel pango pango-devel pangomm pangomm-devel giflib-devel 45 | 46 | npm install -g shalam 47 | ``` 48 | 49 | ### Windows 50 | 51 | Windows support is currently not available. You may find success, however, after [installing node-canvas manually](https://github.com/Automattic/node-canvas/wiki/Installation---Windows). Patches to add or improve Windows support are welcome. 52 | 53 | 54 | ## Adding Sprites 55 | 56 | Simply use the `-shalam-sprite` declaration in your CSS files: 57 | 58 | ```css 59 | .my-great-class { 60 | font-size: 1.5em; 61 | font-weight: bold; 62 | 63 | -shalam-sprite: "files-page/chevron.png" dest-size(32px 32px); 64 | } 65 | ``` 66 | 67 | The syntax for `-shalam-sprite` is simple: a string containing a path to an image asset (discussed below) is required. Following the string is an optional `dest-size()` modifier. `dest-size()` allows you to specify the height and width of the image as you would like it to appear on the page. This is usually the `width` and `height` of the element you are applying `-shalam-sprite` to. Only two positive non-zero numbers that use `px` as the unit are accepted. 68 | 69 | 70 | When shalam is run, you'll get this code: 71 | 72 | ```css 73 | .my-great-class { 74 | font-size: 1.5em; 75 | font-weight: bold; 76 | 77 | -shalam-sprite: "files-page/chevron.png" dest-size(32px 32px); 78 | /* shalam! */; 79 | background: url(../img/sprites/files-page.png) -20px -74px; 80 | background: url(../img/sprites/files-page.compat.png) -50px -24px/125px 32px; 81 | /* end shalam */ 82 | } 83 | ``` 84 | 85 | The `background` declarations with all the fixins are generated for you! This means that generating sprites with retina assets (even if your assets are not all retina) is a breeze. 86 | 87 | 88 | ## Running 89 | 90 | ```bash 91 | shalam /path/to/source/css /path/to/source/images /path/to/final/output/image 92 | ``` 93 | 94 | - The first argument is the path to a directory containing the CSS files to process. 95 | - The second argument is the path to a directory containing the source images that will be used in the sprited image. This doesn't need to be a part of your application's main repository (since the source images will not be included in your assets). For example, you might keep your source images in `/Users/matt/projects/site-assets/`. 96 | - The third argument is the path to the location you expect the final sprited image to go. That is, if you expect to create `static/img/sprite.png`, you will specify `static/img/sprite`. The file extension is omitted intentionally to support a compatibility sprite (`static/img/sprite.compat.png`, for IE8 support). 97 | 98 | For example, you might run: 99 | 100 | ``` 101 | shalam static/css static/img static/img/sprite 102 | ``` 103 | 104 | The URL paths to the sprited images that will be inserted into the CSS will be 105 | relative to their paths on the disk. In the above example, the following code: 106 | 107 | ```css 108 | /* ~/myapp/static/css/main.css */ 109 | .foo { 110 | -shalam-sprite: "image.png"; 111 | } 112 | ``` 113 | 114 | run with the following command: 115 | 116 | ```bash 117 | shalam static/css static/img static/img/sprite 118 | ``` 119 | 120 | will yield 121 | 122 | ```css 123 | /* ~/myapp/static/css/main.css */ 124 | .foo { 125 | -shalam-sprite: "image.png"; 126 | /* shalam! */; 127 | background: url(../img/sprite.png) 0 0; 128 | background: url(../img/sprite.compat.png) 0 0/40px 40px; 129 | /* end shalam */ 130 | } 131 | ``` 132 | 133 | Notice that the URLs used in the template are relative from the final path of the sprited image to the location of the CSS file. In `static/css/main.css`, `../img/sprite.png` would yield `static/img/sprite.png`, which is what we passed for the third argument. 134 | 135 | 136 | ## Advanced Features 137 | 138 | More advanced features are documented on the wiki: 139 | 140 | - [Support for pulling source images directly from Git](../../wiki/Git-Support) 141 | - [Ability to define sprite "packages" for easy sprite re-use](../../wiki/Package-Support) 142 | 143 | 144 | ## Support 145 | 146 | Need to contact us directly? Email oss@box.com and be sure to include the name of this project in the subject. 147 | 148 | 149 | ## Hacking on Shalam 150 | 151 | If you're interested in contributing, please read our wiki page about [hacking on Shalam](https://github.com/box/shalam/wiki/Hacking-on-Shalam) to get started. 152 | 153 | 154 | ## Copyright and License 155 | 156 | Copyright 2015 Box, Inc. All rights reserved. 157 | 158 | Licensed under the Apache License, Version 2.0 (the "License"); 159 | you may not use this file except in compliance with the License. 160 | You may obtain a copy of the License at 161 | 162 | http://www.apache.org/licenses/LICENSE-2.0 163 | 164 | Unless required by applicable law or agreed to in writing, software 165 | distributed under the License is distributed on an "AS IS" BASIS, 166 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 167 | See the License for the specific language governing permissions and 168 | limitations under the License. 169 | -------------------------------------------------------------------------------- /bin/shalam: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var fs = require('fs'); 4 | var path = require('path'); 5 | var sys = require('sys') 6 | var exec = require('child_process').exec; 7 | 8 | var argv = require('minimist')(process.argv.slice(2)); 9 | var semver = require('semver'); 10 | 11 | var main = require('../lib/main'); 12 | var git = require('../lib/git'); 13 | 14 | /** 15 | * Prints the CLI options. 16 | * @returns {void} 17 | */ 18 | function help() { 19 | console.log('Shalam Usage:'); 20 | console.log(' shalam path/to/css path/to/images destination/path/for/sprite.png'); 21 | } 22 | 23 | /** 24 | * Returns the current version of Shalam 25 | * @return {string} Current version of Shalam as a string 26 | */ 27 | function getCurrentVersion(cb) { 28 | exec('npm info shalam version', function(err, stdout, stderr) { 29 | var error = err || stderr; 30 | if (error) { 31 | console.error('Encountered error detecting Shalam version:'); 32 | console.error(error); 33 | process.exit(1); 34 | return; 35 | } else { 36 | cb(stdout); 37 | } 38 | }); 39 | } 40 | 41 | 42 | /** 43 | * Parses the instructions found within the provided package.json file. 44 | * @param {string} packageJSONPath Path to the JSON file to parse. 45 | * @param {string} instructionName Name of the instruction set to be run. 46 | * @returns {void} 47 | */ 48 | function runPackageJSON(packageJSONPath, instructionName) { 49 | console.log('Found package.json: ' + packageJSONPath); 50 | var contents = fs.readFileSync(packageJSONPath).toString(); 51 | var contentsParsed = JSON.parse(contents); 52 | 53 | if (!('shalam' in contentsParsed) || !contentsParsed.shalam.length) { 54 | console.warn('No shalam instructions found in package.json!'); 55 | return; 56 | } 57 | 58 | if ('shalamVersion' in contentsParsed) { 59 | var shalamVersion = contentsParsed.shalamVersion; 60 | getCurrentVersion(function(currentVersion) { 61 | if (!semver.satisfies(currentVersion, shalamVersion)) { 62 | console.error('The package.json file specifies a Shalam version that your installation does not meet.'); 63 | console.error(currentVersion + ' does not satisfy ' + shalamVersion); 64 | process.exit(1); 65 | return; 66 | } 67 | 68 | processJSONRules(contentsParsed.shalam, path.dirname(packageJSONPath), instructionName); 69 | }); 70 | } 71 | } 72 | 73 | /** 74 | * Parses the instructions found within the provided shalam.json file. 75 | * @param {string} shalamJSONPath Path to the JSON file to parse. 76 | * @param {string} instructionName Name of the instruction set to be run. 77 | * @returns {void} 78 | */ 79 | function runShalamJSON(shalamJSONPath, instructionName) { 80 | console.log('Found shalam.json: ' + shalamJSONPath); 81 | var contents = fs.readFileSync(shalamJSONPath).toString(); 82 | processJSONRules(JSON.parse(contents), path.dirname(shalamJSONPath), instructionName); 83 | } 84 | 85 | /** 86 | * Executes the passed instructions from a JSON file. 87 | * @param {object[]} instructions Array of instructions to execute. 88 | * @param {string} dirname Path that the operation should take place in. 89 | * @param {string} instructionName Name of the instruction set to be run. 90 | * @returns {void} 91 | */ 92 | function processJSONRules(instructions, dirname, instructionName) { 93 | var processedInstructionsCounter = 0; 94 | instructions.forEach(function(instruction) { 95 | var cssPath = path.resolve(dirname, instruction.css); 96 | var spritePath = path.resolve(dirname, instruction.sprite); 97 | 98 | if (instructionName && instructionName !== instruction.name) return; 99 | 100 | if (git.isGitURI(instruction.img)) { 101 | console.log('Cloning "' + instruction.img + '"...'); 102 | git.cloneGitURI(instruction.img, function(err, imgPath) { 103 | if (err) { 104 | console.error(err); 105 | return; 106 | } 107 | console.log('Succesfully cloned into ' + imgPath); 108 | setImmediate(main.run.bind(main), cssPath, imgPath, spritePath); 109 | }); 110 | processedInstructionsCounter++; 111 | return; 112 | } 113 | 114 | var imgPath = path.resolve(dirname, instruction.img); 115 | setImmediate(main.run.bind(main), cssPath, imgPath, spritePath); 116 | }); 117 | console.log(processedInstructionsCounter + ' instructions successfully processed'); 118 | } 119 | 120 | // Test if the user typed `--help` 121 | if ('help' in argv) { 122 | help(); 123 | process.exit(0); 124 | } 125 | 126 | /** 127 | * Searches for an applicable JSON file and executes the instructions within 128 | * it. 129 | * @param {string} instructionName Name of the instruction set to be run. 130 | * @returns {void} 131 | */ 132 | function handlePackage(instructionName) { 133 | var basePath = process.cwd(); 134 | var packagePath; 135 | // Iterate up each parent in the current path until a supported 136 | // JSON file is found. 137 | while (basePath !== '/') { 138 | // package.json 139 | packagePath = path.resolve(basePath, 'package.json'); 140 | if (fs.existsSync(packagePath)) { 141 | runPackageJSON(packagePath, instructionName); 142 | return; 143 | } 144 | 145 | // shalam.json 146 | packagePath = path.resolve(basePath, 'shalam.json'); 147 | if (fs.existsSync(packagePath)) { 148 | runShalamJSON(packagePath, instructionName); 149 | return; 150 | } 151 | 152 | basePath = path.resolve(basePath, '../'); 153 | } 154 | 155 | console.error('Could not find package.json'); 156 | process.exit(1); 157 | } 158 | 159 | 160 | if ('package' in argv) { 161 | // Handle `--package` with optional instruction name 162 | var instructionName = argv.package.length > 0 ? argv.package : null; 163 | handlePackage(instructionName); 164 | 165 | } else if (argv._.length !== 3) { 166 | // If we've made it to this point, we're performing the normal 167 | // three-option operation. 168 | help(); 169 | process.exit(1); 170 | 171 | } else { 172 | 173 | if (git.isGitURI(argv._[1])) { 174 | // If the second parameter is a Git URI, clone it so we can run 175 | // against it. 176 | console.log('Cloning "' + argv._[1] + '"...'); 177 | git.cloneGitURI(argv._[1], function(err, imgPath) { 178 | if (err) { 179 | console.error(err); 180 | process.exit(1); 181 | } 182 | console.log('Succesfully cloned into ' + imgPath); 183 | main.run(argv._[0], imgPath, argv._[2]); 184 | }); 185 | } else { 186 | main.run(argv._[0], argv._[1], argv._[2]); 187 | } 188 | 189 | } 190 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | "License" shall mean the terms and conditions for use, reproduction, 9 | and distribution as defined by Sections 1 through 9 of this document. 10 | "Licensor" shall mean the copyright owner or entity authorized by 11 | the copyright owner that is granting the License. 12 | "Legal Entity" shall mean the union of the acting entity and all 13 | other entities that control, are controlled by, or are under common 14 | control with that entity. For the purposes of this definition, 15 | "control" means (i) the power, direct or indirect, to cause the 16 | direction or management of such entity, whether by contract or 17 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 18 | outstanding shares, or (iii) beneficial ownership of such entity. 19 | "You" (or "Your") shall mean an individual or Legal Entity 20 | exercising permissions granted by this License. 21 | "Source" form shall mean the preferred form for making modifications, 22 | including but not limited to software source code, documentation 23 | source, and configuration files. 24 | "Object" form shall mean any form resulting from mechanical 25 | transformation or translation of a Source form, including but 26 | not limited to compiled object code, generated documentation, 27 | and conversions to other media types. 28 | "Work" shall mean the work of authorship, whether in Source or 29 | Object form, made available under the License, as indicated by a 30 | copyright notice that is included in or attached to the work 31 | (an example is provided in the Appendix below). 32 | "Derivative Works" shall mean any work, whether in Source or Object 33 | form, that is based on (or derived from) the Work and for which the 34 | editorial revisions, annotations, elaborations, or other modifications 35 | represent, as a whole, an original work of authorship. For the purposes 36 | of this License, Derivative Works shall not include works that remain 37 | separable from, or merely link (or bind by name) to the interfaces of, 38 | the Work and Derivative Works thereof. 39 | "Contribution" shall mean any work of authorship, including 40 | the original version of the Work and any modifications or additions 41 | to that Work or Derivative Works thereof, that is intentionally 42 | submitted to Licensor for inclusion in the Work by the copyright owner 43 | or by an individual or Legal Entity authorized to submit on behalf of 44 | the copyright owner. For the purposes of this definition, "submitted" 45 | means any form of electronic, verbal, or written communication sent 46 | to the Licensor or its representatives, including but not limited to 47 | communication on electronic mailing lists, source code control systems, 48 | and issue tracking systems that are managed by, or on behalf of, the 49 | Licensor for the purpose of discussing and improving the Work, but 50 | excluding communication that is conspicuously marked or otherwise 51 | designated in writing by the copyright owner as "Not a Contribution." 52 | "Contributor" shall mean Licensor and any individual or Legal Entity 53 | on behalf of whom a Contribution has been received by Licensor and 54 | subsequently incorporated within the Work. 55 | 56 | 2. Grant of Copyright License. Subject to the terms and conditions of 57 | this License, each Contributor hereby grants to You a perpetual, 58 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 59 | copyright license to reproduce, prepare Derivative Works of, 60 | publicly display, publicly perform, sublicense, and distribute the 61 | Work and such Derivative Works in Source or Object form. 62 | 63 | 3. Grant of Patent License. Subject to the terms and conditions of 64 | this License, each Contributor hereby grants to You a perpetual, 65 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 66 | (except as stated in this section) patent license to make, have made, 67 | use, offer to sell, sell, import, and otherwise transfer the Work, 68 | where such license applies only to those patent claims licensable 69 | by such Contributor that are necessarily infringed by their 70 | Contribution(s) alone or by combination of their Contribution(s) 71 | with the Work to which such Contribution(s) was submitted. If You 72 | institute patent litigation against any entity (including a 73 | cross-claim or counterclaim in a lawsuit) alleging that the Work 74 | or a Contribution incorporated within the Work constitutes direct 75 | or contributory patent infringement, then any patent licenses 76 | granted to You under this License for that Work shall terminate 77 | as of the date such litigation is filed. 78 | 79 | 4. Redistribution. You may reproduce and distribute copies of the 80 | Work or Derivative Works thereof in any medium, with or without 81 | modifications, and in Source or Object form, provided that You 82 | meet the following conditions: 83 | 84 | (a) You must give any other recipients of the Work or 85 | Derivative Works a copy of this License; and 86 | 87 | (b) You must cause any modified files to carry prominent notices 88 | stating that You changed the files; and 89 | 90 | (c) You must retain, in the Source form of any Derivative Works 91 | that You distribute, all copyright, patent, trademark, and 92 | attribution notices from the Source form of the Work, 93 | excluding those notices that do not pertain to any part of 94 | the Derivative Works; and 95 | 96 | (d) If the Work includes a "NOTICE" text file as part of its 97 | distribution, then any Derivative Works that You distribute must 98 | include a readable copy of the attribution notices contained 99 | within such NOTICE file, excluding those notices that do not 100 | pertain to any part of the Derivative Works, in at least one 101 | of the following places: within a NOTICE text file distributed 102 | as part of the Derivative Works; within the Source form or 103 | documentation, if provided along with the Derivative Works; or, 104 | within a display generated by the Derivative Works, if and 105 | wherever such third-party notices normally appear. The contents 106 | of the NOTICE file are for informational purposes only and 107 | do not modify the License. You may add Your own attribution 108 | notices within Derivative Works that You distribute, alongside 109 | or as an addendum to the NOTICE text from the Work, provided 110 | that such additional attribution notices cannot be construed 111 | as modifying the License. 112 | 113 | You may add Your own copyright statement to Your modifications and 114 | may provide additional or different license terms and conditions 115 | for use, reproduction, or distribution of Your modifications, or 116 | for any such Derivative Works as a whole, provided Your use, 117 | reproduction, and distribution of the Work otherwise complies with 118 | the conditions stated in this License. 119 | 120 | 5. Submission of Contributions. Unless You explicitly state otherwise, 121 | any Contribution intentionally submitted for inclusion in the Work 122 | by You to the Licensor shall be under the terms and conditions of 123 | this License, without any additional terms or conditions. 124 | Notwithstanding the above, nothing herein shall supersede or modify 125 | the terms of any separate license agreement you may have executed 126 | with Licensor regarding such Contributions. 127 | 128 | 6. Trademarks. This License does not grant permission to use the trade 129 | names, trademarks, service marks, or product names of the Licensor, 130 | except as required for reasonable and customary use in describing the 131 | origin of the Work and reproducing the content of the NOTICE file. 132 | 133 | 7. Disclaimer of Warranty. Unless required by applicable law or 134 | agreed to in writing, Licensor provides the Work (and each 135 | Contributor provides its Contributions) on an "AS IS" BASIS, 136 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 137 | implied, including, without limitation, any warranties or conditions 138 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 139 | PARTICULAR PURPOSE. You are solely responsible for determining the 140 | appropriateness of using or redistributing the Work and assume any 141 | risks associated with Your exercise of permissions under this License. 142 | 143 | 8. Limitation of Liability. In no event and under no legal theory, 144 | whether in tort (including negligence), contract, or otherwise, 145 | unless required by applicable law (such as deliberate and grossly 146 | negligent acts) or agreed to in writing, shall any Contributor be 147 | liable to You for damages, including any direct, indirect, special, 148 | incidental, or consequential damages of any character arising as a 149 | result of this License or out of the use or inability to use the 150 | Work (including but not limited to damages for loss of goodwill, 151 | work stoppage, computer failure or malfunction, or any and all 152 | other commercial damages or losses), even if such Contributor 153 | has been advised of the possibility of such damages. 154 | 155 | 9. Accepting Warranty or Additional Liability. While redistributing 156 | the Work or Derivative Works thereof, You may choose to offer, 157 | and charge a fee for, acceptance of support, warranty, indemnity, 158 | or other liability obligations and/or rights consistent with this 159 | License. However, in accepting such obligations, You may act only 160 | on Your own behalf and on Your sole responsibility, not on behalf 161 | of any other Contributor, and only if You agree to indemnify, 162 | defend, and hold each Contributor harmless for any liability 163 | incurred by, or claims asserted against, such Contributor by reason 164 | of your accepting any such warranty or additional liability. 165 | 166 | END OF TERMS AND CONDITIONS 167 | -------------------------------------------------------------------------------- /lib/layout.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Module responsible for creating sprite layouts 3 | * @author basta 4 | */ 5 | 6 | 'use strict'; 7 | 8 | //------------------------------------------------------------------------------ 9 | // Requirements 10 | //------------------------------------------------------------------------------ 11 | 12 | var path = require('path'); 13 | 14 | var image = require('./image'); 15 | var lcm = require('./lcm').lcm; 16 | 17 | 18 | var MIN_WIDTH = 512; // px 19 | var IMAGE_MARGIN = 2; // px 20 | var IMAGE_SCALE_FACTOR = 2; // times 21 | 22 | 23 | //------------------------------------------------------------------------------ 24 | // Public 25 | //------------------------------------------------------------------------------ 26 | 27 | /** 28 | * Returns an array of images encountered by each CSS processor. 29 | * @param {Processor[]} processors An array of processor objects to extract images from. 30 | * @param {string} imageSource Path to the source of the images to fetch. 31 | * @returns {object[]} Array of extracted images 32 | */ 33 | exports.getImageDirectory = function getImageDirectory(processors, imageSource) { 34 | var images = []; 35 | var seenImages = []; 36 | processors.forEach(function(proc) { 37 | proc.foundSprites.forEach(function(value, i) { 38 | var index = seenImages.indexOf(value); 39 | var size = { 40 | height: proc.rulesets[i].spriteData.destHeight, 41 | width: proc.rulesets[i].spriteData.destWidth, 42 | }; 43 | var sizeHash = image.getSizeHash(size.height, size.width); 44 | if (index === -1) { 45 | var img = { 46 | path: value, 47 | usedSizes: [size], 48 | usedSizesHashes: [sizeHash], 49 | }; 50 | images.push(img); 51 | seenImages.push(value); 52 | } else if (images[index].usedSizesHashes.indexOf(sizeHash) === -1) { 53 | images[index].usedSizes.push(size); 54 | images[index].usedSizesHashes.push(sizeHash); 55 | } 56 | }); 57 | }); 58 | 59 | // Clean up the usedSizesHashes; we don't need that anymore. 60 | // TODO(es6): Change the above code to use a symbol and remove this code. 61 | images.forEach(function(img) { 62 | delete img.usedSizesHashes; 63 | }); 64 | 65 | return images.map(function(img) { 66 | var absPath = path.resolve(imageSource, img.path); 67 | img.absPath = absPath; 68 | img.imageResource = image.fetch(absPath); 69 | img.warnings = []; 70 | 71 | // Replace sizes that reference -1 with their proper values 72 | img.usedSizes = img.usedSizes.map(function(size) { 73 | if (size.width === -1) { 74 | size.height = img.imageResource.height; 75 | size.width = img.imageResource.width; 76 | } 77 | return size; 78 | }); 79 | 80 | // Warn when any of the used sizes are weird. 81 | img.usedSizes.forEach(function(size) { 82 | var widthRatio = img.imageResource.width / size.width; 83 | var heightRatio = img.imageResource.height / size.height; 84 | var tmp; 85 | if (size.width > img.imageResource.width || size.height > img.imageResource.height) { 86 | img.warnings.push('"' + img.path + '" used at dest-size larger than source image'); 87 | } else if (widthRatio !== heightRatio) { 88 | img.warnings.push('"' + img.path + '" used at stretched size (' + widthRatio + '/' + heightRatio + ')'); 89 | } else if (widthRatio % 1 || heightRatio % 1) { 90 | img.warnings.push('"' + img.path + '" used at uneven size (not a multiple of original image size): (' + widthRatio + ' by ' + heightRatio + ')'); 91 | } 92 | }); 93 | 94 | // Figure out the maximum used size for each image. 95 | img.maxSize = img.usedSizes.reduce(function(a, b) { 96 | return { 97 | height: Math.max(a.height, b.height), 98 | width: Math.max(a.width, b.width), 99 | }; 100 | }, {height: 0, width: 0}); 101 | 102 | // Figure out the maximum size that the image should be when it is 103 | // composited into the sprite. It should be a maximum of two times the 104 | // destination sizes or capped at the size of the image. 105 | img.minSpritedSize = { 106 | width: Math.min(img.maxSize.width * IMAGE_SCALE_FACTOR, img.imageResource.width), 107 | height: Math.min(img.maxSize.height * IMAGE_SCALE_FACTOR, img.imageResource.height), 108 | }; 109 | 110 | return img; 111 | }); 112 | }; 113 | 114 | 115 | /** 116 | * Returns the scale factor for an axis 117 | * @param {object} img The image to determine the scale factor for 118 | * @param {string} widthOrHeight "width" or "height"; denotes the axis 119 | * @return {int} 120 | */ 121 | function getImageScaleFactor(img, widthOrHeight) { 122 | var scaleFactorXVals = img.usedSizes.map(function(s) { 123 | var scale = img.minSpritedSize[widthOrHeight] / s[widthOrHeight]; 124 | if (scale === 1) { 125 | return null; 126 | } 127 | if (scale !== Math.ceil(scale)) { 128 | img.warnings.push( 129 | '"' + img.path + '" was used at a size that is scaled with a ' + 130 | 'non-integer (' + scale + ', ' + img.minSpritedSize[widthOrHeight] + 131 | ' to ' + s[widthOrHeight] + '). Its ' + widthOrHeight + 132 | ' will not be pixel-aliged.' 133 | ); 134 | return null; 135 | } 136 | return scale; 137 | }); 138 | scaleFactorXVals = scaleFactorXVals.filter(function(x) { 139 | return x !== null; 140 | }); 141 | return lcm([IMAGE_SCALE_FACTOR].concat(scaleFactorXVals)); 142 | } 143 | 144 | /** 145 | * This method generates a layout object from the images that were passed. 146 | * @param {object[]} images Array of extracted image objects. 147 | * @returns {object} Object describing images arranged into a layout. 148 | */ 149 | exports.performLayout = function performLayout(images) { 150 | var currentX = 0; 151 | var currentY = 0; 152 | var nextY = 0; 153 | var maxY = 0; 154 | 155 | var i; 156 | 157 | var maxWidth = getMaxImageWidth(images); 158 | 159 | sortImagesByWidth(images); 160 | 161 | var scaleFactorX; 162 | var scaleFactorY; 163 | 164 | var layout = []; 165 | for (i = 0; i < images.length; i++) { 166 | var img = images[i]; 167 | 168 | if (currentX + img.minSpritedSize.width > maxWidth) { 169 | currentX = 0; 170 | currentY = Math.ceil(nextY); 171 | } 172 | 173 | // Line up the sprite to the lowest common denominator (the nearest 174 | // pixel boundary). 175 | if (currentX) { 176 | scaleFactorX = getImageScaleFactor(img, 'width'); 177 | currentX += scaleFactorX - (currentX % scaleFactorX); 178 | } 179 | if (currentY) { 180 | scaleFactorY = getImageScaleFactor(img, 'height'); 181 | currentY += scaleFactorY - (currentY % scaleFactorY); 182 | } 183 | 184 | var result = { 185 | imageResource: img.imageResource, 186 | path: img.path, 187 | x: currentX, 188 | y: currentY, 189 | width: img.minSpritedSize.width, 190 | height: img.minSpritedSize.height, 191 | }; 192 | 193 | maxY = Math.max(maxY, currentY + result.height); 194 | nextY = Math.max(nextY, currentY + result.height) + IMAGE_MARGIN; 195 | currentX += result.width + IMAGE_MARGIN; 196 | currentX = Math.ceil(currentX); 197 | 198 | layout.push(result); 199 | } 200 | 201 | return { 202 | images: layout, 203 | mapping: getMapping(layout), 204 | width: maxWidth, 205 | height: maxY, 206 | }; 207 | 208 | }; 209 | 210 | /** 211 | * This method generates a compatibility layout object from the images that were passed. 212 | * @param {object[]} images Array of extracted image objects. 213 | * @returns {object} Object describing images arranged into a compatibility layout. 214 | */ 215 | exports.performLayoutCompat = function performLayoutCompat(images) { 216 | var currentX = 0; 217 | var currentY = 0; 218 | var nextY = 0; 219 | 220 | var i; 221 | var j; 222 | 223 | var max_width = getMaxImageWidth(images); 224 | 225 | sortImagesByWidth(images); 226 | 227 | var layout = []; 228 | for (i = 0; i < images.length; i++) { 229 | var imageRef = images[i]; 230 | 231 | for (j = 0; j < imageRef.usedSizes.length; j++) { 232 | var img = imageRef.usedSizes[j]; 233 | if (currentX + img.width > max_width) { 234 | currentX = 0; 235 | currentY = Math.ceil(nextY); 236 | } 237 | 238 | var result = { 239 | imageResource: imageRef.imageResource, 240 | path: imageRef.path, 241 | x: currentX, 242 | y: currentY, 243 | width: img.width, 244 | height: img.height, 245 | }; 246 | 247 | nextY = Math.max(nextY, currentY + result.height); 248 | currentX += result.width; 249 | currentX = Math.ceil(currentX); 250 | 251 | layout.push(result); 252 | } 253 | 254 | } 255 | 256 | return { 257 | images: layout, 258 | mapping: getMappingCompat(layout), 259 | width: max_width, 260 | height: nextY, 261 | }; 262 | 263 | }; 264 | 265 | 266 | //------------------------------------------------------------------------------ 267 | // Private 268 | //------------------------------------------------------------------------------ 269 | 270 | /** 271 | * Returns the maximum width of a sprite given an array of image objects 272 | * that will be included in it. 273 | * @param {object[]} images Array of image objects. 274 | * @returns {number} Max width of the sprite 275 | */ 276 | function getMaxImageWidth(images) { 277 | return Math.max( 278 | MIN_WIDTH, 279 | Math.max.apply(Math, images.map(function(img) { 280 | return img.minSpritedSize.width; 281 | })) 282 | ); 283 | } 284 | 285 | /** 286 | * Sorts an array of image objects by width. 287 | * @param {object[]} images Array of extracted image objects to be sorted. 288 | * @returns {void} 289 | */ 290 | function sortImagesByWidth(images) { 291 | images.sort(function(a, b) { 292 | return b.maxSize.width - a.maxSize.width; 293 | }); 294 | } 295 | 296 | /** 297 | * Returns a mapping of image paths to images from a layout object. 298 | * @param {object} object The layout object to map 299 | * @returns {object} The resulting mapping 300 | */ 301 | function getMapping(layout) { 302 | var mapping = {}; 303 | for (var i = 0; i < layout.length; i++) { 304 | mapping[layout[i].path] = layout[i]; 305 | } 306 | return mapping; 307 | } 308 | 309 | /** 310 | * Returns a mapping of image paths by dest size to images from a layout object. 311 | * @param {object} object The layout object to map 312 | * @returns {object} The resulting mapping 313 | */ 314 | function getMappingCompat(layout) { 315 | var mapping = {}; 316 | for (var i = 0; i < layout.length; i++) { 317 | var img = layout[i]; 318 | var sizeHash = image.getSizeHash(img.height, img.width); 319 | mapping[img.path + sizeHash] = img; 320 | } 321 | return mapping; 322 | } 323 | -------------------------------------------------------------------------------- /lib/processor.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Tools responsible for reading and processing CSS 3 | * @author basta 4 | */ 5 | 6 | 'use strict'; 7 | 8 | //------------------------------------------------------------------------------ 9 | // Requirements 10 | //------------------------------------------------------------------------------ 11 | 12 | var assert = require('assert'); 13 | var fs = require('fs'); 14 | var path = require('path'); 15 | 16 | var crass = require('crass'); 17 | 18 | var image = require('./image'); 19 | var walker = require('./walker'); 20 | 21 | 22 | //------------------------------------------------------------------------------ 23 | // Private 24 | //------------------------------------------------------------------------------ 25 | 26 | var SHALAM_COMMENT_PATTERN = '[\\s\\n\\t]*\\/\\* shalam! \\*\\/(.|\\n|\\s)*?\\/\\* end shalam \\*\\/([\\s\\n\\t]*)'; 27 | var SPRITE_DECL_NAME = '-shalam-sprite'; 28 | 29 | 30 | //------------------------------------------------------------------------------ 31 | // Public 32 | //------------------------------------------------------------------------------ 33 | 34 | 35 | /** 36 | * The CSS file processor object. 37 | * @constructor 38 | * @param {string} path Path to the CSS file being processed. 39 | */ 40 | function Processor(path) { 41 | this.path = path; 42 | this.data = fs.readFileSync(path).toString(); 43 | this.stripData(); 44 | 45 | this.parsed = crass.parse(this.data); 46 | this.rulesets = []; 47 | this.foundSprites = []; 48 | 49 | this.search(); 50 | } 51 | 52 | /** 53 | * Strips existing shalam comments from the CSS file. 54 | * @returns {void} 55 | */ 56 | Processor.prototype.stripData = function stripData() { 57 | if(/\/\* shalam! \*\//.exec(this.data)) { 58 | this.data = this.data.replace(new RegExp(SHALAM_COMMENT_PATTERN, 'gi'), function(match) { 59 | // This allows us to match the line ending for the end of the 60 | // comment. By returning the last bit of whitespace (matches[2]), 61 | // it looks as if the shalam comment was never there. 62 | var matches = new RegExp(SHALAM_COMMENT_PATTERN).exec(match); 63 | return matches[2] || ''; 64 | }); 65 | } 66 | }; 67 | 68 | /** 69 | * Searches the CSS file for `-shalam-sprite` declarations. 70 | * @returns {void} 71 | */ 72 | Processor.prototype.search = function search() { 73 | walker.walk(this.parsed, function processorSearchCallback(ruleset) { 74 | var decl; 75 | var spriteDecl; 76 | // When a ruleset is encountered, iterate its contents. 77 | for (var i = 0; i < ruleset.content.length; i++) { 78 | decl = ruleset.content[i]; 79 | // If the declaration doesn't have an applicable name, ignore it. 80 | if (decl.ident !== SPRITE_DECL_NAME) continue; 81 | // Keep only a single declaration around. Only the last sprite 82 | // declaration is used. 83 | spriteDecl = decl; 84 | } 85 | // If no sprite declaration was found in the ruleset, ignore the ruleset. 86 | if (!spriteDecl) return; 87 | 88 | // Get data about the sprite declaration. 89 | var spriteData = getSpriteDecl(spriteDecl); 90 | 91 | // Save references to the sprite declaration and the ruleset. 92 | this.foundSprites.push(spriteData.path); 93 | this.rulesets.push({ 94 | declaration: spriteDecl, 95 | spriteData: spriteData, 96 | ruleset: ruleset, 97 | newRules: null, // Filled in after processing is complete with the new Shalam data 98 | }); 99 | }.bind(this)); 100 | }; 101 | 102 | /** 103 | * Returns whether any rulesets in the CSS file contain sprite declarations. 104 | * @returns {bool} 105 | */ 106 | Processor.prototype.hasMatches = function hasMatches() { 107 | return !!this.rulesets.length; 108 | }; 109 | 110 | /** 111 | * Returns an array of rulesets in the corresponding CSS file sorted by their 112 | * position in the file (top to bottom). 113 | * @return {object[]} Sorted rulesets 114 | */ 115 | Processor.prototype.getSortedRulesets = function getSortedRulesets() { 116 | var rulesets = this.rulesets; 117 | rulesets.sort(function(a, b) { 118 | return a.ruleset.range.range[1] - b.ruleset.range.range[1]; 119 | }); 120 | return rulesets; 121 | }; 122 | 123 | /** 124 | * Returns the string used to indent lines in the file. 125 | * @return {string} 126 | */ 127 | Processor.prototype.guessFileIndentation = function guessFileIndentation() { 128 | // Default to indenting with four spaces 129 | var indentChars = ' '; 130 | if (this.data[this.rulesets[0].declaration.range.range[0] - 1] === '\t') { 131 | indentChars = '\t'; 132 | } 133 | return indentChars; 134 | }; 135 | 136 | Processor.prototype.getSpriteRelativePath = function getSpriteRelativePath(spriteDestination) { 137 | return path.relative(path.dirname(this.path), spriteDestination); 138 | }; 139 | 140 | /** 141 | * Rewrites the CSS file to include updated sprite sheet information. 142 | * @param {object} layout The layout object to update the file with. 143 | * @param {object} layoutCompat The compatibility layout object to update the file with. 144 | * @param {string} spriteDestination The location to composite the sprites to. 145 | * @returns {void} 146 | */ 147 | Processor.prototype.applyLayout = function applyLayout(layout, layoutCompat, spriteDestination) { 148 | var spritePath = this.getSpriteRelativePath(spriteDestination); 149 | 150 | // Sort each of the rulesets by its position in the stylesheet 151 | var rulesets = this.getSortedRulesets(); 152 | 153 | rulesets.forEach(function(rulesetItem) { 154 | var spriteData = rulesetItem.spriteData; 155 | var sizeHash = image.getSizeHash(spriteData.destHeight, spriteData.destWidth); 156 | var layoutItem = layout.mapping[spriteData.path]; 157 | var layoutCompatItem = layoutCompat.mapping[spriteData.path + sizeHash]; 158 | 159 | var newRules = []; 160 | 161 | var expressionChain = []; 162 | expressionChain.push([null, new crass.objects.URI(spritePath + '.png')]); 163 | 164 | var compatExpressionChain = []; 165 | compatExpressionChain.push([null, new crass.objects.URI(spritePath + '.compat.png')]); 166 | 167 | var shouldScale = !!spriteData.destWidth; 168 | var xScale = 1; 169 | var yScale = 1; 170 | 171 | // If we have destination size information, use that. 172 | if (shouldScale) { 173 | // Update the scale factor for the sprite. This is necessary 174 | // because background-position uses units relative to the scaled 175 | // background size rather than the source background size. 176 | xScale = spriteData.destWidth / layoutItem.width; 177 | yScale = spriteData.destHeight / layoutItem.height; 178 | } 179 | 180 | // Write the background position declaration with scaling. 181 | expressionChain.push([null, newDimension(layoutItem.x * -1 * xScale)]); 182 | expressionChain.push([null, newDimension(layoutItem.y * -1 * yScale)]); 183 | 184 | compatExpressionChain.push([null, newDimension(layoutCompatItem.x * -1)]); 185 | compatExpressionChain.push([null, newDimension(layoutCompatItem.y * -1)]); 186 | 187 | if (shouldScale) { 188 | expressionChain.push(['/', newDimension(layout.width * xScale)]); 189 | expressionChain.push([null, newDimension(layout.height * yScale)]); 190 | } 191 | 192 | var isImportant = rulesetItem.declaration.important; 193 | newRules.push(newDeclaration('background', compatExpressionChain, isImportant)); 194 | newRules.push(newDeclaration('background', expressionChain, isImportant)); 195 | 196 | rulesetItem.newRules = newRules; 197 | }); 198 | 199 | this.rewriteCSS(rulesets); 200 | this.saveUpdatedCSS(this.data); 201 | 202 | }; 203 | 204 | /** 205 | * Rewrites the CSS using changes triggered by prior processing on a set of 206 | * rulesets. 207 | * @param {Ruleset[]} rulesetsToProcess 208 | * @return {void} 209 | */ 210 | Processor.prototype.rewriteCSS = function(rulesetsToProcess) { 211 | var indentChars = this.guessFileIndentation(); 212 | 213 | // Iterate each affected ruleset in reverse and inject the sprite 214 | // declarations into the end of the ruleset. 215 | var processedOutput = this.data; 216 | var index; 217 | var rulesetData; 218 | for (var i = rulesetsToProcess.length - 1; i >= 0; i--) { 219 | // Build the string of CSS to inject 220 | rulesetData = this.generateRulesetSpriteData(rulesetsToProcess[i], indentChars); 221 | 222 | // Inject the new CSS declarations into the ruleset 223 | index = rulesetsToProcess[i].declaration.range.range[1] + 1; 224 | // If the declaration has a semicolon after it, 225 | if (processedOutput[index] === ';') { 226 | index += 1; 227 | } 228 | processedOutput = processedOutput.substr(0, index) + rulesetData + processedOutput.substr(index); 229 | } 230 | 231 | this.data = processedOutput; 232 | }; 233 | 234 | /** 235 | * Saves the updated CSS back to the source file. 236 | * @param {string} newCSS The updated CSS 237 | * @return {void} 238 | */ 239 | Processor.prototype.saveUpdatedCSS = function saveUpdatedCSS(newCSS) { 240 | fs.writeFileSync(this.path, newCSS); 241 | }; 242 | 243 | /** 244 | * Creates new declarations for an updated CSS file with new shalam comments. 245 | * @param {Ruleset} ruleset The ruleset object to base the new declarations on 246 | * @param {string} indentChars The characters to indent the declarations with 247 | * @returns {string} The new shalam comments and declarations. 248 | */ 249 | Processor.prototype.generateRulesetSpriteData = function generateRulesetSpriteData(ruleset, indentChars) { 250 | // Build the opening shalam comment. 251 | // NOTE: The extra semicolon is to account for weirdly-formed `sprite` 252 | // declarations. It is intentional. 253 | var rulesetData = '\n' + indentChars + '/* shalam! */;\n'; 254 | 255 | // Pretty print each declaration that we're adding 256 | rulesetData += ruleset.newRules.map(function(decl) { 257 | // Semicolons must be added manually. 258 | return indentChars + decl.pretty() + ';\n'; 259 | }).join(''); 260 | 261 | // Add the ending shalam comment. 262 | rulesetData += indentChars + '/* end shalam */'; 263 | 264 | return rulesetData; 265 | } 266 | 267 | /** 268 | * Returns a sprite declaration object given a node from a CSS parse tree. It 269 | * will assert that the declaration is well-formed. 270 | * @param {Declaration} decl The Declaration object from the CSS parse tree. 271 | * @returns {object} 272 | */ 273 | function getSpriteDecl(decl) { 274 | var data = { 275 | destX: null, 276 | destY: null, 277 | destWidth: null, 278 | destHeight: null, 279 | path: null, 280 | }; 281 | 282 | function mustBePxDimension(val) { 283 | assert(val instanceof crass.objects.Dimension); 284 | assert.equal(val.unit, 'px'); 285 | } 286 | 287 | assert(decl.expr.chain[0][1] instanceof crass.objects.String, 'First expression must be a string'); 288 | data.path = decl.expr.chain[0][1].value; 289 | 290 | if (decl.expr.chain.length > 1) { 291 | assert.equal(decl.expr.chain.length, 2); 292 | assert(decl.expr.chain[1][1] instanceof crass.objects.Func, 'Subsequent expressions must be functions'); 293 | assert.equal(decl.expr.chain[1][1].name, 'dest-size'); 294 | 295 | // Test that the first is the dest-expr function 296 | var destExpr = decl.expr.chain[1][1].content; 297 | assert(destExpr instanceof crass.objects.Expression); 298 | assert.equal(destExpr.chain.length, 2); 299 | mustBePxDimension(destExpr.chain[0][1]); 300 | mustBePxDimension(destExpr.chain[1][1]); 301 | 302 | // Save those values 303 | data.destWidth = destExpr.chain[0][1].number.value; 304 | data.destHeight = destExpr.chain[1][1].number.value; 305 | 306 | } else { 307 | data.destWidth = -1; 308 | data.destHeight = -1; 309 | } 310 | 311 | return data; 312 | 313 | } 314 | 315 | /** 316 | * Creates a new CSS Declaration object. 317 | * @param {string} name The name of the CSS declaration to create 318 | * @param {object} expression The contents of the CSS expression to create within the declaration 319 | * @returns {Declaration} The new CSS declaration object. 320 | */ 321 | function newDeclaration(name, expression, isImportant) { 322 | var decl = new crass.objects.Declaration( 323 | name, 324 | new crass.objects.Expression(expression) 325 | ); 326 | decl.important = isImportant; 327 | return decl; 328 | } 329 | 330 | /** 331 | * Creates a new CSS Dimension object. 332 | * @param {number} value The numeric half of the dimension 333 | * @param {string} [unit] The unit to apply to the dimension (defaults to `px`) 334 | * @returns {Dimension|Number} The new CSS dimension object. 335 | */ 336 | function newDimension(value, unit) { 337 | // Short-circuit 0px -> 0 338 | if (!value) { 339 | return new crass.objects.Number(value); 340 | } 341 | return new crass.objects.Dimension( 342 | new crass.objects.Number(value), 343 | unit || 'px' 344 | ); 345 | } 346 | 347 | module.exports = Processor; 348 | module.exports.getSpriteDecl = getSpriteDecl; 349 | module.exports.newDeclaration = newDeclaration; 350 | module.exports.newDimension = newDimension; 351 | -------------------------------------------------------------------------------- /test/processor.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | 3 | var crass = require('crass'); 4 | 5 | var Processor = require('../lib/processor'); 6 | 7 | 8 | describe('Processor', function() { 9 | describe('stripData()', function() { 10 | 11 | var mockProcessor = {}; 12 | 13 | it('should strip existing shalam declarations', function() { 14 | mockProcessor.data = 'foo {/* shalam! */ hello: goodbye; /* end shalam */}'; 15 | Processor.prototype.stripData.call(mockProcessor); 16 | 17 | assert.equal(mockProcessor.data, 'foo {}'); 18 | }); 19 | 20 | it('should strip existing shalam declarations while preserving whitespace', function() { 21 | mockProcessor.data = 'foo {\n first: foo;\n /* shalam! */\n hello: goodbye;\n /* end shalam */\n}'; 22 | Processor.prototype.stripData.call(mockProcessor); 23 | 24 | assert.equal(mockProcessor.data, 'foo {\n first: foo;\n}'); 25 | }); 26 | 27 | it('should strip existing shalam declarations while preserving whitespace between other declarations', function() { 28 | mockProcessor.data = 'foo {\n first: foo;\n /* shalam! */\n hello: goodbye;\n /* end shalam */\n second: bar;\n}'; 29 | Processor.prototype.stripData.call(mockProcessor); 30 | 31 | assert.equal(mockProcessor.data, 'foo {\n first: foo;\n second: bar;\n}'); 32 | }); 33 | 34 | }); 35 | 36 | describe('hasMatches()', function() { 37 | 38 | var mockProcessor = {}; 39 | 40 | it('should return true when there are matches', function() { 41 | mockProcessor.rulesets = ['first', 'second']; 42 | assert(Processor.prototype.hasMatches.call(mockProcessor)); 43 | }); 44 | 45 | it('should return false when there are no matches', function() { 46 | mockProcessor.rulesets = []; 47 | assert(!Processor.prototype.hasMatches.call(mockProcessor)); 48 | }); 49 | 50 | }); 51 | 52 | describe('search()', function() { 53 | 54 | it('should find sprite declarations in a CSS file', function() { 55 | var proc = { 56 | parsed: crass.parse('x y z{-shalam-sprite: "foo/bar";}'), 57 | foundSprites: [], 58 | rulesets: [], 59 | }; 60 | 61 | Processor.prototype.search.call(proc); 62 | 63 | assert.deepEqual(proc.foundSprites, ['foo/bar']); 64 | assert.equal(proc.rulesets.length, 1); 65 | assert.ok(proc.rulesets[0].ruleset instanceof crass.objects.Ruleset); 66 | assert.deepEqual(proc.rulesets[0].spriteData, { 67 | path: 'foo/bar', 68 | destWidth: -1, 69 | destHeight: -1, 70 | destX: null, 71 | destY: null, 72 | }); 73 | }); 74 | 75 | it('should find sprite declarations in a CSS file with importance', function() { 76 | var proc = { 77 | parsed: crass.parse('x y z{-shalam-sprite: "foo/bar" !important;}'), 78 | foundSprites: [], 79 | rulesets: [], 80 | }; 81 | 82 | Processor.prototype.search.call(proc); 83 | 84 | assert.ok(proc.rulesets[0].declaration.important); 85 | }); 86 | 87 | it('should ignore non-shalam declarations', function() { 88 | var proc = { 89 | parsed: crass.parse('x y z{foo: bar}'), 90 | foundSprites: [], 91 | rulesets: [], 92 | }; 93 | 94 | Processor.prototype.search.call(proc); 95 | 96 | assert.equal(proc.foundSprites.length, 0); 97 | assert.equal(proc.rulesets.length, 0); 98 | }); 99 | 100 | }); 101 | 102 | describe('getSortedRulesets', function() { 103 | var mockProcessor = { 104 | rulesets: [], 105 | }; 106 | 107 | function getFakeRuleset(start, end) { 108 | return { 109 | ruleset: { 110 | range: { 111 | range: [start, end], 112 | }, 113 | }, 114 | }; 115 | } 116 | 117 | it('should return the rulesets sorted by end position', function() { 118 | var first = getFakeRuleset(0, 10); 119 | var second = getFakeRuleset(11, 20); 120 | var third = getFakeRuleset(21, 30); 121 | 122 | mockProcessor.rulesets.push(second); 123 | mockProcessor.rulesets.push(first); 124 | mockProcessor.rulesets.push(third); 125 | 126 | var output = Processor.prototype.getSortedRulesets.call(mockProcessor); 127 | 128 | assert.equal(output[0], first); 129 | assert.equal(output[1], second); 130 | assert.equal(output[2], third); 131 | 132 | }); 133 | 134 | }); 135 | 136 | describe('guessFileIndentation', function() { 137 | var mockRange = []; 138 | var mockProcessor = { 139 | data: 'foo {\n\tabc: def;\n}\n', 140 | rulesets: [ 141 | { 142 | declaration: { 143 | range: { 144 | range: mockRange, 145 | }, 146 | } 147 | }, 148 | ], 149 | }; 150 | 151 | it('should return four spaces by default', function() { 152 | mockProcessor.data = 'foo {\n abc: def;\n}\n'; 153 | mockRange[0] = 10; 154 | mockRange[1] = 19; 155 | var output = Processor.prototype.guessFileIndentation.call(mockProcessor); 156 | assert.equal(output, ' '); 157 | }); 158 | 159 | it('should return a tab when a tab is set', function() { 160 | mockProcessor.data = 'foo {\n\tabc: def;\n}\n'; 161 | mockRange[0] = 7; 162 | mockRange[1] = 16; 163 | var output = Processor.prototype.guessFileIndentation.call(mockProcessor); 164 | assert.equal(output, '\t'); 165 | }); 166 | 167 | }); 168 | 169 | describe('getSpriteRelativePath', function() { 170 | it('should generate the correct relative path', function() { 171 | var mockProcessor = { 172 | path: '/opt/shalam/style.css', 173 | }; 174 | assert.equal( 175 | Processor.prototype.getSpriteRelativePath.call(mockProcessor, '/opt/shalam/sprite.png'), 176 | 'sprite.png' 177 | ); 178 | }); 179 | 180 | it('should generate the correct relative path across directories', function() { 181 | var mockProcessor = { 182 | path: '/opt/shalam/static/css/style.css', 183 | }; 184 | assert.equal( 185 | Processor.prototype.getSpriteRelativePath.call(mockProcessor, '/opt/shalam/static/img/sprite.png'), 186 | '../img/sprite.png' 187 | ); 188 | }); 189 | 190 | }); 191 | 192 | describe('generateRulesetSpriteData', function() { 193 | 194 | it('should return the expected ruleset for empty input', function() { 195 | assert.equal( 196 | Processor.prototype.generateRulesetSpriteData( 197 | {newRules: []}, 198 | ' ' 199 | ), 200 | '\n /* shalam! */;\n /* end shalam */' 201 | ); 202 | }); 203 | 204 | it('should return the expected ruleset for non-empty input', function() { 205 | assert.equal( 206 | Processor.prototype.generateRulesetSpriteData( 207 | {newRules: [ 208 | new crass.objects.Declaration( 209 | 'foo', 210 | new crass.objects.Expression([ 211 | [null, new crass.objects.Number(123)], 212 | ]) 213 | ) 214 | ]}, 215 | ' ' 216 | ), 217 | '\n /* shalam! */;\n foo: 123;\n /* end shalam */' 218 | ); 219 | }); 220 | 221 | }); 222 | 223 | describe('rewriteCSS', function() { 224 | 225 | it('should inject Shalam data in all the right places', function() { 226 | var mockCSS = 'foo {\n\ 227 | bar: 123px;\n\ 228 | -shalam-sprite: "foo";\n\ 229 | }\n\ 230 | zip {\n\ 231 | -shalam-sprite: "foo";\n\ 232 | bar: 123px;\n\ 233 | }\n\ 234 | dupe {\n\ 235 | -shalam-sprite: "foo";\n\ 236 | bar: 123px;\n\ 237 | -shalam-sprite: "foo";\n\ 238 | }'; 239 | var parsed = crass.parse(mockCSS); 240 | 241 | var declarationIndices = [1, 0, 2]; // Indices of -shalam-sprite in the css above 242 | var mockProcessor = { 243 | data: mockCSS, 244 | guessFileIndentation: function() { 245 | return ' '; 246 | }, 247 | generateRulesetSpriteData: function() { 248 | // This represents the indentation of this file. 249 | return '\n /* << shalam >> */'; 250 | }, 251 | }; 252 | var rulesets = parsed.content.map(function(ruleset, i) { 253 | return { 254 | declaration: ruleset.content[declarationIndices[i]], 255 | ruleset: ruleset, 256 | }; 257 | }); 258 | 259 | Processor.prototype.rewriteCSS.call(mockProcessor, rulesets); 260 | var expectedOutput = 'foo {\n\ 261 | bar: 123px;\n\ 262 | -shalam-sprite: "foo";\n\ 263 | /* << shalam >> */\n\ 264 | }\n\ 265 | zip {\n\ 266 | -shalam-sprite: "foo";\n\ 267 | /* << shalam >> */\n\ 268 | bar: 123px;\n\ 269 | }\n\ 270 | dupe {\n\ 271 | -shalam-sprite: "foo";\n\ 272 | bar: 123px;\n\ 273 | -shalam-sprite: "foo";\n\ 274 | /* << shalam >> */\n\ 275 | }'; 276 | assert.equal(mockProcessor.data, expectedOutput); 277 | 278 | }); 279 | 280 | }); 281 | 282 | }); 283 | 284 | describe('getSpriteDecl()', function() { 285 | 286 | function getDeclaration(input) { 287 | var res = crass.parse('foo{' + input + '}'); 288 | return res.content[0].content[0]; 289 | } 290 | 291 | it('should reject declarations that do not have an initial string', function() { 292 | assert.throws(function() { 293 | Processor.getSpriteDecl(getDeclaration('-shalam-sprite: 123')); 294 | }); 295 | }); 296 | 297 | it('should accept bare strings', function() { 298 | assert.deepEqual( 299 | Processor.getSpriteDecl(getDeclaration('-shalam-sprite: "foo/bar.png"')), 300 | { 301 | path: 'foo/bar.png', 302 | destWidth: -1, 303 | destHeight: -1, 304 | destX: null, 305 | destY: null, 306 | } 307 | ); 308 | }); 309 | 310 | it('should reject declarations that have non-function second expression elements', function() { 311 | assert.throws(function() { 312 | Processor.getSpriteDecl(getDeclaration('-shalam-sprite: "foo" 123')); 313 | }); 314 | }); 315 | 316 | it('should reject declarations that have more than two expression elements', function() { 317 | assert.throws(function() { 318 | Processor.getSpriteDecl(getDeclaration('-shalam-sprite: "foo" dest-size(10px 10px) 123')); 319 | }); 320 | }); 321 | 322 | it('should reject declarations that do not use dest-size as the second expression element', function() { 323 | assert.throws(function() { 324 | Processor.getSpriteDecl(getDeclaration('-shalam-sprite: "foo" foo-size(10px 10px)')); 325 | }); 326 | }); 327 | 328 | it('should reject declarations that have an invalid dest-size', function() { 329 | assert.throws(function() { 330 | Processor.getSpriteDecl(getDeclaration('-shalam-sprite: "foo" dest-size(10px)')); 331 | }); 332 | }); 333 | 334 | it('should accept sized strings', function() { 335 | assert.deepEqual( 336 | Processor.getSpriteDecl(getDeclaration('-shalam-sprite: "foo/bar.png" dest-size(12px 34px)')), 337 | { 338 | path: 'foo/bar.png', 339 | destWidth: 12, 340 | destHeight: 34, 341 | destX: null, 342 | destY: null, 343 | } 344 | ); 345 | }); 346 | 347 | }); 348 | 349 | describe('newDimension()', function() { 350 | it('should return a Crass Dimension object', function() { 351 | assert.equal( 352 | Processor.newDimension(123).toString(), 353 | '123px' 354 | ); 355 | }); 356 | 357 | it('should return a Crass Dimension object with units', function() { 358 | assert.equal( 359 | Processor.newDimension(123, 'in').toString(), 360 | '123in' 361 | ); 362 | }); 363 | 364 | it('should return a Crass Number object without units when the input is zero', function() { 365 | assert.equal( 366 | Processor.newDimension(0, 'px').toString(), 367 | '0' 368 | ); 369 | }); 370 | 371 | }); 372 | 373 | describe('newDeclaration()', function() { 374 | it('should return a Crass Declaration object', function() { 375 | assert.equal( 376 | Processor.newDeclaration( 377 | 'foo', 378 | [[null, Processor.newDimension(123)]] 379 | ).toString(), 380 | 'foo:123px' 381 | ); 382 | }); 383 | it('should return a Crass Declaration object with importance', function() { 384 | assert.equal( 385 | Processor.newDeclaration( 386 | 'foo', 387 | [[null, Processor.newDimension(123)]], 388 | true 389 | ).toString(), 390 | 'foo:123px!important' 391 | ); 392 | }); 393 | 394 | }); 395 | -------------------------------------------------------------------------------- /test/layout.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | 3 | var mockery = require('mockery'); 4 | var sinon = require('sinon'); 5 | 6 | 7 | describe('Layout', function() { 8 | 9 | function fakeImage(width, height, id) { 10 | return { 11 | imageResource: {id: id}, 12 | path: id, 13 | usedSizes: [ 14 | {width: width, height: height}, 15 | ], 16 | maxSize: {width: width, height: height}, 17 | minSpritedSize: {width: width, height: height}, 18 | warnings: [], 19 | }; 20 | } 21 | 22 | describe('performLayout()', function() { 23 | 24 | var layout = require('../lib/layout'); 25 | 26 | it('should order the images from widest to narrowest', function() { 27 | var images = [ 28 | fakeImage(120, 120, 'first'), 29 | fakeImage(250, 100, 'second'), 30 | fakeImage(200, 10, 'third'), 31 | ]; 32 | 33 | var computedLayout = layout.performLayout(images); 34 | assert.deepEqual( 35 | computedLayout.images.map(function(img) { 36 | return img.imageResource.id; 37 | }), 38 | ['second', 'third', 'first'] 39 | ); 40 | 41 | }); 42 | 43 | it('should make the layout a minumum of 256px wide', function() { 44 | var images = [ 45 | fakeImage(10, 10, 'first'), 46 | ]; 47 | 48 | var computedLayout = layout.performLayout(images); 49 | assert.equal(computedLayout.width, 512); 50 | 51 | }); 52 | 53 | it('should make the layout width equal to the widest element if it exceeds 256px', function() { 54 | var images = [ 55 | fakeImage(700, 10, 'first'), 56 | fakeImage(260, 200, 'second'), 57 | fakeImage(10, 10, 'third'), 58 | ]; 59 | 60 | var computedLayout = layout.performLayout(images); 61 | assert.equal(computedLayout.width, 700); 62 | 63 | }); 64 | 65 | it('should generate a mapping', function() { 66 | var images = [ 67 | fakeImage(300, 10, 'first'), 68 | fakeImage(260, 200, 'second'), 69 | fakeImage(10, 10, 'third'), 70 | ]; 71 | 72 | var computedLayout = layout.performLayout(images); 73 | assert.equal(images[0].imageResource, computedLayout.mapping.first.imageResource); 74 | assert.equal(images[1].imageResource, computedLayout.mapping.second.imageResource); 75 | assert.equal(images[2].imageResource, computedLayout.mapping.third.imageResource); 76 | 77 | }); 78 | 79 | it('should properly calculate the height of the layout', function() { 80 | 81 | // Since these images do not meet the minimum width, they should 82 | // never stack. Thus, the height should be 10. 83 | var images = [ 84 | fakeImage(10, 10, 'first'), 85 | fakeImage(10, 10, 'second'), 86 | fakeImage(10, 10, 'third'), 87 | ]; 88 | 89 | var computedLayout = layout.performLayout(images); 90 | assert.equal(computedLayout.height, 10); 91 | 92 | }); 93 | 94 | it('should properly calculate the height of the layout if it wraps', function() { 95 | 96 | // Since these images exceed the width of the widest element, so 97 | // it should wrap at each line. 98 | var images = [ 99 | fakeImage(700, 10, 'first'), 100 | fakeImage(250, 10, 'second'), 101 | fakeImage(200, 10, 'third'), 102 | ]; 103 | 104 | var computedLayout = layout.performLayout(images); 105 | assert.equal(computedLayout.width, 700); 106 | assert.equal(computedLayout.height, 26); 107 | 108 | }); 109 | 110 | it('should allow images to line up on a single line', function() { 111 | var images = [ 112 | fakeImage(700, 10, 'first'), 113 | fakeImage(250, 10, 'second'), 114 | fakeImage(50, 10, 'third'), 115 | ]; 116 | 117 | var computedLayout = layout.performLayout(images); 118 | assert.equal(computedLayout.width, 700); 119 | assert.equal(computedLayout.height, 26); 120 | 121 | }); 122 | 123 | it('should not cascade decimal sizes to subsequent images on the x-axis', function() { 124 | var images = [ 125 | { 126 | imageResource: {id: 'weirdSize'}, 127 | path: 'weird/size', 128 | usedSizes: [ 129 | {width: 15, height: 15}, 130 | ], 131 | maxSize: {width: 15, height: 15}, 132 | minSpritedSize: {width: 12.345, height: 12.345}, 133 | warnings: [], 134 | }, 135 | { 136 | imageResource: {id: 'normalSize'}, 137 | path: 'normal/size', 138 | usedSizes: [ 139 | {width: 15, height: 15}, 140 | ], 141 | maxSize: {width: 15, height: 15}, 142 | minSpritedSize: {width: 15, height: 15}, 143 | warnings: [], 144 | }, 145 | ]; 146 | 147 | var computedLayout = layout.performLayout(images); 148 | assert.equal(computedLayout.images[1].x, 16); 149 | 150 | }); 151 | 152 | it('should not cascade decimal sizes to subsequent images on the y-axis', function() { 153 | var images = [ 154 | { 155 | imageResource: {id: 'weirdSize'}, 156 | path: 'weird/size', 157 | usedSizes: [ 158 | {width: 1500, height: 15}, 159 | ], 160 | maxSize: {width: 15, height: 15}, 161 | minSpritedSize: {width: 1200, height: 12.345}, 162 | warnings: [], 163 | }, 164 | { 165 | imageResource: {id: 'normalSize'}, 166 | path: 'normal/size', 167 | usedSizes: [ 168 | {width: 15, height: 15}, 169 | ], 170 | maxSize: {width: 15, height: 15}, 171 | minSpritedSize: {width: 15, height: 15}, 172 | warnings: [], 173 | }, 174 | ]; 175 | 176 | var computedLayout = layout.performLayout(images); 177 | assert.equal(computedLayout.images[1].y, 16); 178 | 179 | }); 180 | 181 | it('should be fine with nicely scaled images', function() { 182 | var img = fakeImage(128, 128, 'first') 183 | img.usedSizes.push({height: 64, width: 64}); 184 | img.usedSizes.push({height: 32, width: 32}); 185 | img.usedSizes.push({height: 16, width: 16}); 186 | var images = [ 187 | fakeImage(256, 256, 'ignore me'), 188 | img, 189 | ]; 190 | 191 | var computedLayout = layout.performLayout(images); 192 | assert.equal(computedLayout.width, 512); 193 | assert.equal(computedLayout.height, 256); 194 | assert.equal(img.warnings.length, 0); 195 | 196 | }); 197 | 198 | it('should warn when images are used at a weird size', function() { 199 | var img = fakeImage(128, 128, 'first') 200 | img.usedSizes.push({height: 64, width: 64}); 201 | img.usedSizes.push({height: 13, width: 13}); 202 | img.usedSizes.push({height: 16, width: 16}); 203 | var images = [ 204 | fakeImage(256, 256, 'ignore me'), 205 | img, 206 | ]; 207 | 208 | var computedLayout = layout.performLayout(images); 209 | assert.equal(computedLayout.width, 512); 210 | assert.equal(computedLayout.height, 256); 211 | assert.equal(img.warnings.length, 1); 212 | 213 | }); 214 | 215 | }); 216 | 217 | describe('performLayoutCompat()', function() { 218 | 219 | var layout = require('../lib/layout'); 220 | 221 | it('should order the images from widest to narrowest', function() { 222 | var images = [ 223 | fakeImage(120, 120, 'first'), 224 | fakeImage(250, 100, 'second'), 225 | fakeImage(200, 10, 'third'), 226 | ]; 227 | 228 | var computedLayout = layout.performLayoutCompat(images); 229 | assert.deepEqual( 230 | computedLayout.images.map(function(img) { 231 | return img.imageResource.id; 232 | }), 233 | ['second', 'third', 'first'] 234 | ); 235 | 236 | }); 237 | 238 | it('should make the layout a minumum of 256px wide', function() { 239 | var images = [ 240 | fakeImage(10, 10, 'first'), 241 | ]; 242 | 243 | var computedLayout = layout.performLayoutCompat(images); 244 | assert.equal(computedLayout.width, 512); 245 | 246 | }); 247 | 248 | it('should make the layout width equal to the widest element if it exceeds 256px', function() { 249 | var images = [ 250 | fakeImage(700, 10, 'first'), 251 | fakeImage(260, 200, 'second'), 252 | fakeImage(10, 10, 'third'), 253 | ]; 254 | 255 | var computedLayout = layout.performLayoutCompat(images); 256 | assert.equal(computedLayout.width, 700); 257 | 258 | }); 259 | 260 | it('should generate a mapping', function() { 261 | var images = [ 262 | fakeImage(300, 10, 'first'), 263 | fakeImage(260, 200, 'second'), 264 | fakeImage(10, 10, 'third'), 265 | ]; 266 | 267 | var computedLayout = layout.performLayoutCompat(images); 268 | assert.equal(images[0].imageResource, computedLayout.mapping['first10x300'].imageResource); 269 | assert.equal(images[1].imageResource, computedLayout.mapping['second200x260'].imageResource); 270 | assert.equal(images[2].imageResource, computedLayout.mapping['third10x10'].imageResource); 271 | 272 | }); 273 | 274 | it('should properly calculate the height of the layout', function() { 275 | 276 | // Since these images do not meet the minimum width, they should 277 | // never stack. Thus, the height should be 10. 278 | var images = [ 279 | fakeImage(10, 10, 'first'), 280 | fakeImage(10, 10, 'second'), 281 | fakeImage(10, 10, 'third'), 282 | ]; 283 | 284 | var computedLayout = layout.performLayoutCompat(images); 285 | assert.equal(computedLayout.height, 10); 286 | 287 | }); 288 | 289 | it('should properly calculate the height of the layout if it wraps', function() { 290 | 291 | // Since these images exceed the width of the widest element, so 292 | // it should wrap at each line. 293 | var images = [ 294 | fakeImage(700, 10, 'first'), 295 | fakeImage(250, 10, 'second'), 296 | fakeImage(200, 10, 'third'), 297 | ]; 298 | 299 | var computedLayout = layout.performLayoutCompat(images); 300 | assert.equal(computedLayout.width, 700); 301 | assert.equal(computedLayout.height, 20); 302 | 303 | }); 304 | 305 | it('should allow images to line up on a single line', function() { 306 | var images = [ 307 | fakeImage(700, 10, 'first'), 308 | fakeImage(250, 10, 'second'), 309 | fakeImage(50, 10, 'third'), 310 | ]; 311 | 312 | var computedLayout = layout.performLayoutCompat(images); 313 | assert.equal(computedLayout.width, 700); 314 | assert.equal(computedLayout.height, 20); 315 | 316 | }); 317 | 318 | it('should not cascade decimal sizes to subsequent images on the x-axis', function() { 319 | var images = [ 320 | { 321 | imageResource: {id: 'weirdSize'}, 322 | path: 'weird/size', 323 | usedSizes: [ 324 | {width: 15.5, height: 15}, 325 | ], 326 | maxSize: {width: 15, height: 15}, 327 | minSpritedSize: {width: 12.345, height: 12.345}, 328 | }, 329 | { 330 | imageResource: {id: 'normalSize'}, 331 | path: 'normal/size', 332 | usedSizes: [ 333 | {width: 15, height: 15}, 334 | ], 335 | maxSize: {width: 15, height: 15}, 336 | minSpritedSize: {width: 15, height: 15}, 337 | }, 338 | ]; 339 | 340 | var computedLayout = layout.performLayoutCompat(images); 341 | assert.equal(computedLayout.images[1].x, 16); 342 | 343 | }); 344 | 345 | it('should not cascade decimal sizes to subsequent images on the y-axis', function() { 346 | var images = [ 347 | { 348 | imageResource: {id: 'weirdSize'}, 349 | path: 'weird/size', 350 | usedSizes: [ 351 | {width: 1500, height: 15.5}, 352 | ], 353 | maxSize: {width: 15, height: 15}, 354 | minSpritedSize: {width: 1200, height: 12.345}, 355 | }, 356 | { 357 | imageResource: {id: 'normalSize'}, 358 | path: 'normal/size', 359 | usedSizes: [ 360 | {width: 15, height: 15}, 361 | ], 362 | maxSize: {width: 15, height: 15}, 363 | minSpritedSize: {width: 15, height: 15}, 364 | }, 365 | ]; 366 | 367 | var computedLayout = layout.performLayoutCompat(images); 368 | assert.equal(computedLayout.images[1].y, 16); 369 | 370 | }); 371 | 372 | }); 373 | 374 | describe('getImageDirectory()', function() { 375 | 376 | var layout; 377 | var sandbox; 378 | 379 | var path = { 380 | resolve: function() {} 381 | }; 382 | var image = { 383 | fetch: function() {}, 384 | getSizeHash: function() {}, 385 | }; 386 | 387 | function fakeProcessor(images) { 388 | return { 389 | foundSprites: images, 390 | rulesets: images.map(function(img) { 391 | return { 392 | spriteData: { 393 | destHeight: 0, 394 | destWidth: 0, 395 | } 396 | }; 397 | }), 398 | }; 399 | } 400 | 401 | before(function() { 402 | mockery.enable({ 403 | warnOnUnregistered: false, 404 | useCleanCache: true 405 | }); 406 | }); 407 | 408 | beforeEach(function() { 409 | mockery.registerMock('path', path); 410 | mockery.registerMock('./image', image); 411 | 412 | layout = require('../lib/layout'); 413 | sandbox = sinon.sandbox.create(); 414 | }); 415 | 416 | afterEach(function() { 417 | mockery.resetCache(); 418 | mockery.deregisterAll(); 419 | 420 | sandbox.verifyAndRestore(); 421 | }); 422 | 423 | after(function() { 424 | mockery.disable(); 425 | }); 426 | 427 | it('should aggregate each passed processor\'s images into a single array', function() { 428 | 429 | var procs = [ 430 | fakeProcessor(['image1', 'image2']), 431 | fakeProcessor(['image3', 'image4']), 432 | ]; 433 | 434 | var pathMock = sandbox.mock(path); 435 | pathMock.expects('resolve').withArgs('source', 'image1').once().returns('rimage1'); 436 | pathMock.expects('resolve').withArgs('source', 'image2').once().returns('rimage2'); 437 | pathMock.expects('resolve').withArgs('source', 'image3').once().returns('rimage3'); 438 | pathMock.expects('resolve').withArgs('source', 'image4').once().returns('rimage4'); 439 | 440 | var imgMock = sandbox.mock(image); 441 | imgMock.expects('fetch').withArgs('rimage1').once().returns({id: 'aimage1'}); 442 | imgMock.expects('fetch').withArgs('rimage2').once().returns({id: 'aimage2'}); 443 | imgMock.expects('fetch').withArgs('rimage3').once().returns({id: 'aimage3'}); 444 | imgMock.expects('fetch').withArgs('rimage4').once().returns({id: 'aimage4'}); 445 | 446 | var results = layout.getImageDirectory(procs, 'source'); 447 | assert.deepEqual(results.map(function(res) { 448 | return res.imageResource.id; 449 | }), ['aimage1', 'aimage2', 'aimage3', 'aimage4']); 450 | 451 | }); 452 | 453 | it('should dedupe each processor\'s images', function() { 454 | 455 | var procs = [ 456 | fakeProcessor(['image1', 'image2']), 457 | fakeProcessor(['image2']), 458 | ]; 459 | 460 | var pathMock = sandbox.mock(path); 461 | pathMock.expects('resolve').withArgs('source', 'image1').once().returns('rimage1'); 462 | pathMock.expects('resolve').withArgs('source', 'image2').once().returns('rimage2'); 463 | 464 | var imgMock = sandbox.mock(image); 465 | imgMock.expects('fetch').withArgs('rimage1').once().returns({id: 'aimage1'}); 466 | imgMock.expects('fetch').withArgs('rimage2').once().returns({id: 'aimage2'}); 467 | 468 | var results = layout.getImageDirectory(procs, 'source'); 469 | assert.equal(results.length, 2); 470 | 471 | }); 472 | 473 | function testAtSize(sourceWidth, sourceHeight, destWidth, destHeight) { 474 | var procs = [ 475 | { 476 | foundSprites: ['img/foo.png'], 477 | rulesets: [ 478 | {spriteData: {destWidth: destWidth, destHeight: destHeight}}, 479 | ], 480 | }, 481 | ]; 482 | 483 | var pathMock = sandbox.mock(path); 484 | pathMock.expects('resolve').withArgs('source', 'img/foo.png').once().returns('rimage1'); 485 | 486 | var imgMock = sandbox.mock(image); 487 | imgMock.expects('fetch').withArgs('rimage1').once().returns({height: sourceHeight, width: sourceWidth}); 488 | 489 | return layout.getImageDirectory(procs, 'source'); 490 | } 491 | 492 | it('should not produce warnings for proper usage', function() { 493 | var result = testAtSize(40, 40, 20, 20); 494 | assert.ok(!result[0].warnings.length, 'There should be no warnings'); 495 | }); 496 | 497 | it('should warn about images being used at a larger size than the source', function() { 498 | var result = testAtSize(40, 40, 80, 80); 499 | assert.equal(result[0].warnings.length, 1, 'There should be one warning'); 500 | }); 501 | 502 | it('should warn about images being used at an uneven dimension', function() { 503 | var result = testAtSize(40, 40, 15, 15); 504 | assert.equal(result[0].warnings.length, 1, 'There should be one warning'); 505 | }); 506 | 507 | it('should output a single image with the max resolution when duplicate sprite is defined', function() { 508 | 509 | var procs = [ 510 | { 511 | foundSprites: ['img/foo.png'], 512 | rulesets: [ 513 | {spriteData: {destWidth: 20, destHeight: 20}}, 514 | ], 515 | }, 516 | { 517 | foundSprites: ['img/foo.png'], 518 | rulesets: [ 519 | {spriteData: {destWidth: 160, destHeight: 160}}, 520 | ], 521 | }, 522 | { 523 | foundSprites: ['img/foo.png'], 524 | rulesets: [ 525 | {spriteData: {destWidth: 40, destHeight: 40}}, 526 | ], 527 | }, 528 | { 529 | foundSprites: ['img/foo.png'], 530 | rulesets: [ 531 | {spriteData: {destWidth: 320, destHeight: 320}}, 532 | ], 533 | }, 534 | ]; 535 | 536 | var pathMock = sandbox.mock(path); 537 | pathMock.expects('resolve').withArgs('source', 'img/foo.png').once().returns('rimage1'); 538 | 539 | var imgMock = sandbox.mock(image); 540 | imgMock.expects('fetch').withArgs('rimage1').once().returns({height: 640, width: 640}); 541 | 542 | result = layout.getImageDirectory(procs, 'source'); 543 | 544 | assert.ok(result.length === 1, 'There should be a single sprite image'); 545 | }); 546 | 547 | }); 548 | 549 | }); 550 | --------------------------------------------------------------------------------