* @package WB * @subpackage vfs */ WBClass::load('WBVFS_Mime'); /** * Virtual File System: Mime * * This Mime handler requires ImageMagick * * @version 1.3.1 * @package WB * @subpackage vfs */ class WBVFS_Mime_Image extends WBVFS_Mime { /** * max image size for import */ const IMAGE_SIZE_MAX = 6000; /** * max image size to resize to */ const IMAGE_RESIZE_MAX = 3000; /** * max factor to resize images */ const IMAGE_RESIZE_MAX_FACTOR = 5; /** * Factor for panorama image aspect */ const IMAGE_PANORAMA_DETECT_FACTOR = 3; /** * Factor for panorama image aspect */ const IMAGE_PANORAMA_RESIZE_MAX_FACTOR = 10; /** * mogrify command * @link http://www.imagemagick.org * @var string */ protected static $magickMogrify = 'mogrify'; /** * convert command * @link http://www.imagemagick.org * @var string */ protected static $magickConvert = 'convert'; /** * rotate image accoring to exif header * @var string */ protected static $exifAutotran = 'exifautotran'; /** * too to convert PDFs to images * @var string */ protected static $pdftoppm = 'pdftoppm'; /** * ffmpeg command * @var string */ protected static $ffmpeg = 'ffmpeg'; /** * @var WBConfig */ static protected $config; /** * list of mime types that are convertable to image * * Can convert * - video * * @var array */ protected $convertable = array( 'video', 'text', 'audio', 'application' ); /** * major mime type * @var string */ protected $mimeMajor = 'image'; /** * 2nd Constructor * */ protected function init() { } /** * Static Constrctor * * initialize tools */ static public function staticConstruct() { self::$ffmpeg = WBParam::get('wb/vfs/mime/ffmpeg', self::$ffmpeg); self::$magickMogrify = WBParam::get('wb/vfs/mime/imagemagick/mogrify', self::$magickMogrify); self::$magickConvert = WBParam::get('wb/vfs/mime/imagemagick/convert', self::$magickConvert); self::$pdftoppm = WBParam::get('wb/vfs/mime/pdftoppm', self::$pdftoppm); self::$exifAutotran = WBParam::get('wb/vfs/mime/exif/autotran', self::$exifAutotran); self::$config = WBClass::create('WBConfig'); self::$config->load('vfs/transcode/image'); } /** * inform when file has been set * * Resolve file name extension * @param string $name */ protected function onSetFile($name = '') { $this->onSetVirtualFile(); $this->resolveExtension(); } /** * Inform when virtual file was set * * Set default mime type image/jpeg */ protected function onSetVirtualFile() { switch ($this->mimeMajor) { case 'image': break; default: $this->mimeMajor = 'image'; $this->mimeMinor = 'jpeg'; break; } $this->resolveExtension(); } /** * get information about this file * * Extract from image: * - width * - height * - par (pixel aspect ration) * - exif header * - panorama mode * * @return array */ public function getInfo() { $size = getimagesize($this->file); $size[0] = max(1, $size[0]); $size[1] = max(1, $size[1]); $this->info = array( 'width' => $size[0], 'height' => $size[1], 'exif' => array(), 'panorama' => $this->detectPanoramaMode($size) ); switch ($this->mimeMinor) { case 'jpeg': case 'tiff': $exif = @exif_read_data($this->file); if (!is_array($exif)) { break; } // remove obvious exif headers $exifIgnore = array('filename', 'filedatetime', 'filesize', 'filetype', 'mimetype', 'computed'); foreach ($exif as $key => $value) { if (in_array(strtolower($key), $exifIgnore)) { continue; } if (is_scalar($value) && strlen($value) > 1000) { continue; } $this->info['exif'][$key] = $value; } break; } return $this->info; } /** * Detect Panorama Mode by Size * * @param array $size * @return string 'x', 'y' or '' */ private function detectPanoramaMode($size) { // detect panorama mode if ($size[0] > (self::IMAGE_PANORAMA_DETECT_FACTOR * $size[1])) { return 'x'; } if ($size[1] > (self::IMAGE_PANORAMA_DETECT_FACTOR * $size[0])) { return 'y'; } return ''; } /** * execute command * * Available commands: * - rotate * - flip/flop * - crop * - grayscale * - monochrome * * @todo implement commands * @param string $cmd * @param array $arg */ public function execute($cmd, $arg = array()) { $log = array( 'file' => $this->vfile->getId(), 'mime' => $this->mimeMajor, 'cmd' => $cmd, 'status' => 'done' ); /** @var $file WBFile */ $file = WBClass::create('WBFile'); $file->tempnam('img'); $file->copy($this->vfile->getPath() . '.org'); $cmd = trim(strtolower($cmd)); switch($cmd) { case 'rotate': $angle = intval($arg['angle']) % 360; $exe = sprintf('%s -rotate %s %s', self::$magickMogrify, $angle, $file->realpath()); break; case 'grayscale': $exe = sprintf('%s -colorspace Gray %s', self::$magickMogrify, $file->realpath()); break; case 'crop': $exe = $this->prepareCropCommand($file, $arg); if (empty($exe)) { $log['status'] = 'cropinvalid'; $this->log->err($log); return; } break; case 'monochrome': case 'flip': case 'flop': $exe = sprintf('%s -%s %s', self::$magickMogrify, $cmd, $file->realpath()); break; default: $log['status'] = 'commandunknown'; $this->log->err($log); return; break; } exec($exe, $out, $ret); if ($ret > 0) { $file->unlink(); return; } // copy attributes $save = array( 'dir' => $this->vfile->getDir(), 'description' => $this->vfile->getDescription() ); $name = $this->vfile->getName(); // what to do with the orginal? switch ($arg['original']) { case 'replace': copy($file->realpath(), $this->vfile->getPath() . '.org'); $this->vfile->reimport(); break; case 'remove': $this->vfile->delete(); // fall through to create a new VFS file default: case 'copy': $new = $this->vfile->import($file->realpath(), $name); if (!$new->isOK()) { $file->unlink(); return; } $new->save($save); $this->vfile = $new; break; } $this->log->notice($log); } /** * Prepare Command for Crop * * @param WBFile * @param array * @return string */ private function prepareCropCommand(WBFile $file, $arg) { // check valid options if (empty($arg['aspect'])) { return ''; } $org = array($this->vfile->getInfo('width'), $this->vfile->getInfo('height')); $aspect = explode('x', $arg['aspect']); if (2 < count($aspect)) { return ''; } $aspect[0] = max(1, $aspect[0]); $aspect[1] = max(1, $aspect[1]); $factor = $aspect[0] / $aspect[1]; $dim = $org; $off = array(0, 0); if ($aspect[0] > $aspect[1]) { $dim[1] = $dim[0] / $factor; $off[1] = ($org[1] - $dim[1]) / 2; } else { return ''; } $exe = sprintf('%s -crop %dx%d+%d+%d %s', self::$magickMogrify, $dim[0], $dim[1], $off[0], $off[1], $file->realpath()); return $exe; } /** * import file * * Check whether image extends max-size and resize it * Convert non-web-format images to JPEG */ public function import() { // scalable vector graphic - use the original if ('svg+xml' == $this->mimeMinor) { return; } // resize to max size $size = getimagesize($this->file); // build mogrify command $cmd = array( self::$magickConvert, $this->file, '-strip', '-colorspace sRGB' ); // resizing image during import increased speed of further image processing switch ($this->info['panorama']) { case 'x': if ($size[1] > self::IMAGE_SIZE_MAX) { $cmd[] = '-resize ' . (self::IMAGE_PANORAMA_RESIZE_MAX_FACTOR * self::IMAGE_SIZE_MAX) . 'x' . self::IMAGE_SIZE_MAX; } break; case 'y': if ($size[0] > self::IMAGE_SIZE_MAX) { $cmd[] = '-resize ' . self::IMAGE_SIZE_MAX . 'x' . (self::IMAGE_PANORAMA_RESIZE_MAX_FACTOR * self::IMAGE_SIZE_MAX); } break; default: case '': if (max($size[0], $size[1] ) > self::IMAGE_SIZE_MAX) { $cmd[] = '-resize ' . self::IMAGE_SIZE_MAX . 'x' . self::IMAGE_SIZE_MAX; } break; } // make image format usable for web $ext = 'jpeg'; switch ($this->mimeMinor) { case 'svg+xml': // this should never happen - see shortcut above return; break; case 'gif': case 'png': case 'webp': case 'avif': $ext = $this->mimeMinor; break; case 'jpeg': // rotate image according to orientation stored in exif header exec(self::$exifAutotran . ' ' . $this->file, $out, $ret); break; default: $cmd[] = '-format jpeg'; $cmd[] = '-fill white'; $cmd[] = '-opaque none'; break; } $cmd[] = $this->file . '.' . $ext; $cmd = implode(' ', $cmd); $log = array( 'mime' => 'image', 'action' => 'import', 'exec' => $cmd ); $this->log->info($log); exec($cmd, $out, $ret); /* * some image formats like Adobe's Photoshop provide multiple * layers. Mogrify extracts all laysers and converts them * to individual JPEG images. Image #0 represents the merged * layers * * Therefore import #0 and trash all layer images */ if (file_exists($this->file . '-0.' . $ext)) { rename($this->file . '-0.' . $ext, $this->file); exec('rm ' . $this->file . '-*'); return; } // remove extension if (file_exists($this->file . '.' . $ext)) { rename($this->file . '.' . $ext, $this->file); } return; } /** * get file name of requested file * * @return string */ protected function doGetRequestedFile(&$redirect) { $this->incrementable = false; $this->file = $this->vfile->getPath(); $major = $this->vfile->getMime(WBVFS_File::MIME_MAJOR); $minor = $this->vfile->getMime(WBVFS_File::MIME_MINOR); switch ($this->requestedMimeMinor) { case 'avif': case 'jpeg': case 'png': case 'webp': case 'gif': case 'jp2': $minor = $this->requestedMimeMinor; break; case 'svg'; $minor = 'svg+xml'; break; default: break; } switch ($major) { case 'image': $org = array($this->vfile->getInfo('width'), $this->vfile->getInfo('height')); $this->mimeMinor = $minor; // just deliver file, don't resize, because it is scalable anyway if ('svg+xml' == $minor) { return $this->file; } break; case 'video': $this->mimeMinor = $minor; $org = array($this->vfile->getInfo('width'), $this->vfile->getInfo('height')); $this->convertVideo($org); break; case 'application': if ($this->convertApplication($this->vfile->getMime(WBVFS_File::MIME_MINOR), $org)) { break; } default; $org = array(64, 64); $this->file = $this->getIcon($major, $this->vfile->getMime(WBVFS_File::MIME_MINOR)); break; } // increment view counter if ($this->resize($org, $redirect) && $major == 'image') { // only for real images $size = explode('x', $this->req->get('size', '')); if (!count($size) == 2 || !is_numeric($size[0]) || !is_numeric($size[1])) { $size = $org; } // must have resonable size, like 300x300 $res = array(300, 300); // for small images, half of the image's file is suffice if ($org[0] < $res[0]) { $res[0] = floor($org[0] / 2); } if ($org[1] < $res[1]) { $res[1] = floor($org[1] / 2); } if ($res[0] < $size[0] && $res[1] < $size[1]) { $this->incrementable = true; } } $this->resolveExtension(); return $this->file; } /** * tell file name extension * * Calculate suitable file name extension accotding * to minor mime type * @param string minor mime type */ protected function resolveExtension($mimeMinor = null) { if (empty($mimeMinor)) { $mimeMinor = $this->mimeMinor; } // figure out file extension switch ($mimeMinor) { case 'svg+xml': $this->extension = 'svg'; break; case 'png': $this->extension = 'png'; break; case 'gif': $this->extension = 'gif'; break; case 'webp': $this->extension = 'webp'; break; case 'avif': $this->extension = 'avif'; break; default: case 'jpg': $this->extension = 'jpg'; break; } } /** * Get path of icon * * Try to find icon path for mime type * * @param string $major * @param string $minor * @return string */ protected function getIcon($major, $minor) { $this->mimeMinor = 'png'; $this->resolveExtension(); // application icons if ($major == 'application') { $icon = $major . '-' . $minor; $file = WBParam::get('wb/dir/base') . '/resource/mime/icon/' . $icon . '.png'; if (!file_exists($file)) { $file = WBParam::get('wb/dir/system') . '/resource/mime/icon/' . $icon . '.png'; } if (file_exists($file)) { return $file; } } // icons by major mime type $icon = $major; if (!in_array($major, array('text', 'audio', 'application'))) { $icon = 'unknown'; } // get icon for major mime type $file = WBParam::get('wb/dir/base') . '/resource/mime/icon/' . $icon . '.png'; if (!file_exists($file)) { $file = WBParam::get('wb/dir/system') . '/resource/mime/icon/' . $icon . '.png'; } return $file; } /** * Convert application/* to image * * Some application file are convertable to images. Support for the * the following sub-mime-types: * - pdf * @param string $minor * @param array $org * @return bool true if mime type is convertable */ private function convertApplication($minor, &$org) { if ('pdf' != $minor) { return false; } $org = array(); $cacheDir = $this->mkCacheDir(); // convert PDF to png if (!file_exists($cacheDir . '/image.jpg')) { $cmd = array(self::$pdftoppm); //$cmd[] = '-jpeg'; $cmd[] = '-f 1'; $cmd[] = '-l 1'; $cmd[] = $this->file; $cmd[] = $cacheDir . '/image'; $cmd = implode(' ', $cmd); $log = array( 'mime' => 'image', 'action' => 'convert-pdf', 'exec' => $cmd ); $this->log->info($log); exec($cmd, $out, $ret); // depending on the amount of pages, zero or more "0" will be added to the filename for ($i = 0; $i < 5; ++$i) { $imageFilename = $cacheDir . '/image-'. str_repeat('0', $i) .'1.ppm'; if (file_exists($imageFilename)) { rename($imageFilename, $cacheDir . '/image.ppm'); break; } } if (file_exists($cacheDir . '/image.ppm')) { $cmd = array(self::$magickMogrify); $cmd[] = '-format jpeg'; $cmd[] = $cacheDir . '/image.ppm'; $cmd = implode(' ', $cmd); $log = array( 'mime' => 'image', 'action' => 'convert-pdf', 'exec' => $cmd ); $this->log->info($log); exec($cmd, $out, $ret); unlink($cacheDir . '/image.ppm'); } else { // fallback if PDF to PPM did not work $log = array( 'mime' => 'image', 'action' => 'convert-pdf', 'msg' => 'pdf2ppm failed - there is no ppm file, use default image' ); $this->log->warn($log); // copy PDF-Icon copy($this->getIcon('application', 'pdf'), $cacheDir . '/image.png'); $cmd = array(self::$magickMogrify); $cmd[] = '-format jpeg'; $cmd[] = $cacheDir . '/image.png'; $cmd = implode(' ', $cmd); $log = array( 'mime' => 'image', 'action' => 'convert-pdf', 'exec' => $cmd ); $this->log->info($log); exec($cmd, $out, $ret); unlink($cacheDir . '/image.png'); } if (file_exists($cacheDir . '/image.jpeg')) { rename($cacheDir . '/image.jpeg', $cacheDir . '/image.jpg'); } $size = getimagesize($cacheDir . '/image.jpg'); if (max(intval($size[0]), intval($size[1])) > self::IMAGE_SIZE_MAX) { $cmd = array(self::$magickMogrify); $cmd[] = '-resize ' . self::IMAGE_SIZE_MAX . 'x' . self::IMAGE_SIZE_MAX; $cmd[] = $cacheDir . '/image.jpg'; $cmd = implode(' ', $cmd); $log = array( 'mime' => 'image', 'action' => 'convert-pdf-resize', 'exec' => $cmd ); $this->log->info($log); exec($cmd, $out, $ret); } } $this->file = $cacheDir . '/image.jpg'; $size = getimagesize($this->file); $org = array($size[0], $size[1]); return true; } /** * convert video to browser compatible image * * Either convert to animation or to sinplge image * * @param array $org original size * @return bool true on success */ public function convertVideo($org) { $target = 'image'; $now = WBClock::now(); $log = array( 'mime' => 'image', 'action' => 'convert', 'source' => 'video', 'cache' => 'hit', 'elapsed' => '0ms' ); if ($target == 'anim') { $this->mimeMinor = 'gif'; } $cacheFile = sprintf('%s/%s', $this->mkCacheDir(), $target); if (file_exists($cacheFile) && 10 < filesize($cacheFile)) { $this->file = $cacheFile; $log['elapsed'] = WBClock::stop($now, 1000, 1) . 'ms'; $this->log->notice($log); return true; } // this sort of locks the file an prevents multi processings touch($cacheFile, null); chmod($cacheFile, 0666); // build preview $image = ''; if ($target == 'anim') { $image = $this->convertVideo2Anim($org); } else { $image = $this->convertVideo2Image($org); } $this->resolveExtension(); $this->file = $cacheFile; rename($image, $this->file); chmod($this->file, 0666); $log['elapsed'] = WBClock::stop($now, 1000, 1) . 'ms'; $log['cache'] = 'miss'; $this->log->notice($log); } /** * extract simple image from video * * @param array $org original size * @return string image file name */ public function convertVideo2Image($org) { $tmpDir = WBParam::get('wb/dir/base') . '/var/tmp'; $image = tempnam($tmpDir, 'vfsVideoImage'); $seconds = array(100, 50, 20, 5, 1, 0); $cmd = '%s -i %s -ss {SEC} -an -vframes 1 -f image2 -y %s 2>&1'; $cmd = sprintf($cmd, self::$ffmpeg, $this->file, $image); foreach ($seconds as $sec) { if ($sec > $this->vfile->getInfo('seconds')) { continue; } $vars = array( 'sec' => $sec ); if (!$this->exec($cmd, $vars)) { continue; } if(filesize($image)) { $cmd = '{MOGRIFY} -resize {SIZE}! -format {MIME_MINOR} {FILE}'; $vars = array( 'mogrify' => self::$magickMogrify, 'size' => implode('x', $org), 'mime_minor' => $this->mimeMinor, 'file' => $image ); if ($this->exec($cmd, $vars)) { return $image; } // this should never happen break; } clearstatcache(); } // shis should never happen! unlink($image); return $image; } /** * build an animated gif as preview for viedeo * * Extract frames after X second of the movie. Use every 25th * frame to build a preview animatrion. * * @param array $org original size * @return string image file name */ public function convertVideo2Anim($org) { $tmpDir = WBParam::get('wb/dir/base') . '/var/tmp'; $image = tempnam($tmpDir, 'vfsVideoAnim'); $seconds = array(100, 50, 20, 5, 1); foreach ($seconds as $sec) { if ($this->vfile->getInfo('seconds') > ($sec + 40)) { break; } } // extract some frames $cmd = '%s -i %s -ss %d -an -r 0.25 -vframes 10 -f image2 %s%%2d.jpg 2>&1'; $cmd = sprintf($cmd, self::$ffmpeg, $this->file, $sec, $image); exec($cmd, $out, $ret); // join frame to animated gif $cmd = '%s -resize %s! -adjoin -delay 100 %s*.jpg %s.gif'; $cmd = sprintf($cmd, self::$magickConvert, implode('x', $org), $image, $image); exec($cmd, $out, $ret); // remove temporary files $cmd = 'rm -f %s*.jpg'; $cmd = sprintf($cmd, $image); exec($cmd, $out, $ret); unlink($image); return $image . '.gif'; } /** * Convert Config Data To List * * Helper funktion to load config data * * @param string config path * @return array */ private function getConfigOptionList($path) { $tmp = self::$config->get($path, ''); $opt = array(); if (!is_array($tmp)) { $tmp = array($tmp); } foreach ($tmp as $o) { $o = trim($o); if (empty($o)) { continue; } $opt[] = $o; } return $opt; } /** * change image to requested size * * Return true in case the file will be delivered, false on redirect * * @param array $redirect parameter * @return bool */ protected function resize($org, &$redirect) { $size = explode('x', $this->req->get('size', '')); $cacheDir = $this->mkCacheDir(); $quality = self::$config->get($this->mimeMinor . '/quality', 60); $option = $this->getConfigOptionList($this->mimeMinor . '/option'); $option = implode(' ', $option); $define = $this->getConfigOptionList($this->mimeMinor . '/define'); if (!empty($define)) { array_unshift($define, '-define'); } $define = implode(' -define ', $define); // shortcut if no size or invalid parameter if (count($size) != 2 || !is_numeric($size[0]) || !is_numeric($size[1])) { $cacheFile = sprintf('%s/default.%s', $cacheDir, $this->mimeMinor); if (file_exists($cacheFile)) { $this->file = $cacheFile; return true; } $now = WBClock::now(); copy($this->file, $cacheFile); $cmd = '{MOGRIFY} -quality {QUALITY} {DEFINE} -format {MIME_MINOR} {OPTION} {FILE}'; $vars = array( 'mogrify' => self::$magickMogrify, 'mime_minor' => $this->mimeMinor, 'file' => $cacheFile, 'quality' => $quality, 'option' => $option, 'define' => $define ); if ($this->exec($cmd, $vars)) { $log = array( 'mime' => 'image', 'action' => 'format', 'elapsed' => WBClock::stop($now, 1000, 1) . 'ms' ); $this->log->notice($log); } if (file_exists($cacheFile . '~')) { unlink($cacheFile . '~'); } $this->file = $cacheFile; return true; } if (!$this->calcSize($size, $org)) { $redirect = array( 'get' => array( 'size' => implode('x', $size) ) ); return false; } $size = implode('x', $size); $cacheFile = sprintf('%s/%s.%s', $cacheDir, $size, $this->mimeMinor); if (!file_exists($cacheFile)) { $now = WBClock::now(); // resize image and cache it copy($this->file, $cacheFile); $cmd = '{MOGRIFY} -quality {QUALITY} {DEFINE} -resize {SIZE} -format {MIME_MINOR} {OPTION} {FILE}'; $vars = array( 'mogrify' => self::$magickMogrify, 'size' => $size, 'mime_minor' => $this->mimeMinor, 'file' => $cacheFile, 'quality' => $quality, 'option' => $option, 'define' => $define ); if ($this->exec($cmd, $vars)) { $log = array( 'mime' => 'image', 'action' => 'resize', 'size' => $size, 'elapsed' => WBClock::stop($now, 1000, 1) . 'ms' ); $this->log->notice($log); } } if (file_exists($cacheFile . '~')) { unlink($cacheFile . '~'); } $this->file = $cacheFile; return true; } /** * caluclate new image size * * calculate proper image size and resize factor while * keeping aspect. * * The reckoned width and height are stored in size-array * * In case the requested size is too far away from the calculated size, * it will return false to indicate that you better redirect * * @param array $size requested image size * @param array $org * @return true on success */ protected function calcSize(&$size, $org) { $tmp = array(); $tmp[0] = min($size[0], self::IMAGE_RESIZE_MAX); $tmp[1] = min($size[1], self::IMAGE_RESIZE_MAX); $panorama = $this->detectPanoramaMode($org); switch ($panorama) { case 'x': $tmp[0] = min($size[0], (self::IMAGE_PANORAMA_RESIZE_MAX_FACTOR * self::IMAGE_RESIZE_MAX)); break; case 'y': $tmp[1] = min($size[1], (self::IMAGE_PANORAMA_RESIZE_MAX_FACTOR * self::IMAGE_RESIZE_MAX)); break; default: break; } // get resize factor $divX = $tmp[0] / $org[0]; $divY = $tmp[1] / $org[1]; $factor = min($divX, $divY, self::IMAGE_RESIZE_MAX_FACTOR); // make sure there is at least one pixel in width and height $x = max(1, round($org[0] * $factor)); $y = max(1, round($org[1] * $factor)); // this is the excact result if ($x == $size[0] && $y == $size[1]) { return true; } // be somewhat tolerant to avoid too many redirects if (abs($x - $tmp[0]) < 3 && abs($y - $tmp[1]) < 3) { $size[0] = $x; $size[1] = $y; return true; } // this is the new image size $size[0] = $x; $size[1] = $y; return false; } }