diff --git a/src/applications/files/PhabricatorImageTransformer.php b/src/applications/files/PhabricatorImageTransformer.php --- a/src/applications/files/PhabricatorImageTransformer.php +++ b/src/applications/files/PhabricatorImageTransformer.php @@ -6,193 +6,6 @@ */ final class PhabricatorImageTransformer extends Phobject { - public function executeMemeTransform( - PhabricatorFile $file, - $upper_text, - $lower_text) { - $image = $this->applyMemeToFile($file, $upper_text, $lower_text); - return PhabricatorFile::newFromFileData( - $image, - array( - 'name' => 'meme-'.$file->getName(), - 'ttl.relative' => phutil_units('24 hours in seconds'), - 'canCDN' => true, - )); - } - - private function applyMemeToFile( - PhabricatorFile $file, - $upper_text, - $lower_text) { - $data = $file->loadFileData(); - - $img_type = $file->getMimeType(); - $imagemagick = PhabricatorEnv::getEnvConfig('files.enable-imagemagick'); - - if ($img_type != 'image/gif' || $imagemagick == false) { - return $this->applyMemeTo( - $data, $upper_text, $lower_text, $img_type); - } - - $data = $file->loadFileData(); - $input = new TempFile(); - Filesystem::writeFile($input, $data); - - list($out) = execx('convert %s info:', $input); - $split = phutil_split_lines($out); - if (count($split) > 1) { - return $this->applyMemeWithImagemagick( - $input, - $upper_text, - $lower_text, - count($split), - $img_type); - } else { - return $this->applyMemeTo($data, $upper_text, $lower_text, $img_type); - } - } - - private function applyMemeTo( - $data, - $upper_text, - $lower_text, - $mime_type) { - $img = imagecreatefromstring($data); - - // Some PNGs have color palettes, and allocating the dark border color - // fails and gives us whatever's first in the color table. Copy the image - // to a fresh truecolor canvas before working with it. - - $truecolor = imagecreatetruecolor(imagesx($img), imagesy($img)); - imagecopy($truecolor, $img, 0, 0, 0, 0, imagesx($img), imagesy($img)); - $img = $truecolor; - - $phabricator_root = dirname(phutil_get_library_root('phabricator')); - $font_root = $phabricator_root.'/resources/font/'; - $font_path = $font_root.'tuffy.ttf'; - if (Filesystem::pathExists($font_root.'impact.ttf')) { - $font_path = $font_root.'impact.ttf'; - } - $text_color = imagecolorallocate($img, 255, 255, 255); - $border_color = imagecolorallocatealpha($img, 0, 0, 0, 110); - $border_width = 4; - $font_max = 200; - $font_min = 5; - for ($i = $font_max; $i > $font_min; $i--) { - $fit = $this->doesTextBoundingBoxFitInImage( - $img, - $upper_text, - $i, - $font_path); - if ($fit['doesfit']) { - $x = ($fit['imgwidth'] - $fit['txtwidth']) / 2; - $y = $fit['txtheight'] + 10; - $this->makeImageWithTextBorder($img, - $i, - $x, - $y, - $text_color, - $border_color, - $border_width, - $font_path, - $upper_text); - break; - } - } - for ($i = $font_max; $i > $font_min; $i--) { - $fit = $this->doesTextBoundingBoxFitInImage($img, - $lower_text, $i, $font_path); - if ($fit['doesfit']) { - $x = ($fit['imgwidth'] - $fit['txtwidth']) / 2; - $y = $fit['imgheight'] - 10; - $this->makeImageWithTextBorder( - $img, - $i, - $x, - $y, - $text_color, - $border_color, - $border_width, - $font_path, - $lower_text); - break; - } - } - return self::saveImageDataInAnyFormat($img, $mime_type); - } - - private function makeImageWithTextBorder($img, $font_size, $x, $y, - $color, $stroke_color, $bw, $font, $text) { - $angle = 0; - $bw = abs($bw); - for ($c1 = $x - $bw; $c1 <= $x + $bw; $c1++) { - for ($c2 = $y - $bw; $c2 <= $y + $bw; $c2++) { - if (!(($c1 == $x - $bw || $x + $bw) && - $c2 == $y - $bw || $c2 == $y + $bw)) { - $bg = imagettftext($img, $font_size, - $angle, $c1, $c2, $stroke_color, $font, $text); - } - } - } - imagettftext($img, $font_size, $angle, - $x , $y, $color , $font, $text); - } - - private function doesTextBoundingBoxFitInImage($img, - $text, $font_size, $font_path) { - // Default Angle = 0 - $angle = 0; - - $bbox = imagettfbbox($font_size, $angle, $font_path, $text); - $text_height = abs($bbox[3] - $bbox[5]); - $text_width = abs($bbox[0] - $bbox[2]); - return array( - 'doesfit' => ($text_height * 1.05 <= imagesy($img) / 2 - && $text_width * 1.05 <= imagesx($img)), - 'txtwidth' => $text_width, - 'txtheight' => $text_height, - 'imgwidth' => imagesx($img), - 'imgheight' => imagesy($img), - ); - } - - private function applyMemeWithImagemagick( - $input, - $above, - $below, - $count, - $img_type) { - - $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 < $count; $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->applyMemeTo( - $frame_data, - $above, - $below, - $img_type); - 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); - } - /* -( Saving Image Data )-------------------------------------------------- */ diff --git a/src/applications/macro/engine/PhabricatorMemeEngine.php b/src/applications/macro/engine/PhabricatorMemeEngine.php --- a/src/applications/macro/engine/PhabricatorMemeEngine.php +++ b/src/applications/macro/engine/PhabricatorMemeEngine.php @@ -8,6 +8,7 @@ private $belowText; private $templateFile; + private $metrics; public function setViewer(PhabricatorUser $viewer) { $this->viewer = $viewer; @@ -68,11 +69,7 @@ $hash = $this->newTransformHash(); - $transformer = new PhabricatorImageTransformer(); - $asset = $transformer->executeMemeTransform( - $template, - $this->getAboveText(), - $this->getBelowText()); + $asset = $this->newAssetFile($template); $xfile = id(new PhabricatorTransformedFile()) ->setOriginalPHID($template->getPHID()) @@ -160,4 +157,225 @@ 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, + )); + } + + 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); + } + + }