diff --git a/frameworks/compass/stylesheets/compass/utilities/sprites/_base.scss b/frameworks/compass/stylesheets/compass/utilities/sprites/_base.scss new file mode 100644 index 00000000..42bf3b16 --- /dev/null +++ b/frameworks/compass/stylesheets/compass/utilities/sprites/_base.scss @@ -0,0 +1,29 @@ +// Set the width and height of an element to the original +// dimensions of an image before it was included in the sprite. +@mixin sprite-dimensions($sprite, $name) { + height: image-height(sprite-file($sprite, $name)); + width: image-width(sprite-file($sprite, $name)); +} + +// Set the background position of the given `$sprite` to display the +// image of the given `$name`. You can move the image relative to its +// natural position by passing `$offset-x` and `$offset-y`. +@mixin sprite-position($sprite, $name, $offset-x: 0, $offset-y: 0) { + background-position: sprite-position($sprite, $name, $offset-x, $offset-y); +} + +@mixin sprite($sprite, $name, $dimensions: false, $offset-x: 0, $offset-y: 0) { + @include sprite-position($sprite, $name, $offset-x, $offset-y); + @if $dimensions { + @include sprite-dimensions($sprite, $name); + } +} + +@mixin sprites($sprite, $sprite-names, $base-class: false, $dimensions: false) { + @each $sprite-name in $sprite-names { + .#{sprite-name($sprite)}-#{$sprite-name} { + @if $base-class { @extend #{$base-class}; } + @include sprite($sprite, $sprite-name, $dimensions); + } + } +} \ No newline at end of file diff --git a/lib/compass/sass_extensions/functions/sprites.rb b/lib/compass/sass_extensions/functions/sprites.rb index 41a7e619..7b1b6d65 100644 --- a/lib/compass/sass_extensions/functions/sprites.rb +++ b/lib/compass/sass_extensions/functions/sprites.rb @@ -1,7 +1,7 @@ require 'chunky_png' module Compass::SassExtensions::Functions::Sprites - SASS_NULL = Sass::Script::Number::new(0) + ZERO = Sass::Script::Number::new(0) # Provides a consistent interface for getting a variable in ruby # from a keyword argument hash that accounts for underscores/dash equivalence @@ -12,111 +12,253 @@ module Compass::SassExtensions::Functions::Sprites end end - def generate_sprite_image(uri, kwargs = {}) - kwargs.extend VariableReader - path, name = Compass::Sprites.path_and_name(uri.value) - last_spacing = 0 - width = 0 - height = 0 + class Sprite < Sass::Script::Literal - # Get image metadata - Compass::Sprites.discover_sprites(uri.value).each do |file| - Compass::Sprites.compute_image_metadata! file, path, name + attr_accessor :image_names, :path, :name, :options + attr_accessor :images, :width, :height + + def self.from_uri(uri, context, kwargs) + path, name = Compass::Sprites.path_and_name(uri.value) + new(Compass::Sprites.discover_sprites(uri.value), path, name, context, kwargs) end - images = Compass::Sprites.sprites(path, name) - - # Calculation - images.each do |image| - current_spacing = number_from_var(kwargs, "#{image[:name]}-spacing", 0) - if height > 0 - height += [current_spacing, last_spacing].max - end - image[:y] = height - height += image[:height] - last_spacing = current_spacing - width = image[:width] if image[:width] > width + def initialize(image_names, path, name, context, options) + @image_names, @path, @name, @options = image_names, path, name, options + @images = nil + @width = nil + @height = nil + @evaluation_context = context + validate! + compute_image_metadata! end - - # Generation - output_png = ChunkyPNG::Image.new(width, height, ChunkyPNG::Color::TRANSPARENT) - images.each do |image| - input_png = ChunkyPNG::Image.from_file(image[:file]) - - position = kwargs.get_var("#{image[:name]}-position") || Sass::Script::Number.new(0, ["%"]) - if position.unit_str == "%" - image[:x] = (width - image[:width]) * (position.value / 100) - else - image[:x] = position.value + + def sprite_names + image_names.map{|f| Compass::Sprites.sprite_name(f) } + end + + 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 - - repeat = if (var = kwargs.get_var("#{image[:name]}-repeat")) + end + + # Calculates the overal image dimensions + # collects image sizes and input parameters for each sprite + def compute_image_metadata! + @images = [] + @width = 0 + image_names.each do |file| + relative_file = file.gsub(Compass.configuration.images_path+"/", "") + width, height = Compass::SassExtensions::Functions::ImageSize::ImageProperties.new(file).size + sprite_name = Compass::Sprites.sprite_name(relative_file) + @width = [@width, width].max + @images << { + :name => sprite_name, + :file => file, + :relative_file => relative_file, + :height => height, + :width => width, + :repeat => repeat_for(sprite_name), + :spacing => spacing_for(sprite_name), + :position => position_for(sprite_name) + } + end + @images.each_with_index do |image, index| + if index == 0 + image[:top] = 0 + else + last_image = @images[index-1] + image[:top] = last_image[:top] + last_image[:height] + [image[:spacing], last_image[:spacing]].max + end + if image[:position].unit_str == "%" + image[:left] = (@width - image[:width]) * (image[:position].value / 100) + else + image[:left] = image[:position].value + end + end + @height = @images.last[:top] + @images.last[:height] + end + + def position_for(name) + options.get_var("#{name}-position") || options.get_var("position") || Sass::Script::Number.new(0, ["px"]) + end + + def repeat_for(name) + if (var = options.get_var("#{name}-repeat")) + var.value + elsif (var = options.get_var("repeat")) var.value else "no-repeat" end - if repeat == "no-repeat" - output_png.replace input_png, image[:x], image[:y] - else - x = image[:x] - (image[:x] / image[:width]).ceil * image[:width] - while x < width do - output_png.replace input_png, x, image[:y] - x += image[:width] - end + end + + def spacing_for(name) + (options.get_var("#{name}-spacing") || + options.get_var("spacing") || + ZERO).value + end + + def image_for(name) + @images.detect{|img| img[:name] == name} + end + + # Calculate the size of the sprite + def size + [width, height] + end + + # Generate a sprite image if necessary + def generate + if generation_required? + save!(construct_sprite) end end - output_png.save File.join(File.join(Compass.configuration.images_path, "#{path}.png")) - - sprite_url(uri) - end - Sass::Script::Functions.declare :generate_sprite_image, [:uri], :var_kwargs => true - - def sprite_image(uri, x_shift = SASS_NULL, y_shift = SASS_NULL, depricated_1 = nil, depricated_2 = nil) - check_spacing_deprecation uri, depricated_1, depricated_2 - url = sprite_url(uri) - position = sprite_position(uri, x_shift, y_shift) - Sass::Script::String.new("#{url} #{position}") - end - - def sprite_url(uri) - path, name = Compass::Sprites.path_and_name(uri.value) - image_url(Sass::Script::String.new("#{path}.png")) + + def generation_required? + !File.exists?(filename) || outdated? + end + + # Returns a PNG object + def construct_sprite + output_png = ChunkyPNG::Image.new(width, height, ChunkyPNG::Color::TRANSPARENT) + images.each do |image| + input_png = ChunkyPNG::Image.from_file(image[:file]) + if image[:repeat] == "no-repeat" + output_png.replace input_png, image[:left], image[:top] + else + x = image[:left] - (image[:left] / image[:width]).ceil * image[:width] + while x < width do + output_png.replace input_png, x, image[:top] + x += image[:width] + end + end + end + output_png + end + + # The on-the-disk filename of the sprite + def filename + File.join(File.join(Compass.configuration.images_path, "#{path}.png")) + end + + # saves the sprite for later retrieval + def save!(output_png) + output_png.save filename + end + + # All the full-path filenames involved in this sprite + def image_filenames + image_names.map do |image_name| + File.join(File.join(Compass.configuration.images_path, image_name)) + end + end + + # Checks whether this sprite is outdated + def outdated? + last_update = self.mtime + image_filenames.each do |image| + return true if File.mtime(image) > last_update + end + false + end + + def mtime + File.mtime(filename) + end + + def inspect + to_s + end + + def to_s(options = self.options) + sprite_url(self).value + end + + def method_missing(meth, *args, &block) + if @evaluation_context.respond_to?(meth) + @evaluation_context.send(meth, *args, &block) + else + super + end + end + end - def sprite_position(uri, x_shift = SASS_NULL, y_shift = SASS_NULL, depricated_1 = nil, depricated_2 = nil) - check_spacing_deprecation uri, depricated_1, depricated_2 - path, name = Compass::Sprites.path_and_name(uri.value) - image_name = File.basename(uri.value, '.png') - image = Compass::Sprites.sprites(path, name).detect{ |image| image[:name] == image_name } + def sprite(uri, kwargs = {}) + kwargs.extend VariableReader + Sprite.from_uri(uri, self, kwargs) + end + Sass::Script::Functions.declare :sprite, [:uri], :var_kwargs => true + + def sprite_image(sprite, image = nil, x_shift = ZERO, y_shift = ZERO) + unless sprite.is_a?(Sprite) + missing_sprite!("sprite-image") + end + unless image && image.is_a?(Sass::Script::String) + raise Sass::SyntaxError, %Q(The second argument to sprite-image must be a sprite name. See http://beta.compass-style.org/help/tutorials/spriting/ for more information.) + end + url = sprite_url(sprite) + position = sprite_position(sprite, image, x_shift, y_shift) + Sass::Script::List.new([url, position], :space) + end + + def sprite_name(sprite) + unless sprite.is_a?(Sprite) + missing_sprite!("sprite-name") + end + Sass::Script::String.new(sprite.name) + end + + def sprite_file(sprite, image_name) + unless sprite.is_a?(Sprite) + missing_sprite!("sprite-file") + end + if image = sprite.image_for(image_name.value) + Sass::Script::String.new(image[:relative_file]) + else + missing_image!(sprite, image_name) + end + end + + def sprite_url(sprite) + unless sprite.is_a?(Sprite) + missing_sprite!("sprite-url") + end + sprite.generate + image_url(Sass::Script::String.new("#{sprite.path}.png")) + end + + def missing_image!(sprite, image_name) + raise Sass::SyntaxError, "No image called #{image_name} found in sprite #{sprite.path}/#{sprite.name}. Did you mean one of: #{sprite.sprite_names.join(", ")}" + end + + def missing_sprite!(function_name) + raise Sass::SyntaxError, %Q(The first argument to #{function_name} must be a sprite. See http://beta.compass-style.org/help/tutorials/spriting/ for more information.) + end + + def sprite_position(sprite, image_name = nil, x_shift = ZERO, y_shift = ZERO) + unless sprite.is_a?(Sprite) + missing_sprite!("sprite-position") + end + unless image_name && image_name.is_a?(Sass::Script::String) + raise Sass::SyntaxError, %Q(The second argument to sprite-image must be a sprite name. See http://beta.compass-style.org/help/tutorials/spriting/ for more information.) + end + image = sprite.image_for(image_name.value) + unless image + missing_image!(sprite, image_name) + end if x_shift.unit_str == "%" - x = x_shift.to_s + x = x_shift # CE: Shouldn't this be a percentage of the total width? else - x = x_shift.value - image[:x] - x = "#{x}px" unless x == 0 + x = x_shift.value - image[:left] + x = Sass::Script::Number.new(x, x == 0 ? [] : ["px"]) end - y = y_shift.value - image[:y] - y = "#{y}px" unless y == 0 - Sass::Script::String.new("#{x} #{y}") + y = y_shift.value - image[:top] + y = Sass::Script::Number.new(y, y == 0 ? [] : ["px"]) + Sass::Script::List.new([x, y],:space) end -private - - def number_from_var(kwargs, var_name, default_value) - if number = kwargs.get_var(var_name) - assert_type number, :Number - number.value - else - default_value - end - end - - def check_spacing_deprecation(uri, spacing_before, spacing_after) - if spacing_before or spacing_after - path, name, image_name = Compass::Sprites.path_and_name(uri.value) - message = %Q(Spacing parameter is deprecated. ) + - %Q(Please add `$#{name}-#{image_name}-spacing: #{spacing_before};` ) + - %Q(before the `@import "#{path}/*.png";` statement.) - raise Compass::Error, message - end - end end diff --git a/lib/compass/sprites.rb b/lib/compass/sprites.rb index 28224a70..ed92a1a3 100644 --- a/lib/compass/sprites.rb +++ b/lib/compass/sprites.rb @@ -4,66 +4,41 @@ module Compass attr_accessor :path class << self - def reset - @@sprites = {} - end - def path_and_name(uri) if uri =~ %r{((.+/)?(.+))/(.+?)\.png} [$1, $3, $4] end end - def sprites(path, name, create = false) - if !defined?(@@sprites) || @@sprites.nil? - @@sprites = {} - end - index = "#{path}/#{name}" - images = @@sprites[index] - if images - images - elsif create - images = @@sprites[index] = [] - else - raise Compass::Error, %Q(`@import` statement missing. Please add `@import "#{path}/*.png";`.) - end - end - def discover_sprites(uri) glob = File.join(Compass.configuration.images_path, uri) Dir.glob(glob).sort end - def compute_image_metadata!(file, path, name) - width, height = Compass::SassExtensions::Functions::ImageSize::ImageProperties.new(file).size - sprite_name = File.basename(file, '.png'); - unless sprite_name =~ /\A#{Sass::SCSS::RX::IDENT}\Z/ - raise Sass::SyntaxError, "#{sprite_name} must be a legal css identifier" - end - sprites(path, name, true) << { - :name => sprite_name, - :file => file, - :height => height, - :width => width - } + def sprite_name(file) + File.basename(file, '.png') end + end - def images - Compass::Sprites.sprites(self.path, self.name, true) + def find_relative(*args) + nil end def find(uri, options) if uri =~ /\.png$/ self.path, self.name = Compass::Sprites.path_and_name(uri) options.merge! :filename => name, :syntax => :scss, :importer => self - image_names = Compass::Sprites.discover_sprites(uri).map{|i| File.basename(i, '.png')} + sprite_files = Compass::Sprites.discover_sprites(uri) + image_names = sprite_files.map {|i| Compass::Sprites.sprite_name(i) } Sass::Engine.new(content_for_images(uri, name, image_names), options) end end def content_for_images(uri, name, images) <<-SCSS +@import "compass/utilities/sprites/base"; + // General Sprite Defaults // You can override them before you import this file. $#{name}-sprite-base-class: ".#{name}-sprite" !default; @@ -82,50 +57,46 @@ $#{name}-#{sprite_name}-repeat: $#{name}-repeat !default; SCSS end.join} +$#{name}-sprite: sprite("#{uri}", +#{images.map do |sprite_name| +%Q{ $#{sprite_name}-position: $#{name}-#{sprite_name}-position, + $#{sprite_name}-spacing: $#{name}-#{sprite_name}-spacing, + $#{sprite_name}-repeat: $#{name}-#{sprite_name}-repeat} +end.join(",\n")}); + // All sprites should extend this class // The #{name}-sprite mixin will do so for you. \#{$#{name}-sprite-base-class} { - background: generate-sprite-image("#{uri}", -#{images.map do |sprite_name| -%Q{ $#{sprite_name}-position: $#{name}-#{sprite_name}-position, - $#{sprite_name}-spacing: $#{name}-#{sprite_name}-spacing, - $#{sprite_name}-repeat: $#{name}-#{sprite_name}-repeat} -end.join(",\n")}) no-repeat; + background: $#{name}-sprite no-repeat; } // Use this to set the dimensions of an element // based on the size of the original image. -@mixin #{name}-sprite-dimensions($sprite) { - height: image-height("#{name}/\#{$sprite}.png"); - width: image-width("#{name}/\#{$sprite}.png"); +@mixin #{name}-sprite-dimensions($name) { + @include sprite-dimensions($#{name}-sprite, $name) } // Move the background position to display the sprite. -@mixin #{name}-sprite-position($sprite, $x: 0, $y: 0) { - background-position: sprite-position("#{path}/\#{$sprite}.png", $x, $y); +@mixin #{name}-sprite-position($name, $offset-x: 0, $offset-y: 0) { + @include sprite-position($#{name}-sprite, $name, $offset-x, $offset-y) } // Extends the sprite base class and set the background position for the desired sprite. // It will also apply the image dimensions if $dimensions is true. -@mixin #{name}-sprite($sprite, $dimensions: $#{name}-sprite-dimensions, $x: 0, $y: 0) { +@mixin #{name}-sprite($name, $dimensions: $#{name}-sprite-dimensions, $offset-x: 0, $offset-y: 0) { @extend \#{$#{name}-sprite-base-class}; - @include #{name}-sprite-position($sprite, $x, $y); - @if $dimensions { - @include #{name}-sprite-dimensions($sprite); - } + @include sprite($#{name}-sprite, $name, $dimensions, $offset-x, $offset-y) +} + +@mixin #{name}-sprites($sprite-names, $dimensions: $#{name}-sprite-dimensions) { + @include sprites($#{name}-sprite, $sprite-names, $#{name}-sprite-base-class, $dimensions) } // Generates a class for each sprited image. -@mixin all-#{name}-sprites { -#{images.map do |sprite_name| -<<-SCSS - .#{name}-#{sprite_name} { - @include #{name}-sprite("#{sprite_name}"); - } -SCSS -end.join} +@mixin all-#{name}-sprites($dimensions: $#{name}-sprite-dimensions) { + @include #{name}-sprites(#{images.join(" ")}, $dimensions); } - SCSS +SCSS end def key(uri, options) diff --git a/spec/sprites_spec.rb b/spec/sprites_spec.rb index 8ec5edcb..36481339 100644 --- a/spec/sprites_spec.rb +++ b/spec/sprites_spec.rb @@ -10,7 +10,6 @@ describe Compass::Sprites do FileUtils.cp_r @images_src_path, @images_tmp_path Compass.configuration.images_path = @images_tmp_path Compass.configure_sass_plugin! - Compass::Sprites.reset end after :each do @@ -247,19 +246,21 @@ describe Compass::Sprites do it "should use position adjustments in functions" do css = render <<-SCSS - $squares-position: 100%; - @import "squares/*.png"; + $squares-sprite: sprite("squares/*.png", $position: 100%); + .squares-sprite { + background: $squares-sprite no-repeat; + } .adjusted-percentage { - background-position: sprite-position("squares/ten-by-ten.png", 100%); + background-position: sprite-position($squares-sprite, ten-by-ten, 100%); } .adjusted-px-1 { - background-position: sprite-position("squares/ten-by-ten.png", 4px); + background-position: sprite-position($squares-sprite, ten-by-ten, 4px); } .adjusted-px-2 { - background-position: sprite-position("squares/twenty-by-twenty.png", -3px, 2px); + background-position: sprite-position($squares-sprite, twenty-by-twenty, -3px, 2px); } SCSS css.should == <<-CSS @@ -289,15 +290,15 @@ describe Compass::Sprites do @import "squares/*.png"; .adjusted-percentage { - @include squares-sprite("ten-by-ten", $x: 100%); + @include squares-sprite("ten-by-ten", $offset-x: 100%); } .adjusted-px-1 { - @include squares-sprite("ten-by-ten", $x: 4px); + @include squares-sprite("ten-by-ten", $offset-x: 4px); } .adjusted-px-2 { - @include squares-sprite("twenty-by-twenty", $x: -3px, $y: 2px); + @include squares-sprite("twenty-by-twenty", $offset-x: -3px, $offset-y: 2px); } SCSS css.should == <<-CSS @@ -344,89 +345,46 @@ describe Compass::Sprites do image_md5('squares.png').should == '0187306f3858136feee87d3017e7f307' end - it "should use the sprite-image and sprite-url function as in lemonade" do - css = render <<-SCSS - @import "squares/*.png"; - - .squares-1 { - background: sprite-image("squares/twenty-by-twenty.png") no-repeat; - } - - .squares-2 { - background: sprite-image("squares/twenty-by-twenty.png", 100%) no-repeat; - } - - .squares-3 { - background: sprite-image("squares/twenty-by-twenty.png", -4px, 3px) no-repeat; - } - - .squares-4 { - background-image: sprite-url("squares/twenty-by-twenty.png"); - } - - .squares-5 { - background-image: sprite-url("squares/*.png"); - } - SCSS - css.should == <<-CSS - .squares-sprite { - background: url('/squares.png') no-repeat; - } - - .squares-1 { - background: url('/squares.png') 0 -10px no-repeat; - } - - .squares-2 { - background: url('/squares.png') 100% -10px no-repeat; - } - - .squares-3 { - background: url('/squares.png') -4px -7px no-repeat; - } - - .squares-4 { - background-image: url('/squares.png'); - } - - .squares-5 { - background-image: url('/squares.png'); - } - CSS - end - - it "should raise deprecation errors for lemonade's spacing syntax" do + it "should provide a nice errors for lemonade's old users" do proc do render <<-SCSS - @import "squares/*.png"; - .squares { - background: sprite-image("squares/twenty-by-twenty.png", 0, 0, 11px) no-repeat; + background: sprite-url("squares/*.png") no-repeat; } SCSS - end.should raise_error Compass::Error, - %q(Spacing parameter is deprecated. Please add `$squares-twenty-by-twenty-spacing: 11px;` before the `@import "squares/*.png";` statement.) - proc do - render <<-SCSS - @import "squares/*.png"; - - .squares { - background: sprite-position("squares/twenty-by-twenty.png", 0, 0, 11px) no-repeat; - } - SCSS - end.should raise_error Compass::Error, - %q(Spacing parameter is deprecated. Please add `$squares-twenty-by-twenty-spacing: 11px;` before the `@import "squares/*.png";` statement.) - end - - it "should raise an error if @import is missing" do + end.should raise_error Sass::SyntaxError, + %q(The first argument to sprite-url must be a sprite. See http://beta.compass-style.org/help/tutorials/spriting/ for more information.) proc do render <<-SCSS .squares { background: sprite-image("squares/twenty-by-twenty.png") no-repeat; } SCSS - end.should raise_error Compass::Error, - %q(`@import` statement missing. Please add `@import "squares/*.png";`.) + end.should raise_error Sass::SyntaxError, + %q(The first argument to sprite-image must be a sprite. See http://beta.compass-style.org/help/tutorials/spriting/ for more information.) + proc do + render <<-SCSS + @import "squares/*.png"; + + .squares { + background: sprite-position("squares/twenty-by-twenty.png") no-repeat; + } + SCSS + end.should raise_error Sass::SyntaxError, + %q(The first argument to sprite-position must be a sprite. See http://beta.compass-style.org/help/tutorials/spriting/ for more information.) + end + + it "should work even if @import is missing" do + actual_css = render <<-SCSS + .squares { + background: sprite-image(sprite("squares/*.png"), twenty-by-twenty) no-repeat; + } + SCSS + actual_css.should == <<-CSS + .squares { + background: url('/squares.png') 0 -10px no-repeat; + } + CSS end end \ No newline at end of file