@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
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}