@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
at recaptime-dev/main 385 lines 11 kB view raw
1<?php 2 3/** 4 * NOTE: This is very new and unstable. 5 */ 6final class PhutilSpriteSheet extends Phobject { 7 8 const MANIFEST_VERSION = 1; 9 10 const TYPE_STANDARD = 'standard'; 11 const TYPE_REPEAT_X = 'repeat-x'; 12 const TYPE_REPEAT_Y = 'repeat-y'; 13 14 private $sprites = array(); 15 private $sources = array(); 16 private $hashes = array(); 17 private $cssHeader; 18 private $generated; 19 private $scales = array(1); 20 private $type = self::TYPE_STANDARD; 21 private $basePath; 22 23 private $css; 24 private $images; 25 26 public function addSprite(PhutilSprite $sprite) { 27 $this->generated = false; 28 $this->sprites[] = $sprite; 29 return $this; 30 } 31 32 public function setCSSHeader($header) { 33 $this->generated = false; 34 $this->cssHeader = $header; 35 return $this; 36 } 37 38 public function setScales(array $scales) { 39 $this->scales = array_values($scales); 40 return $this; 41 } 42 43 public function getScales() { 44 return $this->scales; 45 } 46 47 public function setSheetType($type) { 48 $this->type = $type; 49 return $this; 50 } 51 52 public function setBasePath($base_path) { 53 $this->basePath = $base_path; 54 return $this; 55 } 56 57 private function generate() { 58 if ($this->generated) { 59 return; 60 } 61 62 $multi_row = true; 63 $multi_col = true; 64 $margin_w = 1; 65 $margin_h = 1; 66 67 $type = $this->type; 68 switch ($type) { 69 case self::TYPE_STANDARD: 70 break; 71 case self::TYPE_REPEAT_X: 72 $multi_col = false; 73 $margin_w = 0; 74 75 $width = null; 76 foreach ($this->sprites as $sprite) { 77 if ($width === null) { 78 $width = $sprite->getSourceW(); 79 } else if ($width !== $sprite->getSourceW()) { 80 throw new Exception( 81 pht( 82 "All sprites in a '%s' sheet must have the same width.", 83 'repeat-x')); 84 } 85 } 86 break; 87 case self::TYPE_REPEAT_Y: 88 $multi_row = false; 89 $margin_h = 0; 90 91 $height = null; 92 foreach ($this->sprites as $sprite) { 93 if ($height === null) { 94 $height = $sprite->getSourceH(); 95 } else if ($height !== $sprite->getSourceH()) { 96 throw new Exception( 97 pht( 98 "All sprites in a '%s' sheet must have the same height.", 99 'repeat-y')); 100 } 101 } 102 break; 103 default: 104 throw new Exception(pht("Unknown sprite sheet type '%s'!", $type)); 105 } 106 107 108 $css = array(); 109 if ($this->cssHeader) { 110 $css[] = $this->cssHeader; 111 } 112 113 $out_w = 0; 114 $out_h = 0; 115 116 // Lay out the sprite sheet. We attempt to build a roughly square sheet 117 // so it's easier to manage, since 2000x20 is more cumbersome for humans 118 // to deal with than 200x200. 119 // 120 // To do this, we use a simple greedy algorithm, adding sprites one at a 121 // time. For each sprite, if the sheet is at least as wide as it is tall 122 // we create a new row. Otherwise, we try to add it to an existing row. 123 // 124 // This isn't optimal, but does a reasonable job in most cases and isn't 125 // too messy. 126 127 // Group the sprites by their sizes. We lay them out in the sheet as 128 // boxes, but then put them into the boxes in the order they were added 129 // so similar sprites end up nearby on the final sheet. 130 $boxes = array(); 131 foreach (array_reverse($this->sprites) as $sprite) { 132 $s_w = $sprite->getSourceW() + $margin_w; 133 $s_h = $sprite->getSourceH() + $margin_h; 134 $boxes[$s_w][$s_h][] = $sprite; 135 } 136 137 $rows = array(); 138 foreach ($this->sprites as $sprite) { 139 $s_w = $sprite->getSourceW() + $margin_w; 140 $s_h = $sprite->getSourceH() + $margin_h; 141 142 // Choose a row for this sprite. 143 $maybe = array(); 144 foreach ($rows as $key => $row) { 145 if ($row['h'] < $s_h) { 146 // We can only add it to a row if the row is at least as tall as the 147 // sprite. 148 continue; 149 } 150 // We prefer rows which have the same height as the sprite, and then 151 // rows which aren't yet very wide. 152 $wasted_v = ($row['h'] - $s_h); 153 $wasted_h = ($row['w'] / $out_w); 154 $maybe[$key] = $wasted_v + $wasted_h; 155 } 156 157 $row_key = null; 158 if ($maybe && $multi_col) { 159 // If there were any candidate rows, pick the best one. 160 asort($maybe); 161 $row_key = head_key($maybe); 162 } 163 164 if ($row_key !== null && $multi_row) { 165 // If there's a candidate row, but adding the sprite to it would make 166 // the sprite wider than it is tall, create a new row instead. This 167 // generally keeps the sprite square-ish. 168 if ($rows[$row_key]['w'] + $s_w > $out_h) { 169 $row_key = null; 170 } 171 } 172 173 if ($row_key === null) { 174 // Add a new row. 175 $rows[] = array( 176 'w' => 0, 177 'h' => $s_h, 178 'boxes' => array(), 179 ); 180 $row_key = last_key($rows); 181 $out_h += $s_h; 182 } 183 184 // Add the sprite box to the row. 185 $row = $rows[$row_key]; 186 $row['w'] += $s_w; 187 $row['boxes'][] = array($s_w, $s_h); 188 $rows[$row_key] = $row; 189 190 $out_w = max($row['w'], $out_w); 191 } 192 193 $images = array(); 194 foreach ($this->scales as $scale) { 195 $img = imagecreatetruecolor($out_w * $scale, $out_h * $scale); 196 imagesavealpha($img, true); 197 imagefill($img, 0, 0, imagecolorallocatealpha($img, 0, 0, 0, 127)); 198 199 $images[$scale] = $img; 200 } 201 202 203 // Put the shorter rows first. At the same height, put the wider rows first. 204 // This makes the resulting sheet more human-readable. 205 foreach ($rows as $key => $row) { 206 $rows[$key]['sort'] = $row['h'] + (1 - ($row['w'] / $out_w)); 207 } 208 $rows = isort($rows, 'sort'); 209 210 $pos_x = 0; 211 $pos_y = 0; 212 $rules = array(); 213 foreach ($rows as $row) { 214 $max_h = 0; 215 foreach ($row['boxes'] as $box) { 216 $sprite = array_pop($boxes[$box[0]][$box[1]]); 217 218 foreach ($images as $scale => $img) { 219 $src = $this->loadSource($sprite, $scale); 220 imagecopy( 221 $img, 222 $src, 223 $scale * $pos_x, $scale * $pos_y, 224 $scale * $sprite->getSourceX(), $scale * $sprite->getSourceY(), 225 $scale * $sprite->getSourceW(), $scale * $sprite->getSourceH()); 226 } 227 228 $rule = $sprite->getTargetCSS(); 229 $cssx = (-$pos_x).'px'; 230 $cssy = (-$pos_y).'px'; 231 232 $rules[$sprite->getName()] = "{$rule} {\n". 233 " background-position: {$cssx} {$cssy};\n}"; 234 235 $pos_x += $sprite->getSourceW() + $margin_w; 236 $max_h = max($max_h, $sprite->getSourceH()); 237 } 238 $pos_x = 0; 239 $pos_y += $max_h + $margin_h; 240 } 241 242 // Generate CSS rules in input order. 243 foreach ($this->sprites as $sprite) { 244 $css[] = $rules[$sprite->getName()]; 245 } 246 247 $this->images = $images; 248 $this->css = implode("\n\n", $css)."\n"; 249 $this->generated = true; 250 } 251 252 public function generateImage($path, $scale = 1) { 253 $this->generate(); 254 $this->log(pht("Writing sprite '%s'...", $path)); 255 imagepng($this->images[$scale], $path); 256 return $this; 257 } 258 259 public function generateCSS($path) { 260 $this->generate(); 261 $this->log(pht("Writing CSS '%s'...", $path)); 262 263 $out = $this->css; 264 $out = str_replace('{X}', imagesx($this->images[1]), $out); 265 $out = str_replace('{Y}', imagesy($this->images[1]), $out); 266 267 Filesystem::writeFile($path, $out); 268 return $this; 269 } 270 271 public function needsRegeneration(array $manifest) { 272 return ($this->buildManifest() !== $manifest); 273 } 274 275 private function buildManifest() { 276 $output = array(); 277 foreach ($this->sprites as $sprite) { 278 $output[$sprite->getName()] = array( 279 'name' => $sprite->getName(), 280 'rule' => $sprite->getTargetCSS(), 281 'hash' => $this->loadSourceHash($sprite), 282 ); 283 } 284 285 ksort($output); 286 287 $data = array( 288 'version' => self::MANIFEST_VERSION, 289 'sprites' => $output, 290 'scales' => $this->scales, 291 'header' => $this->cssHeader, 292 'type' => $this->type, 293 ); 294 295 return $data; 296 } 297 298 public function generateManifest($path) { 299 $data = $this->buildManifest(); 300 301 $json = new PhutilJSON(); 302 $data = $json->encodeFormatted($data); 303 Filesystem::writeFile($path, $data); 304 return $this; 305 } 306 307 private function log($message) { 308 echo $message."\n"; 309 } 310 311 private function loadSourceHash(PhutilSprite $sprite) { 312 $inputs = array(); 313 314 foreach ($this->scales as $scale) { 315 $file = $sprite->getSourceFile($scale); 316 317 // If two users have a project in different places, like: 318 // 319 // /home/alincoln/project 320 // /home/htaft/project 321 // 322 // ...we want to ignore the `/home/alincoln` part when hashing the sheet, 323 // since the sprites don't change when the project directory moves. If 324 // the base path is set, build the hashes using paths relative to the 325 // base path. 326 327 $file_key = $file; 328 if ($this->basePath) { 329 $file_key = Filesystem::readablePath($file, $this->basePath); 330 } 331 332 if (empty($this->hashes[$file_key])) { 333 $this->hashes[$file_key] = md5(Filesystem::readFile($file)); 334 } 335 336 $inputs[] = $file_key; 337 $inputs[] = $this->hashes[$file_key]; 338 } 339 340 $inputs[] = $sprite->getSourceX(); 341 $inputs[] = $sprite->getSourceY(); 342 $inputs[] = $sprite->getSourceW(); 343 $inputs[] = $sprite->getSourceH(); 344 345 return md5(implode(':', $inputs)); 346 } 347 348 private function loadSource(PhutilSprite $sprite, $scale) { 349 $file = $sprite->getSourceFile($scale); 350 if (empty($this->sources[$file])) { 351 $data = Filesystem::readFile($file); 352 $image = imagecreatefromstring($data); 353 $this->sources[$file] = array( 354 'image' => $image, 355 'x' => imagesx($image), 356 'y' => imagesy($image), 357 ); 358 } 359 360 $s_w = $sprite->getSourceW() * $scale; 361 $i_w = $this->sources[$file]['x']; 362 if ($s_w > $i_w) { 363 throw new Exception( 364 pht( 365 "Sprite source for '%s' is too small (expected width %d, found %d).", 366 $file, 367 $s_w, 368 $i_w)); 369 } 370 371 $s_h = $sprite->getSourceH() * $scale; 372 $i_h = $this->sources[$file]['y']; 373 if ($s_h > $i_h) { 374 throw new Exception( 375 pht( 376 "Sprite source for '%s' is too small (expected height %d, found %d).", 377 $file, 378 $s_h, 379 $i_h)); 380 } 381 382 return $this->sources[$file]['image']; 383 } 384 385}