@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 upstream/main 410 lines 10 kB view raw
1<?php 2 3final class PhabricatorMemeEngine extends Phobject { 4 5 private $viewer; 6 private $template; 7 private $aboveText; 8 private $belowText; 9 10 private $templateFile; 11 private $metrics; 12 13 public function setViewer(PhabricatorUser $viewer) { 14 $this->viewer = $viewer; 15 return $this; 16 } 17 18 public function getViewer() { 19 return $this->viewer; 20 } 21 22 public function setTemplate($template) { 23 $this->template = $template; 24 return $this; 25 } 26 27 public function getTemplate() { 28 return $this->template; 29 } 30 31 public function setAboveText($above_text) { 32 $this->aboveText = $above_text; 33 return $this; 34 } 35 36 public function getAboveText() { 37 return $this->aboveText; 38 } 39 40 public function setBelowText($below_text) { 41 $this->belowText = $below_text; 42 return $this; 43 } 44 45 public function getBelowText() { 46 return $this->belowText; 47 } 48 49 public function getGenerateURI() { 50 $params = array( 51 'macro' => $this->getTemplate(), 52 'above' => $this->getAboveText(), 53 'below' => $this->getBelowText(), 54 ); 55 56 return new PhutilURI('/macro/meme/', $params); 57 } 58 59 public function newAsset() { 60 $cache = $this->loadCachedFile(); 61 if ($cache) { 62 return $cache; 63 } 64 65 $template = $this->loadTemplateFile(); 66 if (!$template) { 67 throw new Exception( 68 pht( 69 'Template "%s" is not a valid template.', 70 $template)); 71 } 72 73 $hash = $this->newTransformHash(); 74 75 $asset = $this->newAssetFile($template); 76 77 $xfile = id(new PhabricatorTransformedFile()) 78 ->setOriginalPHID($template->getPHID()) 79 ->setTransformedPHID($asset->getPHID()) 80 ->setTransform($hash); 81 82 try { 83 $caught = null; 84 85 $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); 86 try { 87 $xfile->save(); 88 } catch (Exception $ex) { 89 $caught = $ex; 90 } 91 unset($unguarded); 92 93 if ($caught) { 94 throw $caught; 95 } 96 97 return $asset; 98 } catch (AphrontDuplicateKeyQueryException $ex) { 99 $xfile = $this->loadCachedFile(); 100 if (!$xfile) { 101 throw $ex; 102 } 103 return $xfile; 104 } 105 } 106 107 private function newTransformHash() { 108 $properties = array( 109 'kind' => 'meme', 110 'above' => $this->getAboveText(), 111 'below' => $this->getBelowText(), 112 ); 113 114 $properties = phutil_json_encode($properties); 115 116 return PhabricatorHash::digestForIndex($properties); 117 } 118 119 public function loadCachedFile() { 120 $viewer = $this->getViewer(); 121 122 $template_file = $this->loadTemplateFile(); 123 if (!$template_file) { 124 return null; 125 } 126 127 $hash = $this->newTransformHash(); 128 129 $xform = id(new PhabricatorTransformedFile())->loadOneWhere( 130 'originalPHID = %s AND transform = %s', 131 $template_file->getPHID(), 132 $hash); 133 if (!$xform) { 134 return null; 135 } 136 137 return id(new PhabricatorFileQuery()) 138 ->setViewer($viewer) 139 ->withPHIDs(array($xform->getTransformedPHID())) 140 ->executeOne(); 141 } 142 143 private function loadTemplateFile() { 144 if ($this->templateFile === null) { 145 $viewer = $this->getViewer(); 146 $template = $this->getTemplate(); 147 148 $macro = id(new PhabricatorMacroQuery()) 149 ->setViewer($viewer) 150 ->withNames(array($template)) 151 ->needFiles(true) 152 ->executeOne(); 153 if (!$macro) { 154 return null; 155 } 156 157 $this->templateFile = $macro->getFile(); 158 } 159 160 return $this->templateFile; 161 } 162 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, 170 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'), 174 )); 175 } 176 177 private function newAssetData(PhabricatorFile $template) { 178 $template_data = $template->loadFileData(); 179 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 (($above_text === null || !phutil_nonempty_string(trim($above_text))) && 186 ($below_text === null || !phutil_nonempty_string(trim($below_text)))) { 187 return $template_data; 188 } 189 190 $result = $this->newImagemagickAsset($template, $template_data); 191 if ($result) { 192 return $result; 193 } 194 195 return $this->newGDAsset($template, $template_data); 196 } 197 198 private function newImagemagickAsset( 199 PhabricatorFile $template, 200 $template_data) { 201 202 // We're only going to use Imagemagick on GIFs. 203 $mime_type = $template->getMimeType(); 204 if ($mime_type != 'image/gif') { 205 return null; 206 } 207 208 // We're only going to use Imagemagick if it is actually available. 209 $available = PhabricatorEnv::getEnvConfig('files.enable-imagemagick'); 210 if (!$available) { 211 return null; 212 } 213 214 $binary = id(new PhabricatorImagemagickSetupCheck()) 215 ->getImageMagickBinaryName(); 216 217 // Test of the GIF is an animated GIF. If it's a flat GIF, we'll fall 218 // back to GD. 219 $input = new TempFile(); 220 Filesystem::writeFile($input, $template_data); 221 list($err, $out) = exec_manual('%s %s info:', $binary, $input); 222 if ($err) { 223 return null; 224 } 225 226 $split = phutil_split_lines($out); 227 $frames = count($split); 228 if ($frames <= 1) { 229 return null; 230 } 231 232 // Split the frames apart, transform each frame, then merge them back 233 // together. 234 $output = new TempFile(); 235 236 $future = new ExecFuture( 237 '%s %s -coalesce +adjoin %s_%s', 238 $binary, 239 $input, 240 $input, 241 '%09d'); 242 $future->setTimeout(10)->resolvex(); 243 244 $output_files = array(); 245 for ($ii = 0; $ii < $frames; $ii++) { 246 $frame_name = sprintf('%s_%09d', $input, $ii); 247 $output_name = sprintf('%s_%09d', $output, $ii); 248 249 $output_files[] = $output_name; 250 251 $frame_data = Filesystem::readFile($frame_name); 252 $memed_frame_data = $this->newGDAsset($template, $frame_data); 253 Filesystem::writeFile($output_name, $memed_frame_data); 254 } 255 256 $future = new ExecFuture( 257 '%s -dispose background -loop 0 %Ls %s', 258 $binary, 259 $output_files, 260 $output); 261 $future->setTimeout(10)->resolvex(); 262 263 return Filesystem::readFile($output); 264 } 265 266 private function newGDAsset(PhabricatorFile $template, $data) { 267 $img = imagecreatefromstring($data); 268 if (!$img) { 269 throw new Exception( 270 pht('Failed to imagecreatefromstring() image template data.')); 271 } 272 273 $dx = imagesx($img); 274 $dy = imagesy($img); 275 276 $metrics = $this->getMetrics($dx, $dy); 277 $font = $this->getFont(); 278 $size = $metrics['size']; 279 280 $above = $this->getAboveText(); 281 if ($above !== null && phutil_nonempty_string(trim($above))) { 282 $x = (int)floor(($dx - $metrics['text']['above']['width']) / 2); 283 $y = $metrics['text']['above']['height'] + 12; 284 285 $this->drawText($img, $font, $metrics['size'], $x, $y, $above); 286 } 287 288 $below = $this->getBelowText(); 289 if ($below !== null && phutil_nonempty_string(trim($below))) { 290 $x = (int)floor(($dx - $metrics['text']['below']['width']) / 2); 291 $y = $dy - 12 - $metrics['text']['below']['descend']; 292 293 $this->drawText($img, $font, $metrics['size'], $x, $y, $below); 294 } 295 296 return PhabricatorImageTransformer::saveImageDataInAnyFormat( 297 $img, 298 $template->getMimeType()); 299 } 300 301 private function getFont() { 302 $phabricator_root = dirname(phutil_get_library_root('phabricator')); 303 304 $font_root = $phabricator_root.'/resources/font/'; 305 if (Filesystem::pathExists($font_root.'impact.ttf')) { 306 $font_path = $font_root.'impact.ttf'; 307 } else { 308 $font_path = $font_root.'tuffy.ttf'; 309 } 310 311 return $font_path; 312 } 313 314 private function getMetrics($dim_x, $dim_y) { 315 if ($this->metrics === null) { 316 $font = $this->getFont(); 317 318 $font_max = 72; 319 $font_min = 5; 320 321 $margin_x = 16; 322 $margin_y = 16; 323 324 $last = null; 325 $cursor = floor(($font_max + $font_min) / 2); 326 $min = $font_min; 327 $max = $font_max; 328 329 $texts = array( 330 'above' => $this->getAboveText(), 331 'below' => $this->getBelowText(), 332 ); 333 334 $metrics = null; 335 $best = null; 336 while (true) { 337 $all_fit = true; 338 $text_metrics = array(); 339 foreach ($texts as $key => $text) { 340 if (phutil_nonempty_string($text)) { 341 $box = imagettfbbox($cursor, 0, $font, $text); 342 $height = abs($box[3] - $box[5]); 343 $width = abs($box[0] - $box[2]); 344 345 // This is the number of pixels below the baseline that the 346 // text extends, for example if it has a "y". 347 $descend = $box[3]; 348 349 if (($height + $margin_y) > $dim_y) { 350 $all_fit = false; 351 break; 352 } 353 354 if (($width + $margin_x) > $dim_x) { 355 $all_fit = false; 356 break; 357 } 358 359 $text_metrics[$key]['width'] = $width; 360 $text_metrics[$key]['height'] = $height; 361 $text_metrics[$key]['descend'] = $descend; 362 } 363 } 364 365 if ($all_fit || $best === null) { 366 $best = $cursor; 367 $metrics = $text_metrics; 368 } 369 370 if ($all_fit) { 371 $min = $cursor; 372 } else { 373 $max = $cursor; 374 } 375 376 $last = $cursor; 377 $cursor = floor(($max + $min) / 2); 378 if ($cursor === $last) { 379 break; 380 } 381 } 382 383 $this->metrics = array( 384 'size' => $best, 385 'text' => $metrics, 386 ); 387 } 388 389 return $this->metrics; 390 } 391 392 private function drawText($img, $font, $size, $x, $y, $text) { 393 $text_color = imagecolorallocate($img, 255, 255, 255); 394 $border_color = imagecolorallocate($img, 0, 0, 0); 395 396 $border = 2; 397 for ($xx = ($x - $border); $xx <= ($x + $border); $xx += $border) { 398 for ($yy = ($y - $border); $yy <= ($y + $border); $yy += $border) { 399 if (($xx === $x) && ($yy === $y)) { 400 continue; 401 } 402 imagettftext($img, $size, 0, $xx, $yy, $border_color, $font, $text); 403 } 404 } 405 406 imagettftext($img, $size, 0, $x, $y, $text_color, $font, $text); 407 } 408 409 410}