Rewrite spriter to lay out sprites more intelligently.

Currently results in a ~10% decrease in sprite file size.

This required some tweaks to the way some sprites were clipped
since there's no longer a huge amount of padding around them,
these changes incidentally fix the issues with other sprites
showing up where they shouldn't when text ran too long etc.
This commit is contained in:
Neil Williams
2011-10-30 15:24:12 -07:00
parent af61995c48
commit 0a5e08b005
6 changed files with 217 additions and 125 deletions

View File

@@ -94,11 +94,11 @@ $(JSTARGETS): $(JSSOURCES)
$(main_sprite) $(static_dir)/$(main_css): $(static_dir)/css/$(main_css)
rm -f $@ # delete symlink so we don't just overwrite the old mangled file
$(PYTHON) r2/lib/contrib/nymph.py sprite-main.png $< | $(CSS_COMPRESS) > $@
$(PYTHON) r2/lib/nymph.py $< $(static_dir)/sprite-main.png | $(CSS_COMPRESS) > $@
$(compact_sprite) $(static_dir)/$(compact_css) : $(static_dir)/css/$(compact_css)
rm -f $@ # delete symlink so we don't just overwrite the old mangled file
$(PYTHON) r2/lib/contrib/nymph.py sprite-compact.png $< | $(CSS_COMPRESS) > $@
$(PYTHON) r2/lib/nymph.py $< $(static_dir)/sprite-compact.png | $(CSS_COMPRESS) > $@
$(static_dir)/%.css : $(static_dir)/css/%.css
$(CAT) $< | $(CSS_COMPRESS) > $@

View File

@@ -1,105 +0,0 @@
# The contents of this file are subject to the Common Public Attribution
# License Version 1.0. (the "License"); you may not use this file except in
# compliance with the License. You may obtain a copy of the License at
# http://code.reddit.com/LICENSE. The License is based on the Mozilla Public
# License Version 1.1, but Sections 14 and 15 have been added to cover use of
# software over a computer network and provide for limited attribution for the
# Original Developer. In addition, Exhibit A has been modified to be consistent
# with Exhibit B.
#
# Software distributed under the License is distributed on an "AS IS" basis,
# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for
# the specific language governing rights and limitations under the License.
#
# The Original Code is Reddit.
#
# The Original Developer is the Initial Developer. The Initial Developer of the
# Original Code is CondeNet, Inc.
#
# All portions of the code written by CondeNet are Copyright (c) 2006-2010
# CondeNet, Inc. All Rights Reserved.
################################################################################
import re, sys, Image, os, hashlib, StringIO
def optimize_png(fname, optimizer = "/usr/bin/env optipng"):
if os.path.exists(fname):
os.popen("%s %s" % (optimizer, fname))
return fname
class Spriter(object):
spritable = re.compile(r"background-image: *url\((.*)\) *.*/\* *SPRITE *(stretch-x)? *\*/")
def __init__(self, padding = (0, 4),
css_path = '/static/', actual_path = "r2/public/static/"):
self.images = []
self.im_lookup = {}
self.ypos = [0]
self.stretch = []
self.padding = padding
self.css_path = css_path
self.actual_path = actual_path
def _make_sprite(self, match):
path = match.group(1).strip('"')
path = re.sub("^" + self.css_path, self.actual_path, path)
stretch_x = match.group(2) == "stretch-x"
if os.path.exists(path):
if path in self.im_lookup:
i = self.im_lookup[path]
else:
im = Image.open(path)
self.images.append(im)
self.stretch.append(stretch_x)
self.im_lookup[path] = len(self.images) - 1
self.ypos.append(self.ypos[-1] + im.size[1] +
2 * self.padding[1])
i = len(self.images) - 1
return "\n".join([" background-image: url(%(sprite)s);",
" background-position: %dpx %spx;" %
(-self.padding[0], "%(pos_" + str(i) + ")s")])
return match.group(0)
def _finish(self, out_file, tmpl_string):
width = 2 * self.padding[0] + max(i.size[0] for i in self.images)
height = sum((i.size[1] + 2 * self.padding[1]) for i in self.images)
master = Image.new(mode = "RGBA", size = (width, height),
color = (0,0,0,0))
for i, image in enumerate(self.images):
if self.stretch[i]:
image = image.resize((width - self.padding[0]*2, image.size[1]))
master.paste(image,
(self.padding[0], self.padding[1] + self.ypos[i]))
f = os.path.join(self.actual_path, out_file)
master.save(f)
# optimize the file
optimize_png(f)
d = dict(('pos_' + str(i), -self.padding[1] - y)
for i, y in enumerate(self.ypos))
# md5 the final contents
with open(f) as handle:
h = hashlib.md5(handle.read()).hexdigest()
d['sprite'] = os.path.join(self.css_path, "%s?v=%s" % (out_file, h))
return tmpl_string % d
def process(self, out_file, in_css):
in_css = in_css.replace('%', '%%')
tmpl_string = self.spritable.sub(self._make_sprite, in_css)
return self._finish(out_file, tmpl_string)
def process_css(incss, out_file = 'sprite.png', css_path = "/static/"):
s = Spriter(css_path = css_path)
return s.process(out_file, open(incss, 'r').read())
if __name__ == '__main__':
import sys
print process_css(sys.argv[-1], sys.argv[-2])

View File

@@ -34,7 +34,6 @@ import os
import tempfile
from r2.lib import s3cp
from md5 import md5
from r2.lib.contrib.nymph import optimize_png
from r2.lib.media import upload_media

View File

@@ -29,7 +29,7 @@ from r2.lib.utils import TimeoutFunction, TimeoutFunctionException
from r2.lib.db.operators import desc
from r2.lib.scraper import make_scraper, str_to_image, image_to_str, prepare_image
from r2.lib import amqp
from r2.lib.contrib.nymph import optimize_png
from r2.lib.nymph import optimize_png
import Image

176
r2/r2/lib/nymph.py Normal file
View File

@@ -0,0 +1,176 @@
# The contents of this file are subject to the Common Public Attribution
# License Version 1.0. (the "License"); you may not use this file except in
# compliance with the License. You may obtain a copy of the License at
# http://code.reddit.com/LICENSE. The License is based on the Mozilla Public
# License Version 1.1, but Sections 14 and 15 have been added to cover use of
# software over a computer network and provide for limited attribution for the
# Original Developer. In addition, Exhibit A has been modified to be consistent
# with Exhibit B.
#
# Software distributed under the License is distributed on an "AS IS" basis,
# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for
# the specific language governing rights and limitations under the License.
#
# The Original Code is Reddit.
#
# The Original Developer is the Initial Developer. The Initial Developer of the
# Original Code is CondeNet, Inc.
#
# All portions of the code written by CondeNet are Copyright (c) 2006-2010
# CondeNet, Inc. All Rights Reserved.
################################################################################
import os
import re
import Image
import subprocess
from r2.lib.static import generate_static_name
sprite_line = re.compile(r"background-image: *url\((.*)\) *.*/\* *SPRITE *(stretch-x)? *\*/")
def optimize_png(filename):
with open(os.path.devnull, 'w') as devnull:
subprocess.check_call(['/usr/bin/env', 'optipng', filename], stdout=devnull)
def _extract_css_info(match):
image_filename, properties = match.groups('')
image_filename = image_filename.strip('"\'')
should_stretch = (properties == 'stretch-x')
return image_filename, should_stretch
class SpritableImage(object):
def __init__(self, base_dir, filename, should_stretch=False):
self.filename = filename
self.stretch = should_stretch
self.image = Image.open(os.path.join(base_dir, filename))
@property
def width(self):
return self.image.size[0]
@property
def height(self):
return self.image.size[1]
def stretch_to_width(self, width):
self.image = self.image.resize((width, self.height))
class SpriteBin(object):
def __init__(self, bounding_box):
# the bounding box is a tuple of
# top-left-x, top-left-y, bottom-right-x, bottom-right-y
self.bounding_box = bounding_box
self.offset = 0
self.height = bounding_box[3] - bounding_box[1]
def has_space_for(self, image):
return (self.offset + image.width <= self.bounding_box[2] and
self.height >= image.height)
def add_image(self, image):
image.sprite_location = (self.offset, self.bounding_box[1])
self.offset += image.width
def _load_spritable_images(css_filename):
css_location = os.path.dirname(os.path.abspath(css_filename))
images = {}
with open(css_filename, 'r') as f:
for line in f:
m = sprite_line.search(line)
if not m:
continue
image_filename, should_stretch = _extract_css_info(m)
if image_filename not in images:
images[image_filename] = SpritableImage(css_location, image_filename, should_stretch)
else:
assert images[image_filename].stretch == should_stretch
return images.values()
def _generate_sprite(images, sprite_path):
sprite_width = max(i.width for i in images)
sprite_height = 0
# put all the max-width and stretch-x images together at the top
small_images = []
for image in images:
if image.width == sprite_width or image.stretch:
if image.stretch:
image.stretch_to_width(sprite_width)
image.sprite_location = (0, sprite_height)
sprite_height += image.height
else:
small_images.append(image)
# lay out the remaining images -- done with a greedy algorithm
small_images.sort(key=lambda i: i.height, reverse=True)
bins = []
for image in small_images:
# find a bin to fit in
for bin in bins:
if bin.has_space_for(image):
break
else:
# or give up and create a new bin
bin = SpriteBin((0, sprite_height, sprite_width, sprite_height + image.height))
sprite_height += image.height
bins.append(bin)
bin.add_image(image)
# generate the image
sprite_dimensions = (sprite_width, sprite_height)
background_color = (255, 255, 255, 0) # transparent "white"
sprite = Image.new('RGBA', sprite_dimensions, background_color)
for image in images:
sprite.paste(image.image, image.sprite_location)
sprite.save(sprite_path, optimize=True)
optimize_png(sprite_path)
# give back the mangled name
sprite_base, sprite_name = os.path.split(sprite_path)
return generate_static_name(sprite_name, base=sprite_base)
def _rewrite_css(css_filename, sprite_path, images):
# map filenames to coordinates
locations = {}
for image in images:
locations[image.filename] = image.sprite_location
def rewrite_sprite_reference(match):
image_filename, should_stretch = _extract_css_info(match)
position = locations[image_filename]
return ''.join((
'background-image: url(%s);' % sprite_path,
'background-position: -%dpx -%dpx;' % position
))
# read in the css and replace sprite references
with open(css_filename, 'r') as f:
css = f.read()
return sprite_line.sub(rewrite_sprite_reference, css)
def spritify(css_filename, sprite_path):
images = _load_spritable_images(css_filename)
sprite_path = _generate_sprite(images, sprite_path)
return _rewrite_css(css_filename, sprite_path, images)
if __name__ == '__main__':
import sys
print spritify(sys.argv[1], sys.argv[2])

View File

@@ -464,12 +464,12 @@ ul.flat-vert {text-align: left;}
width: 40px;
}
.sidebox.create .spacer {
.sidebox.create .spacer a {
background-image: url(../create-a-reddit.png); /* SPRITE */
background-repeat:no-repeat;
}
.sidebox.gold .spacer {
.sidebox.gold .spacer a {
background-image: url(../reddit_gold-40.png); /* SPRITE */
background-repeat:no-repeat;
}
@@ -4185,16 +4185,36 @@ dd { margin-left: 20px; }
.titlebox form.toggle {
margin: 0;
padding: 5px 0px 5px 20px;
padding: 5px 0px;
font-size: smaller;
color: gray;
background: white none no-repeat scroll center left;
}
.titlebox form.leavemoderator-button {
.titlebox form.leavemoderator-button:before,
.titlebox form.leavecontributor-button:before,
.icon-menu .reddit-edit:before,
.icon-menu .reddit-traffic:before,
.icon-menu .reddit-reported:before,
.icon-menu .reddit-spam:before,
.icon-menu .reddit-ban:before,
.icon-menu .reddit-flair:before,
.icon-menu .reddit-moderators:before,
.icon-menu .moderator-mail:before,
.icon-menu .reddit-contributors:before {
height: 16px;
width: 16px;
display: block;
content: " ";
float: left;
margin-right: 5px;
}
.titlebox form.leavemoderator-button:before {
background-image: url(../shield.png); /* SPRITE */
}
.titlebox form.leavecontributor-button {
.titlebox form.leavecontributor-button:before {
background-image: url(../pencil.png); /* SPRITE */
}
@@ -4207,39 +4227,41 @@ dd { margin-left: 20px; }
}
.icon-menu a {
padding-left: 20px;
background: white none no-repeat scroll center left;
}
.icon-menu li {margin: 5px 0;}
.icon-menu .reddit-edit {
.icon-menu .reddit-edit:before {
background-image: url(../reddit_edit.png); /* SPRITE */
}
.icon-menu .reddit-traffic {
.icon-menu .reddit-traffic:before {
background-image: url(../reddit_traffic.png); /* SPRITE */
}
.icon-menu .reddit-reported {
.icon-menu .reddit-reported:before {
background-image: url(../reddit_reported.png); /* SPRITE */
}
.icon-menu .reddit-spam {
.icon-menu .reddit-spam:before {
background-image: url(../reddit_spam.png); /* SPRITE */
}
.icon-menu .reddit-ban {
.icon-menu .reddit-ban:before {
background-image: url(../reddit_ban.png); /* SPRITE */
}
.icon-menu .reddit-flair {
.icon-menu .reddit-flair:before {
background-image: url(../reddit_flair.png); /* SPRITE */
/* Work around a centering difference between this icon and reddit_ban.png */
margin-left: 1px;
padding-left: 19px;
}
.icon-menu .reddit-moderators {
.icon-menu .reddit-moderators:before {
background-image: url(../shield.png); /* SPRITE */
}
.icon-menu .moderator-mail {
.icon-menu .moderator-mail:before {
background-image: url(../mailgray.png); /* SPRITE */
width: 15px;
height: 10px;
margin-top: 4px;
margin-left: 1px;
}
.icon-menu .reddit-contributors {
.icon-menu .reddit-contributors:before {
background-image: url(../pencil.png); /* SPRITE */
}