Path: blob/master/src/applications/macro/engine/PhabricatorMemeEngine.php
12241 views
<?php12final class PhabricatorMemeEngine extends Phobject {34private $viewer;5private $template;6private $aboveText;7private $belowText;89private $templateFile;10private $metrics;1112public function setViewer(PhabricatorUser $viewer) {13$this->viewer = $viewer;14return $this;15}1617public function getViewer() {18return $this->viewer;19}2021public function setTemplate($template) {22$this->template = $template;23return $this;24}2526public function getTemplate() {27return $this->template;28}2930public function setAboveText($above_text) {31$this->aboveText = $above_text;32return $this;33}3435public function getAboveText() {36return $this->aboveText;37}3839public function setBelowText($below_text) {40$this->belowText = $below_text;41return $this;42}4344public function getBelowText() {45return $this->belowText;46}4748public function getGenerateURI() {49$params = array(50'macro' => $this->getTemplate(),51'above' => $this->getAboveText(),52'below' => $this->getBelowText(),53);5455return new PhutilURI('/macro/meme/', $params);56}5758public function newAsset() {59$cache = $this->loadCachedFile();60if ($cache) {61return $cache;62}6364$template = $this->loadTemplateFile();65if (!$template) {66throw new Exception(67pht(68'Template "%s" is not a valid template.',69$template));70}7172$hash = $this->newTransformHash();7374$asset = $this->newAssetFile($template);7576$xfile = id(new PhabricatorTransformedFile())77->setOriginalPHID($template->getPHID())78->setTransformedPHID($asset->getPHID())79->setTransform($hash);8081try {82$caught = null;8384$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();85try {86$xfile->save();87} catch (Exception $ex) {88$caught = $ex;89}90unset($unguarded);9192if ($caught) {93throw $caught;94}9596return $asset;97} catch (AphrontDuplicateKeyQueryException $ex) {98$xfile = $this->loadCachedFile();99if (!$xfile) {100throw $ex;101}102return $xfile;103}104}105106private function newTransformHash() {107$properties = array(108'kind' => 'meme',109'above' => $this->getAboveText(),110'below' => $this->getBelowText(),111);112113$properties = phutil_json_encode($properties);114115return PhabricatorHash::digestForIndex($properties);116}117118public function loadCachedFile() {119$viewer = $this->getViewer();120121$template_file = $this->loadTemplateFile();122if (!$template_file) {123return null;124}125126$hash = $this->newTransformHash();127128$xform = id(new PhabricatorTransformedFile())->loadOneWhere(129'originalPHID = %s AND transform = %s',130$template_file->getPHID(),131$hash);132if (!$xform) {133return null;134}135136return id(new PhabricatorFileQuery())137->setViewer($viewer)138->withPHIDs(array($xform->getTransformedPHID()))139->executeOne();140}141142private function loadTemplateFile() {143if ($this->templateFile === null) {144$viewer = $this->getViewer();145$template = $this->getTemplate();146147$macro = id(new PhabricatorMacroQuery())148->setViewer($viewer)149->withNames(array($template))150->needFiles(true)151->executeOne();152if (!$macro) {153return null;154}155156$this->templateFile = $macro->getFile();157}158159return $this->templateFile;160}161162private function newAssetFile(PhabricatorFile $template) {163$data = $this->newAssetData($template);164return PhabricatorFile::newFromFileData(165$data,166array(167'name' => 'meme-'.$template->getName(),168'canCDN' => true,169170// In modern code these can end up linked directly in email, so let171// them stick around for a while.172'ttl.relative' => phutil_units('30 days in seconds'),173));174}175176private function newAssetData(PhabricatorFile $template) {177$template_data = $template->loadFileData();178179// When we aren't adding text, just return the data unmodified. This saves180// us from doing expensive stitching when we aren't actually making any181// changes to the image.182$above_text = coalesce($this->getAboveText(), '');183$below_text = coalesce($this->getBelowText(), '');184if (!strlen(trim($above_text)) && !strlen(trim($below_text))) {185return $template_data;186}187188$result = $this->newImagemagickAsset($template, $template_data);189if ($result) {190return $result;191}192193return $this->newGDAsset($template, $template_data);194}195196private function newImagemagickAsset(197PhabricatorFile $template,198$template_data) {199200// We're only going to use Imagemagick on GIFs.201$mime_type = $template->getMimeType();202if ($mime_type != 'image/gif') {203return null;204}205206// We're only going to use Imagemagick if it is actually available.207$available = PhabricatorEnv::getEnvConfig('files.enable-imagemagick');208if (!$available) {209return null;210}211212// Test of the GIF is an animated GIF. If it's a flat GIF, we'll fall213// back to GD.214$input = new TempFile();215Filesystem::writeFile($input, $template_data);216list($err, $out) = exec_manual('convert %s info:', $input);217if ($err) {218return null;219}220221$split = phutil_split_lines($out);222$frames = count($split);223if ($frames <= 1) {224return null;225}226227// Split the frames apart, transform each frame, then merge them back228// together.229$output = new TempFile();230231$future = new ExecFuture(232'convert %s -coalesce +adjoin %s_%s',233$input,234$input,235'%09d');236$future->setTimeout(10)->resolvex();237238$output_files = array();239for ($ii = 0; $ii < $frames; $ii++) {240$frame_name = sprintf('%s_%09d', $input, $ii);241$output_name = sprintf('%s_%09d', $output, $ii);242243$output_files[] = $output_name;244245$frame_data = Filesystem::readFile($frame_name);246$memed_frame_data = $this->newGDAsset($template, $frame_data);247Filesystem::writeFile($output_name, $memed_frame_data);248}249250$future = new ExecFuture(251'convert -dispose background -loop 0 %Ls %s',252$output_files,253$output);254$future->setTimeout(10)->resolvex();255256return Filesystem::readFile($output);257}258259private function newGDAsset(PhabricatorFile $template, $data) {260$img = imagecreatefromstring($data);261if (!$img) {262throw new Exception(263pht('Failed to imagecreatefromstring() image template data.'));264}265266$dx = imagesx($img);267$dy = imagesy($img);268269$metrics = $this->getMetrics($dx, $dy);270$font = $this->getFont();271$size = $metrics['size'];272273$above = coalesce($this->getAboveText(), '');274if (strlen($above)) {275$x = (int)floor(($dx - $metrics['text']['above']['width']) / 2);276$y = $metrics['text']['above']['height'] + 12;277278$this->drawText($img, $font, $metrics['size'], $x, $y, $above);279}280281$below = coalesce($this->getBelowText(), '');282if (strlen($below)) {283$x = (int)floor(($dx - $metrics['text']['below']['width']) / 2);284$y = $dy - 12 - $metrics['text']['below']['descend'];285286$this->drawText($img, $font, $metrics['size'], $x, $y, $below);287}288289return PhabricatorImageTransformer::saveImageDataInAnyFormat(290$img,291$template->getMimeType());292}293294private function getFont() {295$phabricator_root = dirname(phutil_get_library_root('phabricator'));296297$font_root = $phabricator_root.'/resources/font/';298if (Filesystem::pathExists($font_root.'impact.ttf')) {299$font_path = $font_root.'impact.ttf';300} else {301$font_path = $font_root.'tuffy.ttf';302}303304return $font_path;305}306307private function getMetrics($dim_x, $dim_y) {308if ($this->metrics === null) {309$font = $this->getFont();310311$font_max = 72;312$font_min = 5;313314$margin_x = 16;315$margin_y = 16;316317$last = null;318$cursor = floor(($font_max + $font_min) / 2);319$min = $font_min;320$max = $font_max;321322$texts = array(323'above' => $this->getAboveText(),324'below' => $this->getBelowText(),325);326327$metrics = null;328$best = null;329while (true) {330$all_fit = true;331$text_metrics = array();332foreach ($texts as $key => $text) {333$text = coalesce($text, '');334$box = imagettfbbox($cursor, 0, $font, $text);335$height = abs($box[3] - $box[5]);336$width = abs($box[0] - $box[2]);337338// This is the number of pixels below the baseline that the339// text extends, for example if it has a "y".340$descend = $box[3];341342if (($height + $margin_y) > $dim_y) {343$all_fit = false;344break;345}346347if (($width + $margin_x) > $dim_x) {348$all_fit = false;349break;350}351352$text_metrics[$key]['width'] = $width;353$text_metrics[$key]['height'] = $height;354$text_metrics[$key]['descend'] = $descend;355}356357if ($all_fit || $best === null) {358$best = $cursor;359$metrics = $text_metrics;360}361362if ($all_fit) {363$min = $cursor;364} else {365$max = $cursor;366}367368$last = $cursor;369$cursor = floor(($max + $min) / 2);370if ($cursor === $last) {371break;372}373}374375$this->metrics = array(376'size' => $best,377'text' => $metrics,378);379}380381return $this->metrics;382}383384private function drawText($img, $font, $size, $x, $y, $text) {385$text_color = imagecolorallocate($img, 255, 255, 255);386$border_color = imagecolorallocate($img, 0, 0, 0);387388$border = 2;389for ($xx = ($x - $border); $xx <= ($x + $border); $xx += $border) {390for ($yy = ($y - $border); $yy <= ($y + $border); $yy += $border) {391if (($xx === $x) && ($yy === $y)) {392continue;393}394imagettftext($img, $size, 0, $xx, $yy, $border_color, $font, $text);395}396}397398imagettftext($img, $size, 0, $x, $y, $text_color, $font, $text);399}400401402}403404405