~launchpad-pqm/launchpad/devel

7675.507.19 by Edwin Grubbs
More progress.
1
# Copyright 2010 Canonical Ltd.  This software is licensed under the
7675.507.16 by Edwin Grubbs
Refactored into spriteutils.py
2
# GNU Affero General Public License version 3 (see the file LICENSE).
3
#
7675.507.10 by Edwin Grubbs
Added make_master.py
4
# Derived from make_master.py by Oran Looney.
5
# http://oranlooney.com/make-css-sprites-python-image-library/
7675.507.9 by Edwin Grubbs
Added make_master.py
6
7675.507.16 by Edwin Grubbs
Refactored into spriteutils.py
7
"""Library to create sprites."""
8
9
__metaclass__ = type
10
11
__all__ = [
12
    'SpriteUtil',
13
    ]
14
7675.507.9 by Edwin Grubbs
Added make_master.py
15
import os
11403.1.4 by Henning Eggers
Reformatted imports using format-imports script r32.
16
import re
7675.507.12 by Edwin Grubbs
Fixed pil_sprite_maker.py
17
import sys
11403.1.4 by Henning Eggers
Reformatted imports using format-imports script r32.
18
from textwrap import dedent
19
7675.507.12 by Edwin Grubbs
Fixed pil_sprite_maker.py
20
import cssutils
7675.507.9 by Edwin Grubbs
Added make_master.py
21
import Image
7675.507.17 by Edwin Grubbs
Improved spriteutils.py.
22
import simplejson
7675.507.9 by Edwin Grubbs
Added make_master.py
23
7675.507.33 by Edwin Grubbs
Addressed reviewer comments.
24
7675.507.16 by Edwin Grubbs
Refactored into spriteutils.py
25
class SpriteUtil:
7675.507.28 by Edwin Grubbs
Added warning to top of icon-sprites.positioning
26
    EDIT_WARNING = dedent("""\
27
        /* DO NOT EDIT THIS FILE BY HAND!!!    */
28
        /* It is autogenerated by spriteutils. */
29
        """)
30
7675.507.29 by Edwin Grubbs
Added docstrings and moved more code into SpriteUtil.__init__().
31
    def __init__(self, css_template_file, group_name,
32
                 url_prefix_substitutions=None, margin=150):
33
        """Initialize with the specified css template file.
34
35
        :param css_template_file: (str) Name of the file containing
36
            css rules with a background-image style that needs to be
37
            combined into the sprite file, a comment allowing sprites to
38
            be grouped into different image files, and a
39
            background-repeat style if necessary. Currently, "repeat-y"
40
            is not supported since the file is combined vertically, so
41
            repeat-y would show the entire combined image file.
42
43
            Example css template:
44
                edit-icon {
45
                    background-image: url(../edit.png)
46
                    /* sprite-ref: group1 */
47
                }
48
                blue-bar {
49
                    background-image: url(../blue-bar.png)
50
                    /* sprite-ref: group1 */
51
                    background-repeat: repeat-x
52
                }
53
54
        :param group_name: (str) Only add sprites to the
55
            combined image file whose sprite-ref comment in the
56
            css template match this group-name.
57
58
        :param url_prefix_substitutions: (dict) The css template
59
            will contain references to image files by their url
60
            path, but the filesystem path relative to the css
61
            template is needed.
62
63
        :param margin: (int) The number of pixels between each sprite.
64
            Be aware that Firefox will ignore extremely large images,
65
            for example 64x34000 pixels.
66
67
        If the css_template_file has been modified, a new
68
        css file using an existing combined image and positioning
69
        file can be generated using:
70
            sprite_util = SpriteUtil(...)
71
            sprite_util.loadPositioning(...)
72
            sprite_util.saveConvertedCSS(...)
73
74
        If a new image file needs to be added to the combined image
75
        and the positioning file, they can be regenerated with:
76
            sprite_util = SpriteUtil(...)
77
            sprite_util.combineImages(...)
78
            sprite_util.savePNG(...)
79
            sprite_util.savePositioning(...)
80
81
        If the image file is regenerated any time the css file is
82
        regenerated, then the step for saving and loading the positioning
83
        information could be removed. For example:
84
            sprite_util = SpriteUtil(...)
85
            sprite_util.combineImages(...)
86
            sprite_util.savePNG(...)
87
            sprite_util.saveConvertedCSS(...)
88
        """
7675.507.16 by Edwin Grubbs
Refactored into spriteutils.py
89
        self.combined_image = None
90
        self.positions = None
7675.507.29 by Edwin Grubbs
Added docstrings and moved more code into SpriteUtil.__init__().
91
        self.group_name = group_name
92
        self.margin = margin
93
        self._loadCSSTemplate(
94
            css_template_file, group_name, url_prefix_substitutions)
7675.507.16 by Edwin Grubbs
Refactored into spriteutils.py
95
7675.507.29 by Edwin Grubbs
Added docstrings and moved more code into SpriteUtil.__init__().
96
    def _loadCSSTemplate(self, css_template_file, group_name,
7675.507.19 by Edwin Grubbs
More progress.
97
                        url_prefix_substitutions=None):
7675.507.29 by Edwin Grubbs
Added docstrings and moved more code into SpriteUtil.__init__().
98
        """See `__init__`."""
7675.507.16 by Edwin Grubbs
Refactored into spriteutils.py
99
        smartsprites_exp = re.compile(
100
            r'/\*+([^*]*sprite-ref: [^*]*)\*/')
7675.507.29 by Edwin Grubbs
Added docstrings and moved more code into SpriteUtil.__init__().
101
        self.css_object = cssutils.parseFile(css_template_file)
7675.507.30 by Edwin Grubbs
Added tests. Moved to lp.services.
102
        self.sprite_info = []
7675.507.16 by Edwin Grubbs
Refactored into spriteutils.py
103
        for rule in self.css_object:
7675.507.17 by Edwin Grubbs
Improved spriteutils.py.
104
            if rule.cssText is None:
105
                continue
7675.507.16 by Edwin Grubbs
Refactored into spriteutils.py
106
            match = smartsprites_exp.search(rule.cssText)
107
            if match is not None:
108
                smartsprites_info = match.group(1)
7675.507.19 by Edwin Grubbs
More progress.
109
                parameters = self._parseCommentParameters(match.group(1))
7675.507.30 by Edwin Grubbs
Added tests. Moved to lp.services.
110
                # Currently, only a single combined image is supported.
7675.507.19 by Edwin Grubbs
More progress.
111
                if parameters['sprite-ref'] == group_name:
112
                    filename = self._getSpriteImagePath(
113
                        rule, url_prefix_substitutions)
10322.1.1 by Edwin Grubbs
Fixed missing .branch sprite. Fixed missing .cves class.
114
                    if filename == '':
115
                        raise AssertionError(
116
                            "Missing background-image url for %s css style"
117
                            % rule.selectorText)
7675.507.32 by Edwin Grubbs
Fixed lint error.
118
                    self.sprite_info.append(
119
                        dict(filename=filename, rule=rule))
7675.507.30 by Edwin Grubbs
Added tests. Moved to lp.services.
120
121
        if len(self.sprite_info) == 0:
122
            raise AssertionError(
123
                "No sprite-ref comments for group %r found" % group_name)
7675.507.16 by Edwin Grubbs
Refactored into spriteutils.py
124
7675.507.19 by Edwin Grubbs
More progress.
125
    def _getSpriteImagePath(self, rule, url_prefix_substitutions=None):
7675.507.29 by Edwin Grubbs
Added docstrings and moved more code into SpriteUtil.__init__().
126
        """Convert the url path to a filesystem path."""
7675.507.19 by Edwin Grubbs
More progress.
127
        # Remove url() from string.
128
        filename = rule.style.backgroundImage[4:-1]
129
        # Convert urls to paths relative to the css
130
        # file, e.g. '/@@/foo.png' => '../images/foo.png'.
131
        if url_prefix_substitutions is not None:
132
            for old, new in url_prefix_substitutions.items():
133
                if filename.startswith(old):
134
                    filename = new + filename[len(old):]
135
        return filename
136
137
    def _parseCommentParameters(self, parameter_string):
7675.507.29 by Edwin Grubbs
Added docstrings and moved more code into SpriteUtil.__init__().
138
        """Parse parameters out of javascript comments.
139
140
        Currently only used for the group name specified
141
        by "sprite-ref".
142
        """
7675.507.19 by Edwin Grubbs
More progress.
143
        results = {}
144
        for parameter in parameter_string.split(';'):
145
            if parameter.strip() != '':
146
                name, value = parameter.split(':')
147
                name = name.strip()
148
                value = value.strip()
149
                results[name] = value
150
        return results
151
7675.507.17 by Edwin Grubbs
Improved spriteutils.py.
152
    def combineImages(self, css_dir):
7675.507.29 by Edwin Grubbs
Added docstrings and moved more code into SpriteUtil.__init__().
153
        """Copy all the sprites into a single PIL image."""
7675.507.30 by Edwin Grubbs
Added tests. Moved to lp.services.
154
155
        # Open all the sprite images.
156
        sprite_images = {}
157
        max_sprite_width = 0
158
        total_sprite_height = 0
159
        for sprite in self.sprite_info:
160
            abs_filename = os.path.join(css_dir, sprite['filename'])
10322.1.1 by Edwin Grubbs
Fixed missing .branch sprite. Fixed missing .cves class.
161
            try:
162
                sprite_images[sprite['filename']] = Image.open(abs_filename)
163
            except IOError:
164
                print >> sys.stderr, "Error opening '%s' for %s css rule" % (
165
                    abs_filename, sprite['rule'].selectorText)
166
                raise
7675.507.30 by Edwin Grubbs
Added tests. Moved to lp.services.
167
            width, height = sprite_images[sprite['filename']].size
168
            max_sprite_width = max(width, max_sprite_width)
169
            total_sprite_height += height
170
171
        # The combined image is the height of all the sprites
172
        # plus the margin between each of them.
7675.507.23 by Edwin Grubbs
Completely working.
173
        combined_image_height = (
7675.507.30 by Edwin Grubbs
Added tests. Moved to lp.services.
174
            total_sprite_height + (self.margin * len(self.sprite_info) - 1))
175
        transparent = (0, 0, 0, 0)
7675.507.23 by Edwin Grubbs
Completely working.
176
        combined_image = Image.new(
7675.507.16 by Edwin Grubbs
Refactored into spriteutils.py
177
            mode='RGBA',
7675.507.30 by Edwin Grubbs
Added tests. Moved to lp.services.
178
            size=(max_sprite_width, combined_image_height),
7675.507.16 by Edwin Grubbs
Refactored into spriteutils.py
179
            color=transparent)
180
7675.507.30 by Edwin Grubbs
Added tests. Moved to lp.services.
181
        # Paste each sprite into the combined image.
7675.507.16 by Edwin Grubbs
Refactored into spriteutils.py
182
        y = 0
183
        positions = {}
7675.507.30 by Edwin Grubbs
Added tests. Moved to lp.services.
184
        for index, sprite in enumerate(self.sprite_info):
185
            sprite_image = sprite_images[sprite['filename']]
7675.507.23 by Edwin Grubbs
Completely working.
186
            try:
7675.507.26 by Edwin Grubbs
Handle repeat-x for sprites.
187
                position = [0, y]
7675.507.30 by Edwin Grubbs
Added tests. Moved to lp.services.
188
                combined_image.paste(sprite_image, tuple(position))
7675.507.26 by Edwin Grubbs
Handle repeat-x for sprites.
189
                # An icon in a vertically combined image can be repeated
190
                # horizontally, but we have to repeat it in the combined
191
                # image so that we don't repeat white space.
192
                if sprite['rule'].style.backgroundRepeat == 'repeat-x':
7675.507.30 by Edwin Grubbs
Added tests. Moved to lp.services.
193
                    width = sprite_image.size[0]
194
                    for x_position in range(width, max_sprite_width, width):
7675.507.26 by Edwin Grubbs
Handle repeat-x for sprites.
195
                        position[0] = x_position
7675.507.30 by Edwin Grubbs
Added tests. Moved to lp.services.
196
                        combined_image.paste(sprite_image, tuple(position))
7675.507.23 by Edwin Grubbs
Completely working.
197
            except:
198
                print >> sys.stderr, (
199
                    "Error with image file %s" % sprite['filename'])
200
                raise
7675.507.19 by Edwin Grubbs
More progress.
201
            # This is the position of the combined image on an HTML
202
            # element. Therefore, it subtracts the position of the
203
            # sprite in the file to move it to the top of the element.
204
            positions[sprite['filename']] = (0, -y)
7675.507.30 by Edwin Grubbs
Added tests. Moved to lp.services.
205
            y += sprite_image.size[1] + self.margin
7675.507.16 by Edwin Grubbs
Refactored into spriteutils.py
206
7675.507.30 by Edwin Grubbs
Added tests. Moved to lp.services.
207
        # If there is an exception earlier, these attributes will remain None.
7675.507.16 by Edwin Grubbs
Refactored into spriteutils.py
208
        self.positions = positions
7675.507.23 by Edwin Grubbs
Completely working.
209
        self.combined_image = combined_image
7675.507.16 by Edwin Grubbs
Refactored into spriteutils.py
210
211
    def savePNG(self, filename):
7675.507.29 by Edwin Grubbs
Added docstrings and moved more code into SpriteUtil.__init__().
212
        """Save the PIL image object to disk."""
7675.507.23 by Edwin Grubbs
Completely working.
213
        self.combined_image.save(filename, format='png', optimize=True)
7675.507.16 by Edwin Grubbs
Refactored into spriteutils.py
214
215
    def savePositioning(self, filename):
7675.507.29 by Edwin Grubbs
Added docstrings and moved more code into SpriteUtil.__init__().
216
        """Save the positions of sprites in the combined image.
217
218
        This allows the final css to be generated after making
219
        changes to the css template without recreating the combined
220
        image file.
221
        """
7675.507.28 by Edwin Grubbs
Added warning to top of icon-sprites.positioning
222
        fp = open(filename, 'w')
223
        fp.write(self.EDIT_WARNING)
224
        simplejson.dump(self.positions, fp=fp, indent=4)
7675.507.16 by Edwin Grubbs
Refactored into spriteutils.py
225
226
    def loadPositioning(self, filename):
7675.507.29 by Edwin Grubbs
Added docstrings and moved more code into SpriteUtil.__init__().
227
        """Load file with the positions of sprites in the combined image."""
7675.507.28 by Edwin Grubbs
Added warning to top of icon-sprites.positioning
228
        json = open(filename).read()
229
        # Remove comments from the beginning of the file.
230
        start = json.index('{')
231
        json = json[start:]
232
        self.positions = simplejson.loads(json)
7675.507.16 by Edwin Grubbs
Refactored into spriteutils.py
233
7675.507.29 by Edwin Grubbs
Added docstrings and moved more code into SpriteUtil.__init__().
234
    def saveConvertedCSS(self, css_file, combined_image_url_path):
235
        """Generate new css from the template and the positioning info.
236
237
        Example css template:
238
            background-image: url(../edit.png); /* sprite-ref: group1 */
239
        Example css output:
240
            background-image: url(combined_image_url_path)
241
            background-position: 0px 2344px
242
        """
7675.507.30 by Edwin Grubbs
Added tests. Moved to lp.services.
243
        for sprite in self.sprite_info:
7675.507.10 by Edwin Grubbs
Added make_master.py
244
            rule = sprite['rule']
7675.507.29 by Edwin Grubbs
Added docstrings and moved more code into SpriteUtil.__init__().
245
            rule.style.backgroundImage = 'url(%s)' % combined_image_url_path
7675.507.17 by Edwin Grubbs
Improved spriteutils.py.
246
            position = self.positions[sprite['filename']]
7675.507.18 by Edwin Grubbs
Updated.
247
            rule.style.backgroundPosition = '%dpx %dpx' % tuple(position)
7675.507.16 by Edwin Grubbs
Refactored into spriteutils.py
248
7675.507.20 by Edwin Grubbs
Generating css correctly.
249
        with open(css_file, 'w') as fp:
7675.507.28 by Edwin Grubbs
Added warning to top of icon-sprites.positioning
250
            fp.write(self.EDIT_WARNING)
7675.507.20 by Edwin Grubbs
Generating css correctly.
251
            fp.write(self.css_object.cssText)