Compare commits

...

12 Commits

Author SHA1 Message Date
Scott Davis
cfd7dd3cf3 clean up 2011-06-09 22:09:13 -04:00
Scott Davis
d1ec112b99 bad mock! 2011-06-09 22:06:02 -04:00
Scott Davis
ba2b608f73 Merge branch 'master' of github.com:chriseppstein/compass into better_sprite_packing
Conflicts:
	Gemfile.lock
2011-06-09 21:56:45 -04:00
Scott Davis
387bf3665a merge 2011-06-07 12:01:50 -04:00
Scott Davis
7794b7202f tests and fitter cleanup 2011-05-17 15:46:45 -04:00
Scott Davis
fc034092aa refactored and extracted sprite methods into modules 2011-05-17 15:09:15 -04:00
Scott Davis
bf0662cf87 smart packing inital implimentation 2011-05-17 12:36:39 -04:00
John Bintz
aa4999370f even more cleanup 2011-05-16 11:50:01 -04:00
John Bintz
9eeff5a9fe tweak 2011-05-16 11:35:41 -04:00
John Bintz
370044ba77 put in the row fitter 2011-05-16 11:35:00 -04:00
John Bintz
cc26b98909 start work on better packing algorithm, start with scan 2011-05-16 11:30:14 -04:00
Scott Davis
d9ca08f685 test files 2011-05-16 09:36:27 -04:00
21 changed files with 608 additions and 47 deletions

View File

@ -1,13 +1,13 @@
GIT
remote: git://github.com/johnbintz/fakefs.git
revision: 005ddaaeb2b2881391c31ac9846a55ce5a42c206
revision: 7363b6f13bfcd9f583bbf7cd1e0d65c2dc656db7
specs:
fakefs (0.3.1)
PATH
remote: .
specs:
compass (0.11.1.f248c22)
compass (0.11.1.387bf36)
chunky_png (~> 1.1)
fssm (>= 0.2.7)
sass (~> 3.1)
@ -17,12 +17,12 @@ GEM
specs:
ZenTest (4.5.0)
abstract (1.0.0)
actionmailer (3.0.7)
actionpack (= 3.0.7)
mail (~> 2.2.15)
actionpack (3.0.7)
activemodel (= 3.0.7)
activesupport (= 3.0.7)
actionmailer (3.0.9.rc3)
actionpack (= 3.0.9.rc3)
mail (~> 2.2.19)
actionpack (3.0.9.rc3)
activemodel (= 3.0.9.rc3)
activesupport (= 3.0.9.rc3)
builder (~> 2.1.2)
erubis (~> 2.6.6)
i18n (~> 0.5.0)
@ -30,21 +30,21 @@ GEM
rack-mount (~> 0.6.14)
rack-test (~> 0.5.7)
tzinfo (~> 0.3.23)
activemodel (3.0.7)
activesupport (= 3.0.7)
activemodel (3.0.9.rc3)
activesupport (= 3.0.9.rc3)
builder (~> 2.1.2)
i18n (~> 0.5.0)
activerecord (3.0.7)
activemodel (= 3.0.7)
activesupport (= 3.0.7)
arel (~> 2.0.2)
activerecord (3.0.9.rc3)
activemodel (= 3.0.9.rc3)
activesupport (= 3.0.9.rc3)
arel (~> 2.0.10)
tzinfo (~> 0.3.23)
activeresource (3.0.7)
activemodel (= 3.0.7)
activesupport (= 3.0.7)
activesupport (3.0.7)
addressable (2.2.5)
arel (2.0.9)
activeresource (3.0.9.rc3)
activemodel (= 3.0.9.rc3)
activesupport (= 3.0.9.rc3)
activesupport (3.0.9.rc3)
addressable (2.2.6)
arel (2.0.10)
autotest (4.4.6)
ZenTest (>= 4.4.1)
autotest-fsevent (0.2.5)
@ -61,7 +61,7 @@ GEM
term-ansicolor (~> 1.0.5)
diff-lcs (1.1.2)
em-dir-watcher (0.9.4)
em-websocket (0.2.1)
em-websocket (0.3.0)
addressable (>= 2.1.1)
eventmachine (>= 0.12.9)
erubis (2.6.6)
@ -71,14 +71,14 @@ GEM
gherkin (2.2.9)
json (~> 1.4.6)
term-ansicolor (~> 1.0.5)
haml (3.1.1)
haml (3.1.2)
i18n (0.5.0)
json (1.4.6)
livereload (1.6)
em-dir-watcher (>= 0.1)
em-websocket (>= 0.1.2)
ruby-json (>= 1.1.2)
mail (2.2.17)
mail (2.2.19)
activesupport (>= 2.3.6)
i18n (>= 0.4.0)
mime-types (~> 1.16)
@ -86,27 +86,29 @@ GEM
mime-types (1.16)
mocha (0.9.12)
polyglot (0.3.1)
rack (1.2.2)
rack (1.2.3)
rack-mount (0.6.14)
rack (>= 1.0.0)
rack-test (0.5.7)
rack (>= 1.0)
rails (3.0.7)
actionmailer (= 3.0.7)
actionpack (= 3.0.7)
activerecord (= 3.0.7)
activeresource (= 3.0.7)
activesupport (= 3.0.7)
rails (3.0.9.rc3)
actionmailer (= 3.0.9.rc3)
actionpack (= 3.0.9.rc3)
activerecord (= 3.0.9.rc3)
activeresource (= 3.0.9.rc3)
activesupport (= 3.0.9.rc3)
bundler (~> 1.0)
railties (= 3.0.7)
railties (3.0.7)
actionpack (= 3.0.7)
activesupport (= 3.0.7)
railties (= 3.0.9.rc3)
railties (3.0.9.rc3)
actionpack (= 3.0.9.rc3)
activesupport (= 3.0.9.rc3)
rake (>= 0.8.7)
rdoc (~> 3.4)
thor (~> 0.14.4)
rake (0.8.7)
rake (0.9.2)
rb-fsevent (0.4.0)
rcov (0.9.9)
rdoc (3.6.1)
rspec (2.0.1)
rspec-core (~> 2.0.1)
rspec-expectations (~> 2.0.1)
@ -118,16 +120,16 @@ GEM
rspec-core (~> 2.0.1)
rspec-expectations (~> 2.0.1)
ruby-json (1.1.2)
ruby-prof (0.10.5)
ruby-prof (0.10.7)
rubyzip (0.9.4)
sass (3.1.1)
sass (3.1.2)
sys-uname (0.8.5)
term-ansicolor (1.0.5)
thor (0.14.6)
timecop (0.3.5)
treetop (1.4.9)
polyglot (>= 0.3.1)
tzinfo (0.3.26)
tzinfo (0.3.27)
PLATFORMS
ruby

View File

@ -1,6 +1,7 @@
require 'digest/md5'
require 'compass/sprite_importer'
module Compass
module SassExtensions
module Sprites
@ -8,7 +9,13 @@ module Compass
end
end
require 'compass/sass_extensions/sprites/image'
#modules
require 'compass/sass_extensions/sprites/sprite'
require 'compass/sass_extensions/sprites/processing'
require 'compass/sass_extensions/sprites/image_helper'
#classes
require 'compass/sass_extensions/sprites/sprite_map'
require 'compass/sass_extensions/sprites/engines'
require 'compass/sass_extensions/sprites/image'
require 'compass/sass_extensions/sprites/row_fitter'
require 'compass/sass_extensions/sprites/image_row'
require 'compass/sass_extensions/sprites/engines'

View File

@ -0,0 +1,74 @@
module Compass
module SassExtensions
module Sprites
class Base < Sass::Script::Literal
attr_accessor :image_names, :path, :name, :map, :kwargs
attr_accessor :images, :width, :height
include Sprite
include Processing
include ImageHelper
# Initialize a new aprite object from a relative file path
# the path is relative to the <tt>images_path</tt> confguration option
def self.from_uri(uri, context, kwargs)
sprite_map = ::Compass::SpriteMap.new(:uri => uri.value, :options => {})
sprites = sprite_map.files.map do |sprite|
sprite.gsub(Compass.configuration.images_path+"/", "")
end
new(sprites, sprite_map, context, kwargs)
end
def initialize(sprites, sprite_map, context, kwargs)
require_engine!
@image_names = sprites
@path = sprite_map.path
@name = sprite_map.name
@kwargs = kwargs
@kwargs['cleanup'] ||= Sass::Script::Bool.new(true)
@kwargs['smart_pack'] ||= Sass::Script::Bool.new(false)
@images = nil
@width = nil
@height = nil
@evaluation_context = context
@map = sprite_map
validate!
compute_image_metadata!
end
# Loads the sprite engine
def require_engine!
self.class.send(:include, eval("::Compass::SassExtensions::Sprites::#{modulize}Engine"))
end
def inspect
to_s
end
def to_s(kwargs = self.kwargs)
sprite_url(self).value
end
def respond_to?(meth)
super || @evaluation_context.respond_to?(meth)
end
def method_missing(meth, *args, &block)
if @evaluation_context.respond_to?(meth)
@evaluation_context.send(meth, *args, &block)
else
super
end
end
private
def modulize
@modulize ||= Compass::configuration.sprite_engine.to_s.scan(/([^_.]+)/).flatten.map {|chunk| "#{chunk[0].chr.upcase}#{chunk[1..-1]}" }.join
end
end
end
end
end

View File

@ -107,7 +107,10 @@ module Compass
base.image_for($1)
end
end
def <=>(other)
other.width <=> self.width
end
private
def dimensions

View File

@ -0,0 +1,32 @@
module Compass
module SassExtensions
module Sprites
module ImageHelper
# Fetches the Sprite::Image object for the supplied name
def image_for(name)
@images.detect { |img| img.name == name}
end
# Returns true if the image name has a hover selector image
def has_hover?(name)
!image_for("#{name}_hover").nil?
end
# Returns true if the image name has a target selector image
def has_target?(name)
!image_for("#{name}_target").nil?
end
# Returns true if the image name has an active selector image
def has_active?(name)
!image_for("#{name}_active").nil?
end
# Return and array of image names that make up this sprite
def sprite_names
image_names.map { |f| File.basename(f, '.png') }
end
end
end
end
end

View File

@ -0,0 +1,48 @@
require 'forwardable'
module Compass
module SassExtensions
module Sprites
class ImageRow
extend Forwardable
attr_reader :images, :max_width
def_delegators :@images, :last, :delete, :empty?, :length
def initialize(max_width)
@images = []
@max_width = max_width
end
def add(image)
return false if !will_fit?(image)
@images << image
true
end
alias :<< :add
def height
images.map(&:height).max
end
def width
images.map(&:width).max
end
def total_width
images.inject(0) {|sum, img| sum + img.width }
end
def efficiency
1 - (total_width.to_f / max_width.to_f)
end
def will_fit?(image)
(total_width + image.width) <= max_width
end
end
end
end
end

View File

@ -0,0 +1,32 @@
module Compass
module SassExtensions
module Sprites
module Processing
def smart_packing
fitter = ::Compass::SassExtensions::Sprites::RowFitter.new(@images)
current_y = 0
fitter.fit!.each do |row|
current_x = 0
row.images.each_with_index do |image, index|
image.left = current_x
image.top = current_y
current_x += image.width
end
current_y += row.height
end
end
def legacy_packing
@images.each_with_index do |image, index|
image.left = image.position.unit_str == "%" ? (@width - image.width) * (image.position.value / 100) : image.position.value
next if index == 0
last_image = @images[index-1]
image.top = last_image.top + last_image.height + [image.spacing, last_image.spacing].max
last_image = image
end
end
end
end
end
end

View File

@ -0,0 +1,83 @@
require 'forwardable'
module Compass
module SassExtensions
module Sprites
class RowFitter
extend Forwardable
attr_reader :images, :rows
def_delegators :rows, :[]
def initialize(images)
@images = images.sort
@rows = []
end
def fit!(style = :scan)
send("#{style}_fit")
@rows
end
def width
@width ||= @images.collect(&:width).max
end
def efficiency
@rows.inject(0) { |sum, row| sum += row.efficiency } ** @rows.length
end
private
def new_row(image = nil)
row = Compass::SassExtensions::Sprites::ImageRow.new(width)
row.add(image) if image
row
end
def fast_fit
row = new_row
@images.each do |image|
if !row.add(image)
@rows << row
row = new_row(image)
end
end
@rows << row
end
def scan_fit
fast_fit
moved_images = []
begin
removed = false
catch :done do
@rows.each do |row|
(@rows - [ row ]).each do |other_row|
other_row.images.each do |image|
if !moved_images.include?(image)
if row.will_fit?(image)
other_row.delete(image)
row << image
@rows.delete(other_row) if other_row.empty?
removed = true
moved_images << image
throw :done
end
end
end
end
end
end
end while removed
end
end
end
end
end

View File

@ -0,0 +1,125 @@
module Compass
module SassExtensions
module Sprites
module Sprite
# Changing this string will invalidate all previously generated sprite images.
# We should do so only when the packing algorithm changes
SPRITE_VERSION = "1"
# Calculates the overal image dimensions
# collects image sizes and input parameters for each sprite
# Calculates the height
def compute_image_metadata!
@width = 0
init_images
compute_image_positions!
@height = @images.last.top + @images.last.height
end
# Creates the Sprite::Image objects for each image and calculates the width
def init_images
@images = image_names.collect do |relative_file|
image = Compass::SassExtensions::Sprites::Image.new(self, relative_file, kwargs)
@width = [ @width, image.width + image.offset ].max
image
end
end
# Calculates the overal image dimensions
# collects image sizes and input parameters for each sprite
def compute_image_positions!
if kwargs.get_var('smart-pack').value
smart_packing
else
legacy_packing
end
end
# Validates that the sprite_names are valid sass
def validate!
for sprite_name in sprite_names
unless sprite_name =~ /\A#{Sass::SCSS::RX::IDENT}\Z/
raise Sass::SyntaxError, "#{sprite_name} must be a legal css identifier"
end
end
end
# The on-the-disk filename of the sprite
def filename
File.join(Compass.configuration.images_path, "#{path}-#{uniqueness_hash}.png")
end
# Generate a sprite image if necessary
def generate
if generation_required?
if kwargs.get_var('cleanup').value
cleanup_old_sprites
end
sprite_data = construct_sprite
save!(sprite_data)
Compass.configuration.run_callback(:sprite_generated, sprite_data)
end
end
def cleanup_old_sprites
Dir[File.join(Compass.configuration.images_path, "#{path}-*.png")].each do |file|
FileUtils.rm file
end
end
# Does this sprite need to be generated
def generation_required?
!File.exists?(filename) || outdated?
end
# Returns the uniqueness hash for this sprite object
def uniqueness_hash
@uniqueness_hash ||= begin
sum = Digest::MD5.new
sum << SPRITE_VERSION
sum << path
images.each do |image|
[:relative_file, :height, :width, :repeat, :spacing, :position, :digest].each do |attr|
sum << image.send(attr).to_s
end
end
sum.hexdigest[0...10]
end
@uniqueness_hash
end
# Saves the sprite engine
def save!(output_png)
saved = output_png.save filename
Compass.configuration.run_callback(:sprite_saved, filename)
saved
end
# All the full-path filenames involved in this sprite
def image_filenames
@images.map(&:file)
end
# Checks whether this sprite is outdated
def outdated?
if File.exists?(filename)
return @images.map(&:mtime).any? { |imtime| imtime.to_i > self.mtime.to_i }
end
true
end
# Mtime of the sprite file
def mtime
@mtime ||= File.mtime(filename)
end
# Calculate the size of the sprite
def size
[width, height]
end
end
end
end
end

7
test.watchr Normal file
View File

@ -0,0 +1,7 @@
watch('test/(.*)_test\.rb') { |m| test(m[0]) }
watch('lib/compass/sass_extensions/sprites/image_group.rb') { test('test/units/sprites/image_group_test.rb') }
watch('lib/compass/sass_extensions/sprites/row_fitter.rb') { test('test/units/sprites/row_fitter_test.rb') }
def test(file = nil)
system %{ruby -I"lib:test" #{file}}.tap { |o| puts o }
end

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@ -604,4 +604,37 @@ class SpritesTest < Test::Unit::TestCase
CSS
end
end
it "should generate a sprite using smart packing" do
css = render <<-SCSS
$image_row-smart-pack: true;
@import "image_row/*.png";
@include all-image_row-sprites;
SCSS
assert_correct css, <<-CSS
.image_row-sprite, .image_row-large, .image_row-large_square, .image_row-medium, .image_row-small, .image_row-tall {
background: url('/image_row-7738758b32.png') no-repeat;
}
.image_row-large {
background-position: 0 0;
}
.image_row-large_square {
background-position: -100px -20px;
}
.image_row-medium {
background-position: 0 -20px;
}
.image_row-small {
background-position: -140px -20px;
}
.image_row-tall {
background-position: -160px -20px;
}
CSS
end
end

View File

@ -21,7 +21,7 @@ require 'rubygems' if need_gems
require 'compass'
require 'test/unit'
require 'mocha'
%w(command_line diff io rails test_case).each do |helper|
require "helpers/#{helper}"
@ -34,4 +34,4 @@ class Test::Unit::TestCase
include Compass::IoHelper
extend Compass::TestCaseHelper::ClassMethods
end
end

View File

@ -0,0 +1,51 @@
require 'test_helper'
class ImageRowTest < Test::Unit::TestCase
def setup
@filenames = %w(large.png large_square.png medium.png tall.png small.png)
Compass.configuration.stubs(:images_path).returns('/')
@images_src_path = File.join(File.dirname(__FILE__), '..', '..', 'fixtures', 'sprites', 'public', 'images')
@image_files = Dir["#{@images_src_path}/image_row/*.png"].sort
@images = @image_files.map do |img|
Compass::SassExtensions::Sprites::Image.new(nil, img, {})
end
image_row(1000)
end
def image_row(max)
@image_row = Compass::SassExtensions::Sprites::ImageRow.new(max)
end
def populate_row
@images.each do |image|
assert @image_row.add(image)
end
end
it "should return false if image will not fit in row" do
image_row(100)
img = Compass::SassExtensions::Sprites::Image.new(nil, File.join(@images_src_path, 'image_row', 'large.png'), {})
assert !@image_row.add(img)
end
it "should have 5 images" do
populate_row
assert_equal 5, @image_row.images.size
end
it "should return max image width" do
populate_row
assert_equal 400, @image_row.width
end
it "should return max image height" do
populate_row
assert_equal 40, @image_row.height
end
it "should have an efficiency rating" do
populate_row
assert_equal 1 - (580.0 / 1000.0), @image_row.efficiency
end
end

View File

@ -3,7 +3,6 @@ require 'mocha'
require 'ostruct'
class SpritesImageTest < Test::Unit::TestCase
def setup
@images_src_path = File.join(File.dirname(__FILE__), '..', '..', 'fixtures', 'sprites', 'public', 'images')
file = StringIO.new("images_path = #{@images_src_path.inspect}\n")
@ -30,6 +29,7 @@ class SpritesImageTest < Test::Unit::TestCase
options.stubs(:get_var).with("#{sprite_name}-repeat").returns(::OpenStruct.new(:value => @repeat))
options.stubs(:get_var).with("#{sprite_name}-spacing").returns(::OpenStruct.new(:value => @spacing))
options.stubs(:get_var).with("#{sprite_name}-position").returns(::OpenStruct.new(:value => @position))
options.stubs(:get_var).with('smart_pack').returns(Sass::Script::Bool.new(false))
options
end

View File

@ -0,0 +1,64 @@
require 'test_helper'
require 'compass/sass_extensions/sprites/row_fitter'
class RowFitterTest < Test::Unit::TestCase
def setup
end
def row_fitter(images = nil)
@row_fitter ||= Compass::SassExtensions::Sprites::RowFitter.new(images)
end
def teardown
@row_fitter = nil
end
def create_images(dims)
dims.collect { |width, height|
image = Compass::SassExtensions::Sprites::Image.new('blah', 'blah', {})
image.stubs(:width => width, :height => height)
image
}
end
def basic_dims
[
[ 100, 10 ],
[ 80, 10 ],
[ 50, 10 ],
[ 35, 10 ],
[ 20, 10 ]
]
end
it 'should use the fast placement algorithm' do
images = create_images(basic_dims)
row_fitter(images)
assert_equal 100, row_fitter.width
row_fitter.fit!(:fast)
assert_equal 4, row_fitter.rows.length
assert_equal [ images[0] ], row_fitter[0].images
assert_equal [ images[1] ], row_fitter[1].images
assert_equal [ images[2], images[3] ], row_fitter[2].images
assert_equal [ images[4] ], row_fitter[3].images
end
it 'should use the scan placement algorithm' do
images = create_images(basic_dims)
row_fitter(images)
row_fitter.fit!(:scan)
assert_equal 3, row_fitter.rows.length
assert_equal [ images[0] ], row_fitter[0].images
assert_equal [ images[1], images[4] ], row_fitter[1].images
assert_equal [ images[2], images[3] ], row_fitter[2].images
end
end

View File

@ -11,7 +11,7 @@ class SpriteMapTest < Test::Unit::TestCase
config.images_path = @images_tmp_path
Compass.add_configuration(config)
Compass.configure_sass_plugin!
@options = {'cleanup' => Sass::Script::Bool.new(true)}
@options = {'cleanup' => Sass::Script::Bool.new(true), 'smart_pack' => Sass::Script::Bool.new(false)}
setup_map
end