CSS-SpriteMaker

 view release on metacpan or  search on metacpan

lib/CSS/SpriteMaker.pm  view on Meta::CPAN

package CSS::SpriteMaker;

use strict;
use warnings;

use File::Find;
use Image::Magick;
use List::Util qw(max);

use Module::Pluggable 
    search_path => ['CSS::SpriteMaker::Layout'],
    except => qr/CSS::SpriteMaker::Layout::Utils::.*/,
    require => 1,
    inner => 0;

use POSIX qw(ceil);


=head1 NAME

CSS::SpriteMaker - Combine several images into a single CSS sprite

=head1 VERSION

Version 1.01

=cut

our $VERSION = '1.01';


=head1 SYNOPSIS

    use CSS::SpriteMaker;

    my $SpriteMaker = CSS::SpriteMaker->new(
        verbose => 1, # optional

        #
        # Options that impact the lifecycle of css class name generation
        #
        # if provided will replace the default logic for creating css classnames
        # out of image filenames. This filename-to-classname is the FIRST step
        # of css classnames creation. It's safe to return invalid css characters
        # in this subroutine. They will be cleaned up internally.
        #
        rc_filename_to_classname => sub { my $filename = shift; ... } # optional

        # ... cleaning stage happens (all non css safe characters are removed)

        # This adds a prefix to all the css class names. This is called after
        # the cleaning stage internally. Don't mess with invalid CSS characters!
        #
        css_class_prefix => 'myicon-',

        # This is the last step. Change here whatever part of the final css
        # class name.
        #
        rc_override_classname => sub { my $css_class = shift; ... } # optional
    );

    $SpriteMaker->make_sprite(
        source_images  => ['/path/to/imagedir', '/images/img1.png', '/img2.png'];
        target_file => '/tmp/test/mysprite.png',
        layout_name => 'Packed',    # optional
        remove_source_padding => 1, # optional
        enable_colormap => 1,       # optional

lib/CSS/SpriteMaker.pm  view on Meta::CPAN

    );
    
Default values are set to:

=over 4

=item remove_source_padding : false,

=item verbose : false,

=item enable_colormap : false,

=item format  : png,

=item css_class_prefix : ''

=back

The parameter rc_filename_to_classname is a code reference to a function that
allow to customize the way class names are generated. This function should take
one parameter as input and return a class name

=cut

sub new {
    my $class  = shift;
    my %opts   = @_;

    # defaults
    $opts{remove_source_padding} //= 0;
    $opts{add_extra_padding}     //= 0;
    $opts{verbose}               //= 0;
    $opts{format}                //= 'png';
    $opts{layout_name}           //= 'Packed';
    $opts{css_class_prefix}      //= '';
    $opts{enable_colormap}       //= 0;
    
    my $self = {
        css_class_prefix => $opts{css_class_prefix},
        source_images => $opts{source_images},
        source_dir => $opts{source_dir},
        target_file => $opts{target_file},
        is_verbose => $opts{verbose},
        format => $opts{format},
        remove_source_padding => $opts{remove_source_padding},
        enable_colormap => $opts{enable_colormap},
        add_extra_padding => $opts{add_extra_padding},
        output_css_file => $opts{output_css_file},
        output_html_file => $opts{output_html_file},

        # layout_name is used as default
        layout => {
            name => $opts{layout_name},
            # no options by default
            options => {}
        },
        rc_filename_to_classname => $opts{rc_filename_to_classname},
        rc_override_classname => $opts{rc_override_classname},

        # the maximum color value
        color_max => 2 ** Image::Magick->QuantumDepth - 1,
    };

    return bless $self, $class;
}

=head2 compose_sprite

Compose many sprite layouts into one sprite. This is done by applying
individual layout separately, then merging the final result together using a
glue layout.

    my $is_error = $SpriteMaker->compose_sprite (
        parts => [
            { source_images => ['some/file.png', 'path/to/some_directory'],
              layout_name => 'Packed',
            },
            { source_images => ['path/to/some_directory'],
              layout => { 
                  name => 'DirectoryBased',
              }
              include_in_css => 0,        # optional
              remove_source_padding => 1, # optional (defaults to 0)
              enable_colormap => 1, # optional (defaults to 0)
              add_extra_padding     => 40, # optional, px (defaults to 0px)
            },
        ],
        # arrange the previous two layout using a glue layout
        layout => {
            name => 'FixedDimension',
            dimension => 'horizontal',
            n => 2
        }
        target_file => 'sample_sprite.png',
        format => 'png8', # optional, default is png
    );

Note the optional include_in_css option, which allows to exclude a group of
images from the CSS (still including them in the resulting image).

=cut

sub compose_sprite {
    my $self = shift;
    my %options = @_;

    if (exists $options{layout}) {
        return $self->_compose_sprite_with_glue(%options);
    }
    else {
        return $self->_compose_sprite_without_glue(%options);
    }
}

=head2 make_sprite

Creates the sprite file out of the specifed image files or directories, and
according to the given layout name.

    my $is_error = $SpriteMaker->make_sprite(
        source_images => ['some/file.png', path/to/some_directory],

lib/CSS/SpriteMaker.pm  view on Meta::CPAN

    #
    # Still no layout, check the cache!
    #
    if (!defined $Layout) {
        # try checking in the cache before giving up...
        if (exists $self->{_cache_layout} 
            && defined $self->{_cache_layout}) {
 
            $Layout = $self->{_cache_layout};
        }
    }

    #
    # Still nothing, then use default layout
    #
    if (!defined $Layout) {
        my $layout_name = $self->{layout}{name};
        my $layout_options = {};
        LOAD_DEFAULT_LAYOUT_PLUGIN:
        for my $plugin ($self->plugins()) {
            my ($plugin_name) = reverse split "::", $plugin;
            if ($plugin eq $layout_name || $plugin_name eq $layout_name) {
                $self->_verbose(" * using DEFAULT layout $plugin");
                $Layout = $plugin->new($options{rh_sources_info}, $layout_options);
                last LOAD_DEFAULT_LAYOUT_PLUGIN;
            }
        }
    }

    return $Layout;
}

sub _write_image {
    my $self    = shift;
    my %options = @_;

    my $target_file   = $options{target_file} // $self->{target_file};
    my $output_format = $options{format} // $self->{format};
    my $Layout        = $options{Layout} // 0;
    my $rh_sources_info = $options{rh_sources_info} // 0;

    if (!$target_file) {
        die "the ``target_file'' parameter is required for this subroutine or you must instantiate Css::SpriteMaker with the target_file parameter";
    }

    if (!$rh_sources_info) {
        die "The 'rh_sources_info' parameter must be passed to _write_image";
    }

    if (!$Layout) {
        die "The 'layout' parameter needs to be specified for _write_image, and must be a CSS::SpriteMaker::Layout object";
    }

    $self->_verbose(" * writing sprite image");

    $self->_verbose(sprintf("Target image size: %s, %s",
        $Layout->width(),
        $Layout->height())
    );

    my $Target = Image::Magick->new();

    $Target->Set(size => sprintf("%sx%s",
        $Layout->width(),
        $Layout->height()
    ));

    # prepare the target image
    if (my $err = $Target->ReadImage('xc:white')) {
        warn $err;
    }
    $Target->Set(type => 'TruecolorMatte');
    
    # make it transparent
    $self->_verbose(" - clearing canvas");
    $Target->Draw(
        fill => 'transparent', 
        primitive => 'rectangle', 
        points => sprintf("0,0 %s,%s", $Layout->width(), $Layout->height())
    );
    $Target->Transparent('color' => 'white');

    # place each image according to the layout
    ITEM_ID:
    for my $source_id ($Layout->get_item_ids) {
        my $rh_source_info = $rh_sources_info->{$source_id};
        my ($layout_x, $layout_y) = $Layout->get_item_coord($source_id);

        $self->_verbose(sprintf(" - placing %s (format: %s  size: %sx%s  position: [%s,%s])",
            $rh_source_info->{pathname},
            $rh_source_info->{format},
            $rh_source_info->{width},
            $rh_source_info->{height},
            $layout_y,
            $layout_x
        ));
        my $I = Image::Magick->new(); 
        my $err = $I->Read($rh_source_info->{pathname});
        if ($err) {
            warn $err;
            next ITEM_ID;
        }

        my $padding = $rh_source_info->{add_extra_padding};

        my $destx = $layout_x + $padding;
        my $desty = $layout_y + $padding;
        $Target->Composite(image=>$I,compose=>'xor',geometry=>"+$destx+$desty");
    }

    # write target image
    my $err = $Target->Write("$output_format:".$target_file);
    if ($err) {
        warn "unable to obtain $target_file for writing it as $output_format. Perhaps you have specified an invalid format. Check http://www.imagemagick.org/script/formats.php for a list of supported formats. Error: $err";

        $self->_verbose("Wrote $target_file");

        return 1;
    }

    # cache the layout and the rh_info structure so that it hasn't to be passed
    # as a parameter next times.
    $self->{_cache_layout} = $Layout;

    # cache the target image file, as making the stylesheet can't be done
    # without this information.
    $self->{_cache_target_image_file} = $target_file;

    # cache sources info
    $self->{_cache_rh_source_info} = $rh_sources_info;

    return 0;
    
}

=head2 _get_image_properties

Return an hashref of information about the image at the given pathname.

=cut

sub _get_image_properties {
    my $self       = shift;
    my $image_path = shift;
    my $remove_source_padding = shift;
    my $add_extra_padding = shift;
    my $enable_colormap = shift;

    my $Image = Image::Magick->new();

    my $err = $Image->Read($image_path);
    if ($err) {
        warn $err;
        return {};
    }

    my $rh_info = {};
    $rh_info->{first_pixel_x} = 0,
    $rh_info->{first_pixel_y} = 0,
    $rh_info->{width} = $Image->Get('columns');
    $rh_info->{height} = $Image->Get('rows');
    $rh_info->{comment} = $Image->Get('comment');
    $rh_info->{colors}{total} = $Image->Get('colors');
    $rh_info->{format} = $Image->Get('magick');

    if ($remove_source_padding) {
        #
        # Find borders for this image.
        #
        # (RE-)SET:
        # - first_pixel(x/y) as the true point the image starts
        # - width/height as the true dimensions of the image
        #
        my $w = $rh_info->{width};
        my $h = $rh_info->{height};

        # seek for left/right borders
        my $first_left = 0;
        my $first_right = $w-1;
        my $left_found = 0;
        my $right_found = 0;

        BORDER_HORIZONTAL:
        for my $x (0 .. ceil(($w-1)/2)) {
            my $xr = $w-$x-1;
            for my $y (0..$h-1) {
                my $al = $Image->Get(sprintf('pixel[%s,%s]', $x, $y));
                my $ar = $Image->Get(sprintf('pixel[%s,%s]', $xr, $y));
                
                # remove rgb info and only get alpha value
                $al =~ s/^.+,//;
                $ar =~ s/^.+,//;

                if ($al != $self->{color_max} && !$left_found) {
                    $first_left = $x;
                    $left_found = 1;
                }
                if ($ar != $self->{color_max} && !$right_found) {
                    $first_right = $xr;
                    $right_found = 1;
                }
                last BORDER_HORIZONTAL if $left_found && $right_found;
            }
        }
        $rh_info->{first_pixel_x} = $first_left;
        $rh_info->{width} = $first_right - $first_left + 1;

        # seek for top/bottom borders
        my $first_top = 0;



( run in 2.039 seconds using v1.01-cache-2.11-cpan-39bf76dae61 )