Changeset View
Changeset View
Standalone View
Standalone View
src/aphront/sprite/PhutilSpriteSheet.php
- This file was added.
| <?php | |||||
| /** | |||||
| * NOTE: This is very new and unstable. | |||||
| */ | |||||
| final class PhutilSpriteSheet extends Phobject { | |||||
| const MANIFEST_VERSION = 1; | |||||
| const TYPE_STANDARD = 'standard'; | |||||
| const TYPE_REPEAT_X = 'repeat-x'; | |||||
| const TYPE_REPEAT_Y = 'repeat-y'; | |||||
| private $sprites = array(); | |||||
| private $sources = array(); | |||||
| private $hashes = array(); | |||||
| private $cssHeader; | |||||
| private $generated; | |||||
| private $scales = array(1); | |||||
| private $type = self::TYPE_STANDARD; | |||||
| private $basePath; | |||||
| private $css; | |||||
| private $images; | |||||
| public function addSprite(PhutilSprite $sprite) { | |||||
| $this->generated = false; | |||||
| $this->sprites[] = $sprite; | |||||
| return $this; | |||||
| } | |||||
| public function setCSSHeader($header) { | |||||
| $this->generated = false; | |||||
| $this->cssHeader = $header; | |||||
| return $this; | |||||
| } | |||||
| public function setScales(array $scales) { | |||||
| $this->scales = array_values($scales); | |||||
| return $this; | |||||
| } | |||||
| public function getScales() { | |||||
| return $this->scales; | |||||
| } | |||||
| public function setSheetType($type) { | |||||
| $this->type = $type; | |||||
| return $this; | |||||
| } | |||||
| public function setBasePath($base_path) { | |||||
| $this->basePath = $base_path; | |||||
| return $this; | |||||
| } | |||||
| private function generate() { | |||||
| if ($this->generated) { | |||||
| return; | |||||
| } | |||||
| $multi_row = true; | |||||
| $multi_col = true; | |||||
| $margin_w = 1; | |||||
| $margin_h = 1; | |||||
| $type = $this->type; | |||||
| switch ($type) { | |||||
| case self::TYPE_STANDARD: | |||||
| break; | |||||
| case self::TYPE_REPEAT_X: | |||||
| $multi_col = false; | |||||
| $margin_w = 0; | |||||
| $width = null; | |||||
| foreach ($this->sprites as $sprite) { | |||||
| if ($width === null) { | |||||
| $width = $sprite->getSourceW(); | |||||
| } else if ($width !== $sprite->getSourceW()) { | |||||
| throw new Exception( | |||||
| pht( | |||||
| "All sprites in a '%s' sheet must have the same width.", | |||||
| 'repeat-x')); | |||||
| } | |||||
| } | |||||
| break; | |||||
| case self::TYPE_REPEAT_Y: | |||||
| $multi_row = false; | |||||
| $margin_h = 0; | |||||
| $height = null; | |||||
| foreach ($this->sprites as $sprite) { | |||||
| if ($height === null) { | |||||
| $height = $sprite->getSourceH(); | |||||
| } else if ($height !== $sprite->getSourceH()) { | |||||
| throw new Exception( | |||||
| pht( | |||||
| "All sprites in a '%s' sheet must have the same height.", | |||||
| 'repeat-y')); | |||||
| } | |||||
| } | |||||
| break; | |||||
| default: | |||||
| throw new Exception(pht("Unknown sprite sheet type '%s'!", $type)); | |||||
| } | |||||
| $css = array(); | |||||
| if ($this->cssHeader) { | |||||
| $css[] = $this->cssHeader; | |||||
| } | |||||
| $out_w = 0; | |||||
| $out_h = 0; | |||||
| // Lay out the sprite sheet. We attempt to build a roughly square sheet | |||||
| // so it's easier to manage, since 2000x20 is more cumbersome for humans | |||||
| // to deal with than 200x200. | |||||
| // | |||||
| // To do this, we use a simple greedy algorithm, adding sprites one at a | |||||
| // time. For each sprite, if the sheet is at least as wide as it is tall | |||||
| // we create a new row. Otherwise, we try to add it to an existing row. | |||||
| // | |||||
| // This isn't optimal, but does a reasonable job in most cases and isn't | |||||
| // too messy. | |||||
| // Group the sprites by their sizes. We lay them out in the sheet as | |||||
| // boxes, but then put them into the boxes in the order they were added | |||||
| // so similar sprites end up nearby on the final sheet. | |||||
| $boxes = array(); | |||||
| foreach (array_reverse($this->sprites) as $sprite) { | |||||
| $s_w = $sprite->getSourceW() + $margin_w; | |||||
| $s_h = $sprite->getSourceH() + $margin_h; | |||||
| $boxes[$s_w][$s_h][] = $sprite; | |||||
| } | |||||
| $rows = array(); | |||||
| foreach ($this->sprites as $sprite) { | |||||
| $s_w = $sprite->getSourceW() + $margin_w; | |||||
| $s_h = $sprite->getSourceH() + $margin_h; | |||||
| // Choose a row for this sprite. | |||||
| $maybe = array(); | |||||
| foreach ($rows as $key => $row) { | |||||
| if ($row['h'] < $s_h) { | |||||
| // We can only add it to a row if the row is at least as tall as the | |||||
| // sprite. | |||||
| continue; | |||||
| } | |||||
| // We prefer rows which have the same height as the sprite, and then | |||||
| // rows which aren't yet very wide. | |||||
| $wasted_v = ($row['h'] - $s_h); | |||||
| $wasted_h = ($row['w'] / $out_w); | |||||
| $maybe[$key] = $wasted_v + $wasted_h; | |||||
| } | |||||
| $row_key = null; | |||||
| if ($maybe && $multi_col) { | |||||
| // If there were any candidate rows, pick the best one. | |||||
| asort($maybe); | |||||
| $row_key = head_key($maybe); | |||||
| } | |||||
| if ($row_key !== null && $multi_row) { | |||||
| // If there's a candidate row, but adding the sprite to it would make | |||||
| // the sprite wider than it is tall, create a new row instead. This | |||||
| // generally keeps the sprite square-ish. | |||||
| if ($rows[$row_key]['w'] + $s_w > $out_h) { | |||||
| $row_key = null; | |||||
| } | |||||
| } | |||||
| if ($row_key === null) { | |||||
| // Add a new row. | |||||
| $rows[] = array( | |||||
| 'w' => 0, | |||||
| 'h' => $s_h, | |||||
| 'boxes' => array(), | |||||
| ); | |||||
| $row_key = last_key($rows); | |||||
| $out_h += $s_h; | |||||
| } | |||||
| // Add the sprite box to the row. | |||||
| $row = $rows[$row_key]; | |||||
| $row['w'] += $s_w; | |||||
| $row['boxes'][] = array($s_w, $s_h); | |||||
| $rows[$row_key] = $row; | |||||
| $out_w = max($row['w'], $out_w); | |||||
| } | |||||
| $images = array(); | |||||
| foreach ($this->scales as $scale) { | |||||
| $img = imagecreatetruecolor($out_w * $scale, $out_h * $scale); | |||||
| imagesavealpha($img, true); | |||||
| imagefill($img, 0, 0, imagecolorallocatealpha($img, 0, 0, 0, 127)); | |||||
| $images[$scale] = $img; | |||||
| } | |||||
| // Put the shorter rows first. At the same height, put the wider rows first. | |||||
| // This makes the resulting sheet more human-readable. | |||||
| foreach ($rows as $key => $row) { | |||||
| $rows[$key]['sort'] = $row['h'] + (1 - ($row['w'] / $out_w)); | |||||
| } | |||||
| $rows = isort($rows, 'sort'); | |||||
| $pos_x = 0; | |||||
| $pos_y = 0; | |||||
| $rules = array(); | |||||
| foreach ($rows as $row) { | |||||
| $max_h = 0; | |||||
| foreach ($row['boxes'] as $box) { | |||||
| $sprite = array_pop($boxes[$box[0]][$box[1]]); | |||||
| foreach ($images as $scale => $img) { | |||||
| $src = $this->loadSource($sprite, $scale); | |||||
| imagecopy( | |||||
| $img, | |||||
| $src, | |||||
| $scale * $pos_x, $scale * $pos_y, | |||||
| $scale * $sprite->getSourceX(), $scale * $sprite->getSourceY(), | |||||
| $scale * $sprite->getSourceW(), $scale * $sprite->getSourceH()); | |||||
| } | |||||
| $rule = $sprite->getTargetCSS(); | |||||
| $cssx = (-$pos_x).'px'; | |||||
| $cssy = (-$pos_y).'px'; | |||||
| $rules[$sprite->getName()] = "{$rule} {\n". | |||||
| " background-position: {$cssx} {$cssy};\n}"; | |||||
| $pos_x += $sprite->getSourceW() + $margin_w; | |||||
| $max_h = max($max_h, $sprite->getSourceH()); | |||||
| } | |||||
| $pos_x = 0; | |||||
| $pos_y += $max_h + $margin_h; | |||||
| } | |||||
| // Generate CSS rules in input order. | |||||
| foreach ($this->sprites as $sprite) { | |||||
| $css[] = $rules[$sprite->getName()]; | |||||
| } | |||||
| $this->images = $images; | |||||
| $this->css = implode("\n\n", $css)."\n"; | |||||
| $this->generated = true; | |||||
| } | |||||
| public function generateImage($path, $scale = 1) { | |||||
| $this->generate(); | |||||
| $this->log(pht("Writing sprite '%s'...", $path)); | |||||
| imagepng($this->images[$scale], $path); | |||||
| return $this; | |||||
| } | |||||
| public function generateCSS($path) { | |||||
| $this->generate(); | |||||
| $this->log(pht("Writing CSS '%s'...", $path)); | |||||
| $out = $this->css; | |||||
| $out = str_replace('{X}', imagesx($this->images[1]), $out); | |||||
| $out = str_replace('{Y}', imagesy($this->images[1]), $out); | |||||
| Filesystem::writeFile($path, $out); | |||||
| return $this; | |||||
| } | |||||
| public function needsRegeneration(array $manifest) { | |||||
| return ($this->buildManifest() !== $manifest); | |||||
| } | |||||
| private function buildManifest() { | |||||
| $output = array(); | |||||
| foreach ($this->sprites as $sprite) { | |||||
| $output[$sprite->getName()] = array( | |||||
| 'name' => $sprite->getName(), | |||||
| 'rule' => $sprite->getTargetCSS(), | |||||
| 'hash' => $this->loadSourceHash($sprite), | |||||
| ); | |||||
| } | |||||
| ksort($output); | |||||
| $data = array( | |||||
| 'version' => self::MANIFEST_VERSION, | |||||
| 'sprites' => $output, | |||||
| 'scales' => $this->scales, | |||||
| 'header' => $this->cssHeader, | |||||
| 'type' => $this->type, | |||||
| ); | |||||
| return $data; | |||||
| } | |||||
| public function generateManifest($path) { | |||||
| $data = $this->buildManifest(); | |||||
| $json = new PhutilJSON(); | |||||
| $data = $json->encodeFormatted($data); | |||||
| Filesystem::writeFile($path, $data); | |||||
| return $this; | |||||
| } | |||||
| private function log($message) { | |||||
| echo $message."\n"; | |||||
| } | |||||
| private function loadSourceHash(PhutilSprite $sprite) { | |||||
| $inputs = array(); | |||||
| foreach ($this->scales as $scale) { | |||||
| $file = $sprite->getSourceFile($scale); | |||||
| // If two users have a project in different places, like: | |||||
| // | |||||
| // /home/alincoln/project | |||||
| // /home/htaft/project | |||||
| // | |||||
| // ...we want to ignore the `/home/alincoln` part when hashing the sheet, | |||||
| // since the sprites don't change when the project directory moves. If | |||||
| // the base path is set, build the hashes using paths relative to the | |||||
| // base path. | |||||
| $file_key = $file; | |||||
| if ($this->basePath) { | |||||
| $file_key = Filesystem::readablePath($file, $this->basePath); | |||||
| } | |||||
| if (empty($this->hashes[$file_key])) { | |||||
| $this->hashes[$file_key] = md5(Filesystem::readFile($file)); | |||||
| } | |||||
| $inputs[] = $file_key; | |||||
| $inputs[] = $this->hashes[$file_key]; | |||||
| } | |||||
| $inputs[] = $sprite->getSourceX(); | |||||
| $inputs[] = $sprite->getSourceY(); | |||||
| $inputs[] = $sprite->getSourceW(); | |||||
| $inputs[] = $sprite->getSourceH(); | |||||
| return md5(implode(':', $inputs)); | |||||
| } | |||||
| private function loadSource(PhutilSprite $sprite, $scale) { | |||||
| $file = $sprite->getSourceFile($scale); | |||||
| if (empty($this->sources[$file])) { | |||||
| $data = Filesystem::readFile($file); | |||||
| $image = imagecreatefromstring($data); | |||||
| $this->sources[$file] = array( | |||||
| 'image' => $image, | |||||
| 'x' => imagesx($image), | |||||
| 'y' => imagesy($image), | |||||
| ); | |||||
| } | |||||
| $s_w = $sprite->getSourceW() * $scale; | |||||
| $i_w = $this->sources[$file]['x']; | |||||
| if ($s_w > $i_w) { | |||||
| throw new Exception( | |||||
| pht( | |||||
| "Sprite source for '%s' is too small (expected width %d, found %d).", | |||||
| $file, | |||||
| $s_w, | |||||
| $i_w)); | |||||
| } | |||||
| $s_h = $sprite->getSourceH() * $scale; | |||||
| $i_h = $this->sources[$file]['y']; | |||||
| if ($s_h > $i_h) { | |||||
| throw new Exception( | |||||
| pht( | |||||
| "Sprite source for '%s' is too small (expected height %d, found %d).", | |||||
| $file, | |||||
| $s_h, | |||||
| $i_h)); | |||||
| } | |||||
| return $this->sources[$file]['image']; | |||||
| } | |||||
| } | |||||