Path: blob/master/src/aphront/sprite/PhutilSpriteSheet.php
12242 views
<?php12/**3* NOTE: This is very new and unstable.4*/5final class PhutilSpriteSheet extends Phobject {67const MANIFEST_VERSION = 1;89const TYPE_STANDARD = 'standard';10const TYPE_REPEAT_X = 'repeat-x';11const TYPE_REPEAT_Y = 'repeat-y';1213private $sprites = array();14private $sources = array();15private $hashes = array();16private $cssHeader;17private $generated;18private $scales = array(1);19private $type = self::TYPE_STANDARD;20private $basePath;2122private $css;23private $images;2425public function addSprite(PhutilSprite $sprite) {26$this->generated = false;27$this->sprites[] = $sprite;28return $this;29}3031public function setCSSHeader($header) {32$this->generated = false;33$this->cssHeader = $header;34return $this;35}3637public function setScales(array $scales) {38$this->scales = array_values($scales);39return $this;40}4142public function getScales() {43return $this->scales;44}4546public function setSheetType($type) {47$this->type = $type;48return $this;49}5051public function setBasePath($base_path) {52$this->basePath = $base_path;53return $this;54}5556private function generate() {57if ($this->generated) {58return;59}6061$multi_row = true;62$multi_col = true;63$margin_w = 1;64$margin_h = 1;6566$type = $this->type;67switch ($type) {68case self::TYPE_STANDARD:69break;70case self::TYPE_REPEAT_X:71$multi_col = false;72$margin_w = 0;7374$width = null;75foreach ($this->sprites as $sprite) {76if ($width === null) {77$width = $sprite->getSourceW();78} else if ($width !== $sprite->getSourceW()) {79throw new Exception(80pht(81"All sprites in a '%s' sheet must have the same width.",82'repeat-x'));83}84}85break;86case self::TYPE_REPEAT_Y:87$multi_row = false;88$margin_h = 0;8990$height = null;91foreach ($this->sprites as $sprite) {92if ($height === null) {93$height = $sprite->getSourceH();94} else if ($height !== $sprite->getSourceH()) {95throw new Exception(96pht(97"All sprites in a '%s' sheet must have the same height.",98'repeat-y'));99}100}101break;102default:103throw new Exception(pht("Unknown sprite sheet type '%s'!", $type));104}105106107$css = array();108if ($this->cssHeader) {109$css[] = $this->cssHeader;110}111112$out_w = 0;113$out_h = 0;114115// Lay out the sprite sheet. We attempt to build a roughly square sheet116// so it's easier to manage, since 2000x20 is more cumbersome for humans117// to deal with than 200x200.118//119// To do this, we use a simple greedy algorithm, adding sprites one at a120// time. For each sprite, if the sheet is at least as wide as it is tall121// we create a new row. Otherwise, we try to add it to an existing row.122//123// This isn't optimal, but does a reasonable job in most cases and isn't124// too messy.125126// Group the sprites by their sizes. We lay them out in the sheet as127// boxes, but then put them into the boxes in the order they were added128// so similar sprites end up nearby on the final sheet.129$boxes = array();130foreach (array_reverse($this->sprites) as $sprite) {131$s_w = $sprite->getSourceW() + $margin_w;132$s_h = $sprite->getSourceH() + $margin_h;133$boxes[$s_w][$s_h][] = $sprite;134}135136$rows = array();137foreach ($this->sprites as $sprite) {138$s_w = $sprite->getSourceW() + $margin_w;139$s_h = $sprite->getSourceH() + $margin_h;140141// Choose a row for this sprite.142$maybe = array();143foreach ($rows as $key => $row) {144if ($row['h'] < $s_h) {145// We can only add it to a row if the row is at least as tall as the146// sprite.147continue;148}149// We prefer rows which have the same height as the sprite, and then150// rows which aren't yet very wide.151$wasted_v = ($row['h'] - $s_h);152$wasted_h = ($row['w'] / $out_w);153$maybe[$key] = $wasted_v + $wasted_h;154}155156$row_key = null;157if ($maybe && $multi_col) {158// If there were any candidate rows, pick the best one.159asort($maybe);160$row_key = head_key($maybe);161}162163if ($row_key !== null && $multi_row) {164// If there's a candidate row, but adding the sprite to it would make165// the sprite wider than it is tall, create a new row instead. This166// generally keeps the sprite square-ish.167if ($rows[$row_key]['w'] + $s_w > $out_h) {168$row_key = null;169}170}171172if ($row_key === null) {173// Add a new row.174$rows[] = array(175'w' => 0,176'h' => $s_h,177'boxes' => array(),178);179$row_key = last_key($rows);180$out_h += $s_h;181}182183// Add the sprite box to the row.184$row = $rows[$row_key];185$row['w'] += $s_w;186$row['boxes'][] = array($s_w, $s_h);187$rows[$row_key] = $row;188189$out_w = max($row['w'], $out_w);190}191192$images = array();193foreach ($this->scales as $scale) {194$img = imagecreatetruecolor($out_w * $scale, $out_h * $scale);195imagesavealpha($img, true);196imagefill($img, 0, 0, imagecolorallocatealpha($img, 0, 0, 0, 127));197198$images[$scale] = $img;199}200201202// Put the shorter rows first. At the same height, put the wider rows first.203// This makes the resulting sheet more human-readable.204foreach ($rows as $key => $row) {205$rows[$key]['sort'] = $row['h'] + (1 - ($row['w'] / $out_w));206}207$rows = isort($rows, 'sort');208209$pos_x = 0;210$pos_y = 0;211$rules = array();212foreach ($rows as $row) {213$max_h = 0;214foreach ($row['boxes'] as $box) {215$sprite = array_pop($boxes[$box[0]][$box[1]]);216217foreach ($images as $scale => $img) {218$src = $this->loadSource($sprite, $scale);219imagecopy(220$img,221$src,222$scale * $pos_x, $scale * $pos_y,223$scale * $sprite->getSourceX(), $scale * $sprite->getSourceY(),224$scale * $sprite->getSourceW(), $scale * $sprite->getSourceH());225}226227$rule = $sprite->getTargetCSS();228$cssx = (-$pos_x).'px';229$cssy = (-$pos_y).'px';230231$rules[$sprite->getName()] = "{$rule} {\n".232" background-position: {$cssx} {$cssy};\n}";233234$pos_x += $sprite->getSourceW() + $margin_w;235$max_h = max($max_h, $sprite->getSourceH());236}237$pos_x = 0;238$pos_y += $max_h + $margin_h;239}240241// Generate CSS rules in input order.242foreach ($this->sprites as $sprite) {243$css[] = $rules[$sprite->getName()];244}245246$this->images = $images;247$this->css = implode("\n\n", $css)."\n";248$this->generated = true;249}250251public function generateImage($path, $scale = 1) {252$this->generate();253$this->log(pht("Writing sprite '%s'...", $path));254imagepng($this->images[$scale], $path);255return $this;256}257258public function generateCSS($path) {259$this->generate();260$this->log(pht("Writing CSS '%s'...", $path));261262$out = $this->css;263$out = str_replace('{X}', imagesx($this->images[1]), $out);264$out = str_replace('{Y}', imagesy($this->images[1]), $out);265266Filesystem::writeFile($path, $out);267return $this;268}269270public function needsRegeneration(array $manifest) {271return ($this->buildManifest() !== $manifest);272}273274private function buildManifest() {275$output = array();276foreach ($this->sprites as $sprite) {277$output[$sprite->getName()] = array(278'name' => $sprite->getName(),279'rule' => $sprite->getTargetCSS(),280'hash' => $this->loadSourceHash($sprite),281);282}283284ksort($output);285286$data = array(287'version' => self::MANIFEST_VERSION,288'sprites' => $output,289'scales' => $this->scales,290'header' => $this->cssHeader,291'type' => $this->type,292);293294return $data;295}296297public function generateManifest($path) {298$data = $this->buildManifest();299300$json = new PhutilJSON();301$data = $json->encodeFormatted($data);302Filesystem::writeFile($path, $data);303return $this;304}305306private function log($message) {307echo $message."\n";308}309310private function loadSourceHash(PhutilSprite $sprite) {311$inputs = array();312313foreach ($this->scales as $scale) {314$file = $sprite->getSourceFile($scale);315316// If two users have a project in different places, like:317//318// /home/alincoln/project319// /home/htaft/project320//321// ...we want to ignore the `/home/alincoln` part when hashing the sheet,322// since the sprites don't change when the project directory moves. If323// the base path is set, build the hashes using paths relative to the324// base path.325326$file_key = $file;327if ($this->basePath) {328$file_key = Filesystem::readablePath($file, $this->basePath);329}330331if (empty($this->hashes[$file_key])) {332$this->hashes[$file_key] = md5(Filesystem::readFile($file));333}334335$inputs[] = $file_key;336$inputs[] = $this->hashes[$file_key];337}338339$inputs[] = $sprite->getSourceX();340$inputs[] = $sprite->getSourceY();341$inputs[] = $sprite->getSourceW();342$inputs[] = $sprite->getSourceH();343344return md5(implode(':', $inputs));345}346347private function loadSource(PhutilSprite $sprite, $scale) {348$file = $sprite->getSourceFile($scale);349if (empty($this->sources[$file])) {350$data = Filesystem::readFile($file);351$image = imagecreatefromstring($data);352$this->sources[$file] = array(353'image' => $image,354'x' => imagesx($image),355'y' => imagesy($image),356);357}358359$s_w = $sprite->getSourceW() * $scale;360$i_w = $this->sources[$file]['x'];361if ($s_w > $i_w) {362throw new Exception(363pht(364"Sprite source for '%s' is too small (expected width %d, found %d).",365$file,366$s_w,367$i_w));368}369370$s_h = $sprite->getSourceH() * $scale;371$i_h = $this->sources[$file]['y'];372if ($s_h > $i_h) {373throw new Exception(374pht(375"Sprite source for '%s' is too small (expected height %d, found %d).",376$file,377$s_h,378$i_h));379}380381return $this->sources[$file]['image'];382}383384}385386387