Remove product literal strings in "pht()", part 6
[phabricator.git] / src / applications / macro / engine / PhabricatorMemeEngine.php
blobafee0f9b18d8483e96a5b2397153778d68222467
1 <?php
3 final class PhabricatorMemeEngine extends Phobject {
5 private $viewer;
6 private $template;
7 private $aboveText;
8 private $belowText;
10 private $templateFile;
11 private $metrics;
13 public function setViewer(PhabricatorUser $viewer) {
14 $this->viewer = $viewer;
15 return $this;
18 public function getViewer() {
19 return $this->viewer;
22 public function setTemplate($template) {
23 $this->template = $template;
24 return $this;
27 public function getTemplate() {
28 return $this->template;
31 public function setAboveText($above_text) {
32 $this->aboveText = $above_text;
33 return $this;
36 public function getAboveText() {
37 return $this->aboveText;
40 public function setBelowText($below_text) {
41 $this->belowText = $below_text;
42 return $this;
45 public function getBelowText() {
46 return $this->belowText;
49 public function getGenerateURI() {
50 $params = array(
51 'macro' => $this->getTemplate(),
52 'above' => $this->getAboveText(),
53 'below' => $this->getBelowText(),
56 return new PhutilURI('/macro/meme/', $params);
59 public function newAsset() {
60 $cache = $this->loadCachedFile();
61 if ($cache) {
62 return $cache;
65 $template = $this->loadTemplateFile();
66 if (!$template) {
67 throw new Exception(
68 pht(
69 'Template "%s" is not a valid template.',
70 $template));
73 $hash = $this->newTransformHash();
75 $asset = $this->newAssetFile($template);
77 $xfile = id(new PhabricatorTransformedFile())
78 ->setOriginalPHID($template->getPHID())
79 ->setTransformedPHID($asset->getPHID())
80 ->setTransform($hash);
82 try {
83 $caught = null;
85 $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
86 try {
87 $xfile->save();
88 } catch (Exception $ex) {
89 $caught = $ex;
91 unset($unguarded);
93 if ($caught) {
94 throw $caught;
97 return $asset;
98 } catch (AphrontDuplicateKeyQueryException $ex) {
99 $xfile = $this->loadCachedFile();
100 if (!$xfile) {
101 throw $ex;
103 return $xfile;
107 private function newTransformHash() {
108 $properties = array(
109 'kind' => 'meme',
110 'above' => $this->getAboveText(),
111 'below' => $this->getBelowText(),
114 $properties = phutil_json_encode($properties);
116 return PhabricatorHash::digestForIndex($properties);
119 public function loadCachedFile() {
120 $viewer = $this->getViewer();
122 $template_file = $this->loadTemplateFile();
123 if (!$template_file) {
124 return null;
127 $hash = $this->newTransformHash();
129 $xform = id(new PhabricatorTransformedFile())->loadOneWhere(
130 'originalPHID = %s AND transform = %s',
131 $template_file->getPHID(),
132 $hash);
133 if (!$xform) {
134 return null;
137 return id(new PhabricatorFileQuery())
138 ->setViewer($viewer)
139 ->withPHIDs(array($xform->getTransformedPHID()))
140 ->executeOne();
143 private function loadTemplateFile() {
144 if ($this->templateFile === null) {
145 $viewer = $this->getViewer();
146 $template = $this->getTemplate();
148 $macro = id(new PhabricatorMacroQuery())
149 ->setViewer($viewer)
150 ->withNames(array($template))
151 ->needFiles(true)
152 ->executeOne();
153 if (!$macro) {
154 return null;
157 $this->templateFile = $macro->getFile();
160 return $this->templateFile;
163 private function newAssetFile(PhabricatorFile $template) {
164 $data = $this->newAssetData($template);
165 return PhabricatorFile::newFromFileData(
166 $data,
167 array(
168 'name' => 'meme-'.$template->getName(),
169 'canCDN' => true,
171 // In modern code these can end up linked directly in email, so let
172 // them stick around for a while.
173 'ttl.relative' => phutil_units('30 days in seconds'),
177 private function newAssetData(PhabricatorFile $template) {
178 $template_data = $template->loadFileData();
180 // When we aren't adding text, just return the data unmodified. This saves
181 // us from doing expensive stitching when we aren't actually making any
182 // changes to the image.
183 $above_text = $this->getAboveText();
184 $below_text = $this->getBelowText();
185 if (!strlen(trim($above_text)) && !strlen(trim($below_text))) {
186 return $template_data;
189 $result = $this->newImagemagickAsset($template, $template_data);
190 if ($result) {
191 return $result;
194 return $this->newGDAsset($template, $template_data);
197 private function newImagemagickAsset(
198 PhabricatorFile $template,
199 $template_data) {
201 // We're only going to use Imagemagick on GIFs.
202 $mime_type = $template->getMimeType();
203 if ($mime_type != 'image/gif') {
204 return null;
207 // We're only going to use Imagemagick if it is actually available.
208 $available = PhabricatorEnv::getEnvConfig('files.enable-imagemagick');
209 if (!$available) {
210 return null;
213 // Test of the GIF is an animated GIF. If it's a flat GIF, we'll fall
214 // back to GD.
215 $input = new TempFile();
216 Filesystem::writeFile($input, $template_data);
217 list($err, $out) = exec_manual('convert %s info:', $input);
218 if ($err) {
219 return null;
222 $split = phutil_split_lines($out);
223 $frames = count($split);
224 if ($frames <= 1) {
225 return null;
228 // Split the frames apart, transform each frame, then merge them back
229 // together.
230 $output = new TempFile();
232 $future = new ExecFuture(
233 'convert %s -coalesce +adjoin %s_%s',
234 $input,
235 $input,
236 '%09d');
237 $future->setTimeout(10)->resolvex();
239 $output_files = array();
240 for ($ii = 0; $ii < $frames; $ii++) {
241 $frame_name = sprintf('%s_%09d', $input, $ii);
242 $output_name = sprintf('%s_%09d', $output, $ii);
244 $output_files[] = $output_name;
246 $frame_data = Filesystem::readFile($frame_name);
247 $memed_frame_data = $this->newGDAsset($template, $frame_data);
248 Filesystem::writeFile($output_name, $memed_frame_data);
251 $future = new ExecFuture(
252 'convert -dispose background -loop 0 %Ls %s',
253 $output_files,
254 $output);
255 $future->setTimeout(10)->resolvex();
257 return Filesystem::readFile($output);
260 private function newGDAsset(PhabricatorFile $template, $data) {
261 $img = imagecreatefromstring($data);
262 if (!$img) {
263 throw new Exception(
264 pht('Failed to imagecreatefromstring() image template data.'));
267 $dx = imagesx($img);
268 $dy = imagesy($img);
270 $metrics = $this->getMetrics($dx, $dy);
271 $font = $this->getFont();
272 $size = $metrics['size'];
274 $above = $this->getAboveText();
275 if (strlen($above)) {
276 $x = (int)floor(($dx - $metrics['text']['above']['width']) / 2);
277 $y = $metrics['text']['above']['height'] + 12;
279 $this->drawText($img, $font, $metrics['size'], $x, $y, $above);
282 $below = $this->getBelowText();
283 if (strlen($below)) {
284 $x = (int)floor(($dx - $metrics['text']['below']['width']) / 2);
285 $y = $dy - 12 - $metrics['text']['below']['descend'];
287 $this->drawText($img, $font, $metrics['size'], $x, $y, $below);
290 return PhabricatorImageTransformer::saveImageDataInAnyFormat(
291 $img,
292 $template->getMimeType());
295 private function getFont() {
296 $phabricator_root = dirname(phutil_get_library_root('phabricator'));
298 $font_root = $phabricator_root.'/resources/font/';
299 if (Filesystem::pathExists($font_root.'impact.ttf')) {
300 $font_path = $font_root.'impact.ttf';
301 } else {
302 $font_path = $font_root.'tuffy.ttf';
305 return $font_path;
308 private function getMetrics($dim_x, $dim_y) {
309 if ($this->metrics === null) {
310 $font = $this->getFont();
312 $font_max = 72;
313 $font_min = 5;
315 $margin_x = 16;
316 $margin_y = 16;
318 $last = null;
319 $cursor = floor(($font_max + $font_min) / 2);
320 $min = $font_min;
321 $max = $font_max;
323 $texts = array(
324 'above' => $this->getAboveText(),
325 'below' => $this->getBelowText(),
328 $metrics = null;
329 $best = null;
330 while (true) {
331 $all_fit = true;
332 $text_metrics = array();
333 foreach ($texts as $key => $text) {
334 $box = imagettfbbox($cursor, 0, $font, $text);
335 $height = abs($box[3] - $box[5]);
336 $width = abs($box[0] - $box[2]);
338 // This is the number of pixels below the baseline that the
339 // text extends, for example if it has a "y".
340 $descend = $box[3];
342 if (($height + $margin_y) > $dim_y) {
343 $all_fit = false;
344 break;
347 if (($width + $margin_x) > $dim_x) {
348 $all_fit = false;
349 break;
352 $text_metrics[$key]['width'] = $width;
353 $text_metrics[$key]['height'] = $height;
354 $text_metrics[$key]['descend'] = $descend;
357 if ($all_fit || $best === null) {
358 $best = $cursor;
359 $metrics = $text_metrics;
362 if ($all_fit) {
363 $min = $cursor;
364 } else {
365 $max = $cursor;
368 $last = $cursor;
369 $cursor = floor(($max + $min) / 2);
370 if ($cursor === $last) {
371 break;
375 $this->metrics = array(
376 'size' => $best,
377 'text' => $metrics,
381 return $this->metrics;
384 private function drawText($img, $font, $size, $x, $y, $text) {
385 $text_color = imagecolorallocate($img, 255, 255, 255);
386 $border_color = imagecolorallocate($img, 0, 0, 0);
388 $border = 2;
389 for ($xx = ($x - $border); $xx <= ($x + $border); $xx += $border) {
390 for ($yy = ($y - $border); $yy <= ($y + $border); $yy += $border) {
391 if (($xx === $x) && ($yy === $y)) {
392 continue;
394 imagettftext($img, $size, 0, $xx, $yy, $border_color, $font, $text);
398 imagettftext($img, $size, 0, $x, $y, $text_color, $font, $text);