From 0a5e08b0054b92563e2cfb6d515eef3cbe370a2c Mon Sep 17 00:00:00 2001 From: Neil Williams Date: Sun, 30 Oct 2011 15:24:12 -0700 Subject: [PATCH] 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. --- r2/Makefile | 4 +- r2/r2/lib/contrib/nymph.py | 105 ----------------- r2/r2/lib/cssfilter.py | 1 - r2/r2/lib/media.py | 2 +- r2/r2/lib/nymph.py | 176 +++++++++++++++++++++++++++++ r2/r2/public/static/css/reddit.css | 54 ++++++--- 6 files changed, 217 insertions(+), 125 deletions(-) delete mode 100644 r2/r2/lib/contrib/nymph.py create mode 100644 r2/r2/lib/nymph.py diff --git a/r2/Makefile b/r2/Makefile index b7f40fce6..020ad002e 100644 --- a/r2/Makefile +++ b/r2/Makefile @@ -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) > $@ diff --git a/r2/r2/lib/contrib/nymph.py b/r2/r2/lib/contrib/nymph.py deleted file mode 100644 index 39c722a49..000000000 --- a/r2/r2/lib/contrib/nymph.py +++ /dev/null @@ -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]) diff --git a/r2/r2/lib/cssfilter.py b/r2/r2/lib/cssfilter.py index 6a9e74662..cb257f0b1 100644 --- a/r2/r2/lib/cssfilter.py +++ b/r2/r2/lib/cssfilter.py @@ -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 diff --git a/r2/r2/lib/media.py b/r2/r2/lib/media.py index c6b06f68a..bcbd3d6f7 100644 --- a/r2/r2/lib/media.py +++ b/r2/r2/lib/media.py @@ -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 diff --git a/r2/r2/lib/nymph.py b/r2/r2/lib/nymph.py new file mode 100644 index 000000000..eb4d1d57e --- /dev/null +++ b/r2/r2/lib/nymph.py @@ -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]) diff --git a/r2/r2/public/static/css/reddit.css b/r2/r2/public/static/css/reddit.css index ff4a6d218..815364489 100644 --- a/r2/r2/public/static/css/reddit.css +++ b/r2/r2/public/static/css/reddit.css @@ -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 */ }