diff --git a/src/applications/macro/engine/PhabricatorMemeEngine.php b/src/applications/macro/engine/PhabricatorMemeEngine.php index 8e0ffbcbba..f72a161171 100644 --- a/src/applications/macro/engine/PhabricatorMemeEngine.php +++ b/src/applications/macro/engine/PhabricatorMemeEngine.php @@ -1,381 +1,384 @@ viewer = $viewer; return $this; } public function getViewer() { return $this->viewer; } public function setTemplate($template) { $this->template = $template; return $this; } public function getTemplate() { return $this->template; } public function setAboveText($above_text) { $this->aboveText = $above_text; return $this; } public function getAboveText() { return $this->aboveText; } public function setBelowText($below_text) { $this->belowText = $below_text; return $this; } public function getBelowText() { return $this->belowText; } public function getGenerateURI() { return id(new PhutilURI('/macro/meme/')) ->alter('macro', $this->getTemplate()) ->alter('above', $this->getAboveText()) ->alter('below', $this->getBelowText()); } public function newAsset() { $cache = $this->loadCachedFile(); if ($cache) { return $cache; } $template = $this->loadTemplateFile(); if (!$template) { throw new Exception( pht( 'Template "%s" is not a valid template.', $template)); } $hash = $this->newTransformHash(); $asset = $this->newAssetFile($template); $xfile = id(new PhabricatorTransformedFile()) ->setOriginalPHID($template->getPHID()) ->setTransformedPHID($asset->getPHID()) ->setTransform($hash); try { $caught = null; $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); try { $xfile->save(); } catch (Exception $ex) { $caught = $ex; } unset($unguarded); if ($caught) { throw $caught; } return $asset; } catch (AphrontDuplicateKeyQueryException $ex) { $xfile = $this->loadCachedFile(); if (!$xfile) { throw $ex; } return $xfile; } } private function newTransformHash() { $properties = array( 'kind' => 'meme', 'above' => phutil_utf8_strtoupper($this->getAboveText()), 'below' => phutil_utf8_strtoupper($this->getBelowText()), ); $properties = phutil_json_encode($properties); return PhabricatorHash::digestForIndex($properties); } public function loadCachedFile() { $viewer = $this->getViewer(); $template_file = $this->loadTemplateFile(); if (!$template_file) { return null; } $hash = $this->newTransformHash(); $xform = id(new PhabricatorTransformedFile())->loadOneWhere( 'originalPHID = %s AND transform = %s', $template_file->getPHID(), $hash); if (!$xform) { return null; } return id(new PhabricatorFileQuery()) ->setViewer($viewer) ->withPHIDs(array($xform->getTransformedPHID())) ->executeOne(); } private function loadTemplateFile() { if ($this->templateFile === null) { $viewer = $this->getViewer(); $template = $this->getTemplate(); $macro = id(new PhabricatorMacroQuery()) ->setViewer($viewer) ->withNames(array($template)) ->needFiles(true) ->executeOne(); if (!$macro) { return null; } $this->templateFile = $macro->getFile(); } return $this->templateFile; } private function newAssetFile(PhabricatorFile $template) { $data = $this->newAssetData($template); return PhabricatorFile::newFromFileData( $data, array( 'name' => 'meme-'.$template->getName(), - 'ttl.relative' => phutil_units('24 hours in seconds'), 'canCDN' => true, + + // In modern code these can end up linked directly in email, so let + // them stick around for a while. + 'ttl.relative' => phutil_units('30 days in seconds'), )); } private function newAssetData(PhabricatorFile $template) { $template_data = $template->loadFileData(); $result = $this->newImagemagickAsset($template, $template_data); if ($result) { return $result; } return $this->newGDAsset($template, $template_data); } private function newImagemagickAsset( PhabricatorFile $template, $template_data) { // We're only going to use Imagemagick on GIFs. $mime_type = $template->getMimeType(); if ($mime_type != 'image/gif') { return null; } // We're only going to use Imagemagick if it is actually available. $available = PhabricatorEnv::getEnvConfig('files.enable-imagemagick'); if (!$available) { return null; } // Test of the GIF is an animated GIF. If it's a flat GIF, we'll fall // back to GD. $input = new TempFile(); Filesystem::writeFile($input, $template_data); list($err, $out) = exec_manual('convert %s info:', $input); if ($err) { return null; } $split = phutil_split_lines($out); $frames = count($split); if ($frames <= 1) { return null; } // Split the frames apart, transform each frame, then merge them back // together. $output = new TempFile(); $future = new ExecFuture( 'convert %s -coalesce +adjoin %s_%s', $input, $input, '%09d'); $future->setTimeout(10)->resolvex(); $output_files = array(); for ($ii = 0; $ii < $frames; $ii++) { $frame_name = sprintf('%s_%09d', $input, $ii); $output_name = sprintf('%s_%09d', $output, $ii); $output_files[] = $output_name; $frame_data = Filesystem::readFile($frame_name); $memed_frame_data = $this->newGDAsset($template, $frame_data); Filesystem::writeFile($output_name, $memed_frame_data); } $future = new ExecFuture('convert -loop 0 %Ls %s', $output_files, $output); $future->setTimeout(10)->resolvex(); return Filesystem::readFile($output); } private function newGDAsset(PhabricatorFile $template, $data) { $img = imagecreatefromstring($data); if (!$img) { throw new Exception( pht('Failed to imagecreatefromstring() image template data.')); } $dx = imagesx($img); $dy = imagesy($img); $metrics = $this->getMetrics($dx, $dy); $font = $this->getFont(); $size = $metrics['size']; $above = $this->getAboveText(); if (strlen($above)) { $x = (int)floor(($dx - $metrics['text']['above']['width']) / 2); $y = $metrics['text']['above']['height'] + 12; $this->drawText($img, $font, $metrics['size'], $x, $y, $above); } $below = $this->getBelowText(); if (strlen($below)) { $x = (int)floor(($dx - $metrics['text']['below']['width']) / 2); $y = $dy - 12 - $metrics['text']['below']['descend']; $this->drawText($img, $font, $metrics['size'], $x, $y, $below); } return PhabricatorImageTransformer::saveImageDataInAnyFormat( $img, $template->getMimeType()); } private function getFont() { $phabricator_root = dirname(phutil_get_library_root('phabricator')); $font_root = $phabricator_root.'/resources/font/'; if (Filesystem::pathExists($font_root.'impact.ttf')) { $font_path = $font_root.'impact.ttf'; } else { $font_path = $font_root.'tuffy.ttf'; } return $font_path; } private function getMetrics($dim_x, $dim_y) { if ($this->metrics === null) { $font = $this->getFont(); $font_max = 72; $font_min = 5; $last = null; $cursor = floor(($font_max + $font_min) / 2); $min = $font_min; $max = $font_max; $texts = array( 'above' => $this->getAboveText(), 'below' => $this->getBelowText(), ); $metrics = null; $best = null; while (true) { $all_fit = true; $text_metrics = array(); foreach ($texts as $key => $text) { $box = imagettfbbox($cursor, 0, $font, $text); $height = abs($box[3] - $box[5]); $width = abs($box[0] - $box[2]); // This is the number of pixels below the baseline that the // text extends, for example if it has a "y". $descend = $box[3]; if ($height > $dim_y) { $all_fit = false; break; } if ($width > $dim_x) { $all_fit = false; break; } $text_metrics[$key]['width'] = $width; $text_metrics[$key]['height'] = $height; $text_metrics[$key]['descend'] = $descend; } if ($all_fit || $best === null) { $best = $cursor; $metrics = $text_metrics; } if ($all_fit) { $min = $cursor; } else { $max = $cursor; } $last = $cursor; $cursor = floor(($max + $min) / 2); if ($cursor === $last) { break; } } $this->metrics = array( 'size' => $best, 'text' => $metrics, ); } return $this->metrics; } private function drawText($img, $font, $size, $x, $y, $text) { $text_color = imagecolorallocate($img, 255, 255, 255); $border_color = imagecolorallocate($img, 0, 0, 0); $border = 2; for ($xx = ($x - $border); $xx <= ($x + $border); $xx += $border) { for ($yy = ($y - $border); $yy <= ($y + $border); $yy += $border) { if (($xx === $x) && ($yy === $y)) { continue; } imagettftext($img, $size, 0, $xx, $yy, $border_color, $font, $text); } } imagettftext($img, $size, 0, $x, $y, $text_color, $font, $text); } } diff --git a/src/applications/macro/markup/PhabricatorMemeRemarkupRule.php b/src/applications/macro/markup/PhabricatorMemeRemarkupRule.php index cdee10676c..eacb1a33ed 100644 --- a/src/applications/macro/markup/PhabricatorMemeRemarkupRule.php +++ b/src/applications/macro/markup/PhabricatorMemeRemarkupRule.php @@ -1,76 +1,103 @@ isFlatText($matches[0])) { return $matches[0]; } $options = array( 'src' => null, 'above' => null, 'below' => null, ); $parser = new PhutilSimpleOptions(); $options = $parser->parse($matches[1]) + $options; $engine = id(new PhabricatorMemeEngine()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->setTemplate($options['src']) ->setAboveText($options['above']) ->setBelowText($options['below']); $asset = $engine->loadCachedFile(); - $uri = $engine->getGenerateURI(); - if ($this->getEngine()->isHTMLMailMode()) { - $uri = PhabricatorEnv::getProductionURI($uri); + $is_html_mail = $this->getEngine()->isHTMLMailMode(); + $is_text = $this->getEngine()->isTextMode(); + $must_inline = ($is_html_mail || $is_text); + + if ($must_inline) { + if (!$asset) { + try { + $asset = $engine->newAsset(); + } catch (Exception $ex) { + return $matches[0]; + } + } } - if ($this->getEngine()->isTextMode()) { - $img = - ($options['above'] != '' ? "\"{$options['above']}\"\n" : ''). - $options['src'].' <'.PhabricatorEnv::getProductionURI($uri).'>'. - ($options['below'] != '' ? "\n\"{$options['below']}\"" : ''); + if ($asset) { + $uri = $asset->getViewURI(); } else { - $alt_text = pht( - 'Macro %s: %s %s', - $options['src'], - $options['above'], - $options['below']); - - if ($asset) { - $img = $this->newTag( - 'img', - array( - 'src' => $asset->getViewURI(), - 'class' => 'phabricator-remarkup-macro', - 'alt' => $alt_text, - )); - } else { - $img = id(new PHUIRemarkupImageView()) - ->setURI($uri) - ->addClass('phabricator-remarkup-macro') - ->setAlt($alt_text); + $uri = $engine->getGenerateURI(); + } + + if ($is_text) { + $parts = array(); + + $above = $options['above']; + if (strlen($above)) { + $parts[] = pht('"%s"', $above); } + + $parts[] = $options['src'].' <'.$uri.'>'; + + $below = $options['below']; + if (strlen($below)) { + $parts[] = pht('"%s"', $below); + } + + $parts = implode("\n", $parts); + return $this->getEngine()->storeText($parts); + } + + $alt_text = pht( + 'Macro %s: %s %s', + $options['src'], + $options['above'], + $options['below']); + + if ($asset) { + $img = $this->newTag( + 'img', + array( + 'src' => $uri, + 'class' => 'phabricator-remarkup-macro', + 'alt' => $alt_text, + )); + } else { + $img = id(new PHUIRemarkupImageView()) + ->setURI($uri) + ->addClass('phabricator-remarkup-macro') + ->setAlt($alt_text); } return $this->getEngine()->storeText($img); } }