mirror of
https://github.com/reddit-archive/reddit.git
synced 2026-01-26 15:28:37 -05:00
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:
@@ -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) > $@
|
||||
|
||||
@@ -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])
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
176
r2/r2/lib/nymph.py
Normal 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])
|
||||
@@ -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 */
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user