Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
freebsd
GitHub Repository: freebsd/phabricator
Path: blob/master/src/aphront/sprite/PhutilSpriteSheet.php
12242 views
1
<?php
2
3
/**
4
* NOTE: This is very new and unstable.
5
*/
6
final 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
}
386
387