diff --git a/.gitignore b/.gitignore index 319b3826f..a4ada9c75 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ /phpunit.xml +composer.lock +composer.phar +vendor/ \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 000000000..686495d2a --- /dev/null +++ b/.travis.yml @@ -0,0 +1,23 @@ +language: php + +php: + - 5.3 + - 5.4 + - 5.5 + +env: + - SYMFONY_VERSION=2.0.* + - SYMFONY_VERSION=2.1.* + - SYMFONY_VERSION=2.2.* + - SYMFONY_VERSION=2.3.* + - SYMFONY_VERSION=dev-master + +before_script: + - composer require symfony/framework-bundle:${SYMFONY_VERSION} --prefer-source + - composer install --dev --prefer-source + +script: phpunit --coverage-text + +notifications: + email: + - travis-ci@liip.ch diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..f99573792 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,6 @@ +Changelog +========= + +* **2013-09-17**: [PHPCR loader] DoctrinePHPCRLoader is now deprecated, as the + CmfMediaBundle provides a more reliable loader that is already provided as a + service. See http://symfony.com/doc/master/cmf/bundles/media.html#liipimagine \ No newline at end of file diff --git a/Controller/ImagineController.php b/Controller/ImagineController.php index e11936a87..c2979924d 100644 --- a/Controller/ImagineController.php +++ b/Controller/ImagineController.php @@ -2,49 +2,48 @@ namespace Liip\ImagineBundle\Controller; +use Liip\ImagineBundle\Imagine\Cache\CacheManager; +use Liip\ImagineBundle\Imagine\Data\DataManager; +use Liip\ImagineBundle\Imagine\Filter\FilterManager; + use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Liip\ImagineBundle\Imagine\CachePathResolver; -use Liip\ImagineBundle\Imagine\DataLoader\LoaderInterface; -use Liip\ImagineBundle\Imagine\Filter\FilterManager; class ImagineController { /** - * @var LoaderInterface + * @var DataManager */ - private $dataLoader; + protected $dataManager; /** * @var FilterManager */ - private $filterManager; + protected $filterManager; /** - * @var CachePathResolver + * @var CacheManager */ - private $cachePathResolver; + protected $cacheManager; /** - * Constructor + * Constructor. * - * @param LoaderInterface $dataLoader + * @param DataManager $dataManager * @param FilterManager $filterManager - * @param CachePathResolver $cachePathResolver + * @param CacheManager $cacheManager */ - public function __construct(LoaderInterface $dataLoader, FilterManager $filterManager, CachePathResolver $cachePathResolver = null) + public function __construct(DataManager $dataManager, FilterManager $filterManager, CacheManager $cacheManager) { - $this->dataLoader = $dataLoader; + $this->dataManager = $dataManager; $this->filterManager = $filterManager; - $this->cachePathResolver = $cachePathResolver; + $this->cacheManager = $cacheManager; } /** - * This action applies a given filter to a given image, - * optionally saves the image and - * outputs it to the browser at the same time + * This action applies a given filter to a given image, optionally saves the image and outputs it to the browser at the same time. * - * @param Symfony\Component\HttpFoundation\Request $request + * @param Request $request * @param string $path * @param string $filter * @@ -52,19 +51,16 @@ public function __construct(LoaderInterface $dataLoader, FilterManager $filterMa */ public function filterAction(Request $request, $path, $filter) { - $targetPath = false; - if ($this->cachePathResolver) { - $targetPath = $this->cachePathResolver->resolve($request, $path, $filter); - if ($targetPath instanceof Response) { - return $targetPath; - } + $targetPath = $this->cacheManager->resolve($request, $path, $filter); + if ($targetPath instanceof Response) { + return $targetPath; } - $image = $this->dataLoader->find($path); + $image = $this->dataManager->find($filter, $path); $response = $this->filterManager->get($request, $filter, $image, $path); - if ($targetPath && $response->isSuccessful()) { - $response = $this->cachePathResolver->store($response, $targetPath); + if ($targetPath) { + $response = $this->cacheManager->store($response, $targetPath, $filter); } return $response; diff --git a/DependencyInjection/Compiler/CreateCacheDirectoriesCompilerPass.php b/DependencyInjection/Compiler/CreateCacheDirectoriesCompilerPass.php index dd5e7c14d..a7c81f3ea 100644 --- a/DependencyInjection/Compiler/CreateCacheDirectoriesCompilerPass.php +++ b/DependencyInjection/Compiler/CreateCacheDirectoriesCompilerPass.php @@ -16,13 +16,14 @@ public function process(ContainerBuilder $container) $webRoot = $container->getParameter('liip_imagine.web_root'); $cachePrefix = $container->getParameter('liip_imagine.cache_prefix'); $filters = $container->getParameter('liip_imagine.filters'); + $mode = $container->getParameter('liip_imagine.cache_mkdir_mode'); foreach ($filters as $filter => $options) { $dir = isset($options['path']) ? $webRoot.$options['path'] : $webRoot.$cachePrefix.'/'.$filter; - if (!is_dir($dir) && !mkdir($dir, 0777, true)) { + if (!is_dir($dir) && !mkdir($dir, $mode, true)) { throw new \RuntimeException(sprintf( 'Could not create directory for caching processed '. 'images in "%s"', $dir diff --git a/DependencyInjection/Compiler/LoadersCompilerPass.php b/DependencyInjection/Compiler/LoadersCompilerPass.php index 13fabd69c..c96ab24e2 100644 --- a/DependencyInjection/Compiler/LoadersCompilerPass.php +++ b/DependencyInjection/Compiler/LoadersCompilerPass.php @@ -16,7 +16,27 @@ public function process(ContainerBuilder $container) $manager = $container->getDefinition('liip_imagine.filter.manager'); foreach ($tags as $id => $tag) { - $manager->addMethodCall('addLoader', array($tag[0]['filter'], new Reference($id))); + $manager->addMethodCall('addLoader', array($tag[0]['loader'], new Reference($id))); + } + } + + $tags = $container->findTaggedServiceIds('liip_imagine.data.loader'); + + if (count($tags) > 0 && $container->hasDefinition('liip_imagine.data.manager')) { + $manager = $container->getDefinition('liip_imagine.data.manager'); + + foreach ($tags as $id => $tag) { + $manager->addMethodCall('addLoader', array($tag[0]['loader'], new Reference($id))); + } + } + + $tags = $container->findTaggedServiceIds('liip_imagine.cache.resolver'); + + if (count($tags) > 0 && $container->hasDefinition('liip_imagine.cache.manager')) { + $manager = $container->getDefinition('liip_imagine.cache.manager'); + + foreach ($tags as $id => $tag) { + $manager->addMethodCall('addResolver', array($tag[0]['resolver'], new Reference($id))); } } } diff --git a/DependencyInjection/Configuration.php b/DependencyInjection/Configuration.php index 4bae850a6..3b1b4e832 100644 --- a/DependencyInjection/Configuration.php +++ b/DependencyInjection/Configuration.php @@ -2,8 +2,8 @@ namespace Liip\ImagineBundle\DependencyInjection; -use Symfony\Component\Config\Definition\Builder\TreeBuilder, - Symfony\Component\Config\Definition\ConfigurationInterface; +use Symfony\Component\Config\Definition\Builder\TreeBuilder; +use Symfony\Component\Config\Definition\ConfigurationInterface; class Configuration implements ConfigurationInterface { @@ -28,9 +28,14 @@ public function getConfigTreeBuilder() ->end() ->end() ->scalarNode('web_root')->defaultValue('%kernel.root_dir%/../web')->end() + ->scalarNode('data_root')->defaultValue('%liip_imagine.web_root%')->end() + ->scalarNode('cache_mkdir_mode')->defaultValue(0777)->end() ->scalarNode('cache_prefix')->defaultValue('/media/cache')->end() - ->scalarNode('cache')->defaultTrue()->end() - ->scalarNode('loader')->defaultNull()->end() + ->scalarNode('cache')->defaultValue('web_path')->end() + ->scalarNode('cache_base_path')->defaultValue('')->end() + ->booleanNode('cache_clearer')->defaultValue(true)->end() + ->scalarNode('data_loader')->defaultValue('filesystem')->end() + ->scalarNode('controller_action')->defaultValue('liip_imagine.controller:filterAction')->end() ->arrayNode('formats') ->defaultValue(array()) ->prototype('scalar')->end() @@ -39,11 +44,21 @@ public function getConfigTreeBuilder() ->useAttributeAsKey('name') ->prototype('array') ->fixXmlConfig('filter', 'filters') - ->useAttributeAsKey('name') ->children() ->scalarNode('path')->end() ->scalarNode('quality')->defaultValue(100)->end() ->scalarNode('format')->defaultNull()->end() + ->scalarNode('cache')->defaultNull()->end() + ->scalarNode('data_loader')->defaultNull()->end() + ->scalarNode('controller_action')->defaultNull()->end() + ->arrayNode('route') + ->defaultValue(array()) + ->useAttributeAsKey('name') + ->prototype('array') + ->useAttributeAsKey('name') + ->prototype('variable')->end() + ->end() + ->end() ->arrayNode('filters') ->useAttributeAsKey('name') ->prototype('array') diff --git a/DependencyInjection/LiipImagineExtension.php b/DependencyInjection/LiipImagineExtension.php index 5420e47d0..d642de017 100644 --- a/DependencyInjection/LiipImagineExtension.php +++ b/DependencyInjection/LiipImagineExtension.php @@ -3,11 +3,13 @@ namespace Liip\ImagineBundle\DependencyInjection; use Symfony\Component\Config\FileLocator; + use Symfony\Component\DependencyInjection\Alias; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Loader\XmlFileLoader; + use Symfony\Component\HttpKernel\DependencyInjection\Extension; -use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\HttpKernel\Kernel; class LiipImagineExtension extends Extension { @@ -21,13 +23,20 @@ public function load(array $configs, ContainerBuilder $container) $loader = new XmlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); $loader->load('imagine.xml'); + if ($config['cache_clearer']) { + $loader->load('cache_clearer.xml'); + } + $container->setAlias('liip_imagine', new Alias('liip_imagine.'.$config['driver'])); $cachePrefix = $config['cache_prefix'] ? '/'.trim($config['cache_prefix'], '/') : ''; $container->setParameter('liip_imagine.cache_prefix', $cachePrefix); $container->setParameter('liip_imagine.web_root', $config['web_root']); + $container->setParameter('liip_imagine.data_root', $config['data_root']); + $container->setParameter('liip_imagine.cache_mkdir_mode', $config['cache_mkdir_mode']); $container->setParameter('liip_imagine.formats', $config['formats']); - $container->setParameter('liip_imagine.cache', $config['cache']); + $container->setParameter('liip_imagine.cache.resolver.default', $config['cache']); + foreach ($config['filter_sets'] as $filter => $options) { if (isset($options['path'])) { $config['filter_sets'][$filter]['path'] = '/'.trim($options['path'], '/'); @@ -35,14 +44,18 @@ public function load(array $configs, ContainerBuilder $container) } $container->setParameter('liip_imagine.filter_sets', $config['filter_sets']); - if ($container->getParameter('liip_imagine.cache')) { - $controller = $container->getDefinition('liip_imagine.controller'); - $controller->addArgument(new Reference('liip_imagine.cache.path.resolver')); - } + $container->setParameter('liip_imagine.data.loader.default', $config['data_loader']); + + $container->setParameter('liip_imagine.controller_action', $config['controller_action']); - if (!empty($config['loader'])) { - $controller = $container->getDefinition('liip_imagine.controller'); - $controller->replaceArgument(0, new Reference($config['loader'])); + if ('2' == Kernel::MAJOR_VERSION && '0' == Kernel::MINOR_VERSION) { + $container->removeDefinition('liip_imagine.cache.clearer'); } + + $container->setParameter('liip_imagine.cache.resolver.base_path', $config['cache_base_path']); + + $resources = $container->hasParameter('twig.form.resources') ? $container->getParameter('twig.form.resources') : array(); + $resources[] = 'LiipImagineBundle:Form:form_div_layout.html.twig'; + $container->setParameter('twig.form.resources', $resources); } } diff --git a/Form/Type/ImageType.php b/Form/Type/ImageType.php new file mode 100644 index 000000000..7254a1bde --- /dev/null +++ b/Form/Type/ImageType.php @@ -0,0 +1,52 @@ + + */ +class ImageType extends AbstractType +{ + public function buildView(FormView $view, FormInterface $form, array $options) + { + $view->vars['image_path'] = $options['image_path']; + $view->vars['image_filter'] = $options['image_filter']; + $view->vars['image_attr'] = $options['image_attr']; + $view->vars['link_url'] = $options['link_url']; + $view->vars['link_filter'] = $options['link_filter']; + $view->vars['link_attr'] = $options['link_attr']; + } + + public function setDefaultOptions(OptionsResolverInterface $resolver) + { + $resolver->setRequired(array( + 'image_path', + 'image_filter', + )); + + $resolver->setDefaults(array( + 'image_attr' => array(), + 'link_url' => null, + 'link_filter' => null, + 'link_attr' => array(), + )); + } + + public function getParent() + { + return 'file'; + } + + public function getName() + { + return 'liip_imagine_image'; + } +} diff --git a/Imagine/Cache/CacheClearer.php b/Imagine/Cache/CacheClearer.php new file mode 100644 index 000000000..1ec50e29b --- /dev/null +++ b/Imagine/Cache/CacheClearer.php @@ -0,0 +1,45 @@ + + */ +class CacheClearer implements CacheClearerInterface +{ + /** + * @var CacheManager + */ + protected $cacheManager; + + /** + * @var string + */ + protected $cachePrefix; + + /** + * Constructor. + * + * @param CacheManager $cacheManager + * @param string $cachePrefix The prefix applied to all cached images. + */ + public function __construct(CacheManager $cacheManager, $cachePrefix) + { + $this->cacheManager = $cacheManager; + $this->cachePrefix = $cachePrefix; + } + + /** + * (non-PHPdoc) + * @see Symfony\Component\HttpKernel\CacheClearer\CacheClearerInterface::clear() + */ + public function clear($cacheDir) + { + // $cacheDir contains the application cache, which we don't care about + $this->cacheManager->clearResolversCache($this->cachePrefix); + } +} diff --git a/Imagine/Cache/CacheManager.php b/Imagine/Cache/CacheManager.php new file mode 100644 index 000000000..76b44bca0 --- /dev/null +++ b/Imagine/Cache/CacheManager.php @@ -0,0 +1,241 @@ +filterConfig = $filterConfig; + $this->router = $router; + $this->webRoot = realpath($webRoot); + $this->defaultResolver = $defaultResolver; + } + + /** + * Adds a resolver to handle cached images for the given filter. + * + * @param string $filter + * @param ResolverInterface $resolver + * + * @return void + */ + public function addResolver($filter, ResolverInterface $resolver) + { + $this->resolvers[$filter] = $resolver; + + if ($resolver instanceof CacheManagerAwareInterface) { + $resolver->setCacheManager($this); + } + } + + /** + * Returns the configured web root path. + * + * @return string + */ + public function getWebRoot() + { + return $this->webRoot; + } + + /** + * Gets a resolver for the given filter. + * + * In case there is no specific resolver, but a default resolver has been configured, the default will be returned. + * + * @param string $filter + * + * @return ResolverInterface + * + * @throws \InvalidArgumentException If neither a specific nor a default resolver is available. + */ + protected function getResolver($filter) + { + $config = $this->filterConfig->get($filter); + + $resolverName = empty($config['cache']) + ? $this->defaultResolver : $config['cache']; + + if (!isset($this->resolvers[$resolverName])) { + throw new \InvalidArgumentException(sprintf( + 'Could not find resolver for "%s" filter type', $filter + )); + } + + return $this->resolvers[$resolverName]; + } + + /** + * Gets filtered path for rendering in the browser. + * + * @see ResolverInterface::getBrowserPath + * + * @param string $path The path where the resolved file is expected. + * @param string $filter + * @param boolean $absolute + * + * @return string + */ + public function getBrowserPath($path, $filter, $absolute = false) + { + return $this->getResolver($filter)->getBrowserPath($path, $filter, $absolute); + } + + /** + * Returns a web accessible URL. + * + * @param string $path The path where the resolved file is expected. + * @param string $filter The name of the imagine filter in effect. + * @param bool $absolute Whether to generate an absolute URL or a relative path is accepted. + * In case the resolver does not support relative paths, it may ignore this flag. + * + * @return string + */ + public function generateUrl($path, $filter, $absolute = false) + { + $config = $this->filterConfig->get($filter); + + if (isset($config['format'])) { + $pathinfo = pathinfo($path); + + // the extension should be forced and a directory is detected + if ((!isset($pathinfo['extension']) || $pathinfo['extension'] !== $config['format']) + && isset($pathinfo['dirname'])) { + + if ('\\' === $pathinfo['dirname']) { + $pathinfo['dirname'] = ''; + } + + $path = $pathinfo['dirname'].'/'.$pathinfo['filename'].'.'.$config['format']; + } + } + + $params = array('path' => ltrim($path, '/')); + + return str_replace( + urlencode($params['path']), + urldecode($params['path']), + $this->router->generate('_imagine_'.$filter, $params, $absolute) + ); + } + + /** + * Resolves filtered path for rendering in the browser. + * + * @param Request $request + * @param string $path + * @param string $filter + * + * @return string|boolean|Response target path or false if filter has no + * resolver or a Response object from the resolver + * + * @throws NotFoundHttpException if the path can not be resolved + */ + public function resolve(Request $request, $path, $filter) + { + if (false !== strpos($path, '/../') || 0 === strpos($path, '../')) { + throw new NotFoundHttpException(sprintf("Source image was searched with '%s' outside of the defined root path", $path)); + } + + try { + $resolver = $this->getResolver($filter); + } catch (\InvalidArgumentException $e) { + return false; + } + + return $resolver->resolve($request, $path, $filter); + } + + /** + * Store successful responses with the cache resolver. + * + * @see ResolverInterface::store + * + * @param Response $response + * @param string $targetPath + * @param string $filter + * + * @return Response + */ + public function store(Response $response, $targetPath, $filter) + { + if ($response->isSuccessful()) { + $response = $this->getResolver($filter)->store($response, $targetPath, $filter); + } + + return $response; + } + + /** + * Remove a cached image from the storage. + * + * @see ResolverInterface::remove + * + * @param string $targetPath + * @param string $filter + * + * @return bool + */ + public function remove($targetPath, $filter) + { + return $this->getResolver($filter)->remove($targetPath, $filter); + } + + /** + * Clear the cache of all resolvers. + * + * @see ResolverInterface::clear + * + * @param string $cachePrefix + * + * @return void + */ + public function clearResolversCache($cachePrefix) + { + foreach ($this->resolvers as $resolver) { + $resolver->clear($cachePrefix); + } + } +} diff --git a/Imagine/Cache/CacheManagerAwareInterface.php b/Imagine/Cache/CacheManagerAwareInterface.php new file mode 100644 index 000000000..e8baf53f0 --- /dev/null +++ b/Imagine/Cache/CacheManagerAwareInterface.php @@ -0,0 +1,11 @@ +cacheManager = $cacheManager; + } +} diff --git a/Imagine/Cache/Resolver/AbstractFilesystemResolver.php b/Imagine/Cache/Resolver/AbstractFilesystemResolver.php new file mode 100644 index 000000000..b7c2c1021 --- /dev/null +++ b/Imagine/Cache/Resolver/AbstractFilesystemResolver.php @@ -0,0 +1,140 @@ +filesystem = $filesystem; + } + + /** + * @param CacheManager $cacheManager + */ + public function setCacheManager(CacheManager $cacheManager) + { + $this->cacheManager = $cacheManager; + } + + /** + * Set the base path to + * + * @param $basePath + */ + public function setBasePath($basePath) + { + $this->basePath = $basePath; + } + + /** + * @param int $mkdirMode + */ + public function setFolderPermissions ($folderPermissions) + { + $this->folderPermissions = $folderPermissions; + } + + /** + * Stores the content into a static file. + * + * @param Response $response + * @param string $targetPath + * @param string $filter + * + * @return Response + * + * @throws \RuntimeException + */ + public function store(Response $response, $targetPath, $filter) + { + $dir = pathinfo($targetPath, PATHINFO_DIRNAME); + + $this->makeFolder($dir); + + file_put_contents($targetPath, $response->getContent()); + + $response->setStatusCode(201); + + return $response; + } + + /** + * Removes a stored image resource. + * + * @param string $targetPath The target path provided by the resolve method. + * @param string $filter The name of the imagine filter in effect. + * + * @return bool Whether the file has been removed successfully. + */ + public function remove($targetPath, $filter) + { + $filename = $this->getFilePath($targetPath, $filter); + $this->filesystem->remove($filename); + + return !file_exists($filename); + } + + /** + * @param string $dir + * @throws \RuntimeException + */ + protected function makeFolder ($dir) + { + if (!is_dir($dir)) { + $parent = dirname($dir); + try { + $this->makeFolder($parent); + $this->filesystem->mkdir($dir); + $this->filesystem->chmod($dir, $this->folderPermissions); + } catch (IOException $e) { + throw new \RuntimeException(sprintf('Could not create directory %s', $dir), 0, $e); + } + } + } + + /** + * Return the local filepath. + * + * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException + * + * @param string $path The resource path to convert. + * @param string $filter The name of the imagine filter. + * + * @return string + */ + abstract protected function getFilePath($path, $filter); +} diff --git a/Imagine/Cache/Resolver/AmazonS3Resolver.php b/Imagine/Cache/Resolver/AmazonS3Resolver.php new file mode 100644 index 000000000..5a622bddc --- /dev/null +++ b/Imagine/Cache/Resolver/AmazonS3Resolver.php @@ -0,0 +1,214 @@ +storage = $storage; + + $this->bucket = $bucket; + $this->acl = $acl; + + $this->objUrlOptions = $objUrlOptions; + } + + /** + * Sets the logger to be used. + * + * @param LoggerInterface $logger + */ + public function setLogger(LoggerInterface $logger) + { + $this->logger = $logger; + } + + /** + * @param CacheManager $cacheManager + */ + public function setCacheManager(CacheManager $cacheManager) + { + $this->cacheManager = $cacheManager; + } + + /** + * {@inheritDoc} + */ + public function resolve(Request $request, $path, $filter) + { + $objectPath = $this->getObjectPath($path, $filter); + if ($this->objectExists($objectPath)) { + return new RedirectResponse($this->getObjectUrl($objectPath), 301); + } + + return $objectPath; + } + + /** + * {@inheritDoc} + */ + public function store(Response $response, $targetPath, $filter) + { + $storageResponse = $this->storage->create_object($this->bucket, $targetPath, array( + 'body' => $response->getContent(), + 'contentType' => $response->headers->get('Content-Type'), + 'length' => strlen($response->getContent()), + 'acl' => $this->acl, + )); + + if ($storageResponse->isOK()) { + $response->setStatusCode(301); + $response->headers->set('Location', $this->getObjectUrl($targetPath)); + } else { + if ($this->logger) { + $this->logger->warn('The object could not be created on Amazon S3.', array( + 'targetPath' => $targetPath, + 'filter' => $filter, + 's3_response' => $storageResponse, + )); + } + } + + return $response; + } + + /** + * {@inheritDoc} + */ + public function getBrowserPath($path, $filter, $absolute = false) + { + $objectPath = $this->getObjectPath($path, $filter); + if ($this->objectExists($objectPath)) { + return $this->getObjectUrl($objectPath); + } + + return $this->cacheManager->generateUrl($path, $filter, $absolute); + } + + /** + * {@inheritDoc} + */ + public function remove($targetPath, $filter) + { + if (!$this->objectExists($targetPath)) { + // A non-existing object to delete: done! + return true; + } + + return $this->storage->delete_object($this->bucket, $targetPath)->isOK(); + } + + /** + * Sets a single option to be passed when retrieving an objects URL. + * + * If the option is already set, it will be overwritten. + * + * @see \AmazonS3::get_object_url() for available options. + * + * @param string $key The name of the option. + * @param mixed $value The value to be set. + * + * @return AmazonS3Resolver $this + */ + public function setObjectUrlOption($key, $value) + { + $this->objUrlOptions[$key] = $value; + + return $this; + } + + /** + * {@inheritDoc} + */ + public function clear($cachePrefix) + { + // TODO: implement cache clearing for Amazon S3 service + } + + /** + * Returns the object path within the bucket. + * + * @param string $path The base path of the resource. + * @param string $filter The name of the imagine filter in effect. + * + * @return string The path of the object on S3. + */ + protected function getObjectPath($path, $filter) + { + return str_replace('//', '/', $filter.'/'.$path); + } + + /** + * Returns the URL for an object saved on Amazon S3. + * + * @param string $targetPath + * + * @return string + */ + protected function getObjectUrl($targetPath) + { + return $this->storage->get_object_url($this->bucket, $targetPath, 0, $this->objUrlOptions); + } + + /** + * Checks whether an object exists. + * + * @param string $objectPath + * + * @return bool + */ + protected function objectExists($objectPath) + { + return $this->storage->if_object_exists($this->bucket, $objectPath); + } +} diff --git a/Imagine/Cache/Resolver/AwsS3Resolver.php b/Imagine/Cache/Resolver/AwsS3Resolver.php new file mode 100644 index 000000000..7da87a89c --- /dev/null +++ b/Imagine/Cache/Resolver/AwsS3Resolver.php @@ -0,0 +1,226 @@ +storage = $storage; + + $this->bucket = $bucket; + $this->acl = $acl; + + $this->objUrlOptions = $objUrlOptions; + } + + /** + * Sets the logger to be used. + * + * @param LoggerInterface $logger + */ + public function setLogger(LoggerInterface $logger) + { + $this->logger = $logger; + } + + /** + * @param CacheManager $cacheManager + */ + public function setCacheManager(CacheManager $cacheManager) + { + $this->cacheManager = $cacheManager; + } + + /** + * {@inheritDoc} + */ + public function resolve(Request $request, $path, $filter) + { + $objectPath = $this->getObjectPath($path, $filter); + if ($this->objectExists($objectPath)) { + return new RedirectResponse($this->getObjectUrl($objectPath), 301); + } + + return $objectPath; + } + + /** + * {@inheritDoc} + */ + public function store(Response $response, $targetPath, $filter) + { + try { + $storageResponse = $this->storage->putObject(array( + 'ACL' => $this->acl, + 'Bucket' => $this->bucket, + 'Key' => $targetPath, + 'Body' => $response->getContent(), + 'ContentType' => $response->headers->get('Content-Type') + )); + } catch (\Exception $e) { + if ($this->logger) { + $this->logger->warn('The object could not be created on Amazon S3.', array( + 'targetPath' => $targetPath, + 'filter' => $filter, + )); + } + + return $response; + } + + $response->setStatusCode(301); + $response->headers->set('Location', $storageResponse->get('ObjectURL')); + + return $response; + } + + /** + * {@inheritDoc} + */ + public function getBrowserPath($path, $filter, $absolute = false) + { + $objectPath = $this->getObjectPath($path, $filter); + if ($this->objectExists($objectPath)) { + return $this->getObjectUrl($objectPath); + } + + return $this->cacheManager->generateUrl($path, $filter, $absolute); + } + + /** + * {@inheritDoc} + */ + public function remove($targetPath, $filter) + { + if (!$this->objectExists($targetPath)) { + // A non-existing object to delete: done! + return true; + } + + try { + $response = $this->storage->deleteObject(array( + 'Bucket' => $this->bucket, + 'Key' => $targetPath, + )); + + return true; + } catch (\Exception $e) { + return false; + } + } + + /** + * Sets a single option to be passed when retrieving an objects URL. + * + * If the option is already set, it will be overwritten. + * + * @see Aws\S3\S3Client::getObjectUrl() for available options. + * + * @param string $key The name of the option. + * @param mixed $value The value to be set. + * + * @return AmazonS3Resolver $this + */ + public function setObjectUrlOption($key, $value) + { + $this->objUrlOptions[$key] = $value; + + return $this; + } + + /** + * {@inheritDoc} + */ + public function clear($cachePrefix) + { + // TODO: implement cache clearing for Amazon S3 service + } + + /** + * Returns the object path within the bucket. + * + * @param string $path The base path of the resource. + * @param string $filter The name of the imagine filter in effect. + * + * @return string The path of the object on S3. + */ + protected function getObjectPath($path, $filter) + { + return str_replace('//', '/', $filter.'/'.$path); + } + + /** + * Returns the URL for an object saved on Amazon S3. + * + * @param string $targetPath + * + * @return string + */ + protected function getObjectUrl($targetPath) + { + return $this->storage->getObjectUrl($this->bucket, $targetPath, 0, $this->objUrlOptions); + } + + /** + * Checks whether an object exists. + * + * @param string $objectPath + * + * @return bool + */ + protected function objectExists($objectPath) + { + return $this->storage->doesObjectExist($this->bucket, $objectPath); + } +} diff --git a/Imagine/Cache/Resolver/CacheResolver.php b/Imagine/Cache/Resolver/CacheResolver.php new file mode 100644 index 000000000..8bafec06b --- /dev/null +++ b/Imagine/Cache/Resolver/CacheResolver.php @@ -0,0 +1,246 @@ +cache = $cache; + $this->resolver = $cacheResolver; + + if (null === $optionsResolver) { + $optionsResolver = new OptionsResolver(); + } + + $this->setDefaultOptions($optionsResolver); + $this->options = $optionsResolver->resolve($options); + } + + /** + * {@inheritDoc} + */ + public function resolve(Request $request, $path, $filter) + { + $key = $this->generateCacheKey('resolve', $path, $filter); + if ($this->cache->contains($key)) { + return $this->cache->fetch($key); + } + + $targetPath = $this->resolver->resolve($request, $path, $filter); + $this->saveToCache($key, $targetPath); + + /* + * The targetPath being a string will be forwarded to the ResolverInterface::store method. + * As there is no way to reverse this operation by the interface, we store this information manually. + * + * If it's not a string, it's a Response it will be returned as it without calling the store method. + */ + if (is_string($targetPath)) { + $reverseKey = $this->generateCacheKey('reverse', $targetPath, $filter); + $this->saveToCache($reverseKey, $path); + } + + return $targetPath; + } + + /** + * {@inheritDoc} + */ + public function store(Response $response, $targetPath, $filter) + { + return $this->resolver->store($response, $targetPath, $filter); + } + + /** + * {@inheritDoc} + */ + public function getBrowserPath($path, $filter, $absolute = false) + { + $key = $this->generateCacheKey('getBrowserPath', $path, $filter, array( + $absolute ? 'absolute' : 'relative', + )); + + if ($this->cache->contains($key)) { + return $this->cache->fetch($key); + } + + $result = $this->resolver->getBrowserPath($path, $filter, $absolute); + $this->saveToCache($key, $result); + + return $result; + } + + /** + * {@inheritDoc} + */ + public function remove($targetPath, $filter) + { + $removed = $this->resolver->remove($targetPath, $filter); + + // If the resolver did not remove the content, we can leave the cache. + if ($removed) { + $reverseKey = $this->generateCacheKey('reverse', $targetPath, $filter); + if ($this->cache->contains($reverseKey)) { + $path = $this->cache->fetch($reverseKey); + + // The indexKey is not utilizing the method so the value is not important. + $indexKey = $this->generateIndexKey($this->generateCacheKey(null, $path, $filter)); + + // Retrieve the index and remove the content from the cache. + $index = $this->cache->fetch($indexKey); + foreach ($index as $eachCacheKey) { + $this->cache->delete($eachCacheKey); + } + + // Remove the auxiliary keys. + $this->cache->delete($indexKey); + $this->cache->delete($reverseKey); + } + } + + return $removed; + } + + /** + * {@inheritDoc} + */ + public function clear($cachePrefix) + { + // TODO: implement cache clearing + } + + /** + * Generate a unique cache key based on the given parameters. + * + * When overriding this method, ensure generateIndexKey is adjusted accordingly. + * + * @param string $method The cached method. + * @param string $path The image path in use. + * @param string $filter The filter in use. + * @param array $suffixes An optional list of additional parameters to use to create the key. + * + * @return string + */ + public function generateCacheKey($method, $path, $filter, array $suffixes = array()) + { + $keyStack = array( + $this->options['global_prefix'], + $this->options['prefix'], + $filter, + $path, + $method, + ); + + return implode('.', array_merge($keyStack, $suffixes)); + } + + /** + * Generate the index key for the given cacheKey. + * + * The index contains a list of cache keys related to an image and a filter. + * + * @param string $cacheKey + * + * @return string + */ + protected function generateIndexKey($cacheKey) + { + $cacheKeyStack = explode('.', $cacheKey); + + $indexKeyStack = array( + $this->options['global_prefix'], + $this->options['prefix'], + $this->options['index_key'], + $cacheKeyStack[2], // filter + $cacheKeyStack[3], // path + ); + + return implode('.', $indexKeyStack); + } + + /** + * Save the given content to the cache and update the cache index. + * + * @param string $cacheKey + * @param mixed $content + * + * @return bool + */ + protected function saveToCache($cacheKey, $content) + { + // Create or update the index list containing all cache keys for a given image and filter pairing. + $indexKey = $this->generateIndexKey($cacheKey); + if ($this->cache->contains($indexKey)) { + $index = $this->cache->fetch($indexKey); + + if (!in_array($cacheKey, $index)) { + $index[] = $cacheKey; + } + } else { + $index = array($cacheKey); + } + + /* + * Only save the content, if the index has been updated successfully. + * This is required to have a (hopefully) synchron state between cache and backend. + * + * "Hopefully" because there are caches (like Memcache) which will remove keys by themselves. + */ + if ($this->cache->save($indexKey, $index)) { + return $this->cache->save($cacheKey, $content); + } + + return false; + } + + protected function setDefaultOptions(OptionsResolverInterface $resolver) + { + $resolver->setDefaults(array( + 'global_prefix' => 'liip_imagine.resolver_cache', + 'prefix' => get_class($this->resolver), + 'index_key' => 'index', + )); + + $resolver->setAllowedTypes(array( + 'global_prefix' => 'string', + 'prefix' => 'string', + 'index_key' => 'string', + )); + } +} diff --git a/Imagine/Cache/Resolver/NoCacheResolver.php b/Imagine/Cache/Resolver/NoCacheResolver.php new file mode 100644 index 000000000..0a8ced5f9 --- /dev/null +++ b/Imagine/Cache/Resolver/NoCacheResolver.php @@ -0,0 +1,38 @@ +setBasePath($request->getBaseUrl()); + + return $this->getFilePath($path, $filter); + } + + /** + * {@inheritDoc} + */ + public function store(Response $response, $targetPath, $filter) + { + return $response; + } + + /** + * {@inheritDoc} + */ + public function remove($targetPath, $filter) + { + return true; + } +} diff --git a/Imagine/Cache/Resolver/ResolverInterface.php b/Imagine/Cache/Resolver/ResolverInterface.php new file mode 100644 index 000000000..0b91b4d84 --- /dev/null +++ b/Imagine/Cache/Resolver/ResolverInterface.php @@ -0,0 +1,65 @@ +decodeBrowserPath($this->getBrowserPath($path, $filter)); + $this->basePath = $request->getBaseUrl(); + $targetPath = $this->getFilePath($path, $filter); + + // if the file has already been cached, we're probably not rewriting + // correctly, hence make a 301 to proper location, so browser remembers + if (file_exists($targetPath)) { + // Strip the base URL of this request from the browserpath to not interfere with the base path. + $baseUrl = $request->getBaseUrl(); + if ($baseUrl && 0 === strpos($browserPath, $baseUrl)) { + $browserPath = substr($browserPath, strlen($baseUrl)); + } + + return new RedirectResponse($request->getBasePath().$browserPath); + } + + return $targetPath; + } + + /** + * {@inheritDoc} + */ + public function getBrowserPath($targetPath, $filter, $absolute = false) + { + return $this->cacheManager->generateUrl($targetPath, $filter, $absolute); + } + + /** + * {@inheritDoc} + */ + public function clear($cachePrefix) + { + // Let's just avoid to remove the web/ directory content if cache prefix is empty + if ($cachePrefix === '') { + throw new \InvalidArgumentException("Cannot clear the Imagine cache because the cache_prefix is empty in your config."); + } + + $cachePath = $this->cacheManager->getWebRoot() . $cachePrefix; + + // Avoid an exception if the cache path does not exist (i.e. Imagine didn't yet render any image) + if (is_dir($cachePath)) { + $this->filesystem->remove(Finder::create()->in($cachePath)->depth(0)->directories()); + } + } + + /** + * {@inheritDoc} + */ + protected function getFilePath($path, $filter) + { + $browserPath = $this->decodeBrowserPath($this->getBrowserPath($path, $filter)); + + if (!empty($this->basePath) && 0 === strpos($browserPath, $this->basePath)) { + $browserPath = substr($browserPath, strlen($this->basePath)); + } + + return $this->cacheManager->getWebRoot().$browserPath; + } + + /** + * Decodes the URL encoded browser path. + * + * @param string $browserPath + * + * @return string + */ + protected function decodeBrowserPath($browserPath) + { + //TODO: find out why I need double urldecode to get a valid path + return urldecode(urldecode($browserPath)); + } +} diff --git a/Imagine/CachePathResolver.php b/Imagine/CachePathResolver.php deleted file mode 100644 index 12402912f..000000000 --- a/Imagine/CachePathResolver.php +++ /dev/null @@ -1,124 +0,0 @@ -router = $router; - $this->filesystem = $filesystem; - $this->webRoot = realpath($webRoot); - } - - /** - * Gets filtered path for rendering in the browser - * - * @param string $path - * @param string $filter - * @param boolean $absolute - * - * @return string - */ - public function getBrowserPath($targetPath, $filter, $absolute = false) - { - $params = array('path' => ltrim($targetPath, '/')); - - $path = str_replace( - urlencode($params['path']), - urldecode($params['path']), - $this->router->generate('_imagine_'.$filter, $params, $absolute) - ); - - return $path; - } - - /** - * Resolves filtered path for rendering in the browser - * - * @param Request $request - * @param string $path - * @param string $filter - * - * @return string - */ - public function resolve(Request $request, $targetPath, $filter) - { - //TODO: find out why I need double urldecode to get a valid path - $browserPath = urldecode(urldecode($this->getBrowserPath($targetPath, $filter))); - - // if cache path cannot be determined, return 404 - if (null === $browserPath) { - throw new NotFoundHttpException('Image doesn\'t exist'); - } - - $basePath = $request->getBaseUrl(); - if (!empty($basePath) && 0 === strpos($browserPath, $basePath)) { - $browserPath = substr($browserPath, strlen($basePath)); - } - - $targetPath = $this->webRoot.$browserPath; - - // if the file has already been cached, we're probably not rewriting - // correctly, hence make a 301 to proper location, so browser remembers - if (file_exists($targetPath)) { - return new RedirectResponse($request->getBasePath().$browserPath); - } - - return $targetPath; - } - - /** - * @throws \RuntimeException - * @param Response $response - * @param string $targetPath - * - * @return Response - */ - public function store(Response $response, $targetPath) - { - $dir = pathinfo($targetPath, PATHINFO_DIRNAME); - - if (!is_dir($dir) && !$this->filesystem->mkdir($dir)) { - throw new \RuntimeException(sprintf( - 'Could not create directory %s', $dir - )); - } - - file_put_contents($targetPath, $response->getContent()); - - $response->setStatusCode(201); - - return $response; - } -} diff --git a/Imagine/Data/DataManager.php b/Imagine/Data/DataManager.php new file mode 100644 index 000000000..b6076fef1 --- /dev/null +++ b/Imagine/Data/DataManager.php @@ -0,0 +1,89 @@ +filterConfig = $filterConfig; + $this->defaultLoader = $defaultLoader; + } + + /** + * Adds a loader to retrieve images for the given filter. + * + * @param string $filter + * @param LoaderInterface $loader + * + * @return void + */ + public function addLoader($filter, LoaderInterface $loader) + { + $this->loaders[$filter] = $loader; + } + + /** + * Returns a loader previously attached to the given filter. + * + * @param string $filter + * + * @return LoaderInterface + * + * @throws \InvalidArgumentException + */ + public function getLoader($filter) + { + $config = $this->filterConfig->get($filter); + + $loaderName = empty($config['data_loader']) + ? $this->defaultLoader : $config['data_loader']; + + if (!isset($this->loaders[$loaderName])) { + throw new \InvalidArgumentException(sprintf( + 'Could not find data loader for "%s" filter type', $filter + )); + } + + return $this->loaders[$loaderName]; + } + + /** + * Retrieves an image with the given filter applied. + * + * @param string $filter + * @param string $path + * + * @return \Imagine\Image\ImageInterface + */ + public function find($filter, $path) + { + $loader = $this->getLoader($filter); + + return $loader->find($path); + } +} diff --git a/Imagine/Data/Loader/AbstractDoctrineLoader.php b/Imagine/Data/Loader/AbstractDoctrineLoader.php new file mode 100644 index 000000000..4113aaf48 --- /dev/null +++ b/Imagine/Data/Loader/AbstractDoctrineLoader.php @@ -0,0 +1,73 @@ +imagine = $imagine; + $this->manager = $manager; + $this->class = $class; + } + + /** + * Map the requested path (ie. subpath in the URL) to an id that can be used to lookup the image in the Doctrine store. + * + * @param string $path + * + * @return string + */ + abstract protected function mapPathToId($path); + + /** + * Return a stream resource from the Doctrine entity/document with the image content + * + * @param object $image + * + * @return resource + */ + abstract protected function getStreamFromImage($image); + + /** + * @param string $path + * + * @return \Imagine\Image\ImageInterface + */ + public function find($path) + { + $image = $this->manager->find($this->class, $this->mapPathToId($path)); + + if (!$image) { + throw new NotFoundHttpException(sprintf('Source image not found with id "%s"', $path)); + } + + return $this->imagine->load(stream_get_contents($this->getStreamFromImage($image))); + } +} diff --git a/Imagine/Data/Loader/DoctrinePHPCRLoader.php b/Imagine/Data/Loader/DoctrinePHPCRLoader.php new file mode 100644 index 000000000..c56c91d0f --- /dev/null +++ b/Imagine/Data/Loader/DoctrinePHPCRLoader.php @@ -0,0 +1,80 @@ +manager = $manager; + $this->class = $class; + $this->rootPath = $rootPath; + } + + protected function getStreamFromImage($image) + { + return $image->getContent(); + } + + /** + * @param string $path + * + * @return \Imagine\Image\ImageInterface + */ + public function find($path) + { + $file = $this->rootPath.'/'.ltrim($path, '/'); + $info = $this->getFileInfo($file); + $name = $info['dirname'].'/'.$info['filename']; + + // consider full path as provided (with or without an extension) + $paths = array($file); + foreach ($this->formats as $format) { + // consider all possible alternative extensions + if (empty($info['extension']) || $info['extension'] !== $format) { + $paths[] = $name.'.'.$format; + } + } + + // if the full path contained an extension, also consider the full path without an extension + if ($file !== $name) { + $paths[] = $name; + } + + $images = $this->manager->findMany($this->class, $paths); + if (!$images->count()) { + throw new NotFoundHttpException(sprintf('Source image not found with id "%s"', $path)); + } + + return $this->imagine->load(stream_get_contents($this->getStreamFromImage($images->first()))); + } +} diff --git a/Imagine/Data/Loader/ExtendedFileSystemLoader.php b/Imagine/Data/Loader/ExtendedFileSystemLoader.php new file mode 100644 index 000000000..aa0ec117a --- /dev/null +++ b/Imagine/Data/Loader/ExtendedFileSystemLoader.php @@ -0,0 +1,47 @@ +transformers = $transformers; + } + + /** + * Apply transformers to the file. + * + * @param $absolutePath + * + * @return array + */ + protected function getFileInfo($absolutePath) + { + if (!empty($this->transformers)) { + foreach ($this->transformers as $transformer) { + $absolutePath = $transformer->apply($absolutePath); + } + } + + return pathinfo($absolutePath); + } +} diff --git a/Imagine/Data/Loader/FileSystemLoader.php b/Imagine/Data/Loader/FileSystemLoader.php new file mode 100644 index 000000000..e6ec344d9 --- /dev/null +++ b/Imagine/Data/Loader/FileSystemLoader.php @@ -0,0 +1,98 @@ +imagine = $imagine; + $this->formats = $formats; + $this->rootPath = realpath($rootPath); + } + + /** + * Get the file info for the given path. + * + * This can optionally be used to generate the given file. + * + * @param string $absolutePath + * + * @return array + */ + protected function getFileInfo($absolutePath) + { + return pathinfo($absolutePath); + } + + /** + * {@inheritDoc} + */ + public function find($path) + { + if (false !== strpos($path, '/../') || 0 === strpos($path, '../')) { + throw new NotFoundHttpException(sprintf("Source image was searched with '%s' out side of the defined root path", $path)); + } + + $file = $this->rootPath.'/'.ltrim($path, '/'); + $info = $this->getFileInfo($file); + $absolutePath = $info['dirname'].DIRECTORY_SEPARATOR.$info['basename']; + + $name = $info['dirname'].DIRECTORY_SEPARATOR.$info['filename']; + + $targetFormat = null; + // set a format if an extension is found and is allowed + if (isset($info['extension']) + && (empty($this->formats) || in_array($info['extension'], $this->formats)) + ) { + $targetFormat = $info['extension']; + } + + if (empty($targetFormat) || !file_exists($absolutePath)) { + // attempt to determine path and format + $absolutePath = null; + foreach ($this->formats as $format) { + if ($targetFormat !== $format && file_exists($name.'.'.$format)) { + $absolutePath = $name.'.'.$format; + + break; + } + } + + if (!$absolutePath) { + if (!empty($targetFormat) && is_file($name)) { + $absolutePath = $name; + } else { + throw new NotFoundHttpException(sprintf('Source image not found in "%s"', $file)); + } + } + } + + return $this->imagine->open($absolutePath); + } +} diff --git a/Imagine/Data/Loader/GridFSLoader.php b/Imagine/Data/Loader/GridFSLoader.php new file mode 100644 index 000000000..7716bbbc6 --- /dev/null +++ b/Imagine/Data/Loader/GridFSLoader.php @@ -0,0 +1,57 @@ +imagine = $imagine; + $this->dm = $dm; + $this->class = $class; + } + + /** + * {@inheritDoc} + */ + public function find($id) + { + $image = $this->dm + ->getRepository($this->class) + ->findAll() + ->getCollection() + ->findOne(array("_id" => new \MongoId($id))); + + if (!$image) { + throw new NotFoundHttpException(sprintf('Source image not found with id "%s"', $id)); + } + + return $this->imagine->load($image['file']->getBytes()); + } +} diff --git a/Imagine/Data/Loader/LoaderInterface.php b/Imagine/Data/Loader/LoaderInterface.php new file mode 100644 index 000000000..406077d92 --- /dev/null +++ b/Imagine/Data/Loader/LoaderInterface.php @@ -0,0 +1,17 @@ +imagine = $imagine; + $this->wrapperPrefix = $wrapperPrefix; + + if ($context && !is_resource($context)) { + throw new \InvalidArgumentException('The given context is no valid resource.'); + } + + $this->context = $context; + } + + /** + * {@inheritDoc} + */ + public function find($path) + { + $name = $this->wrapperPrefix.$path; + + /* + * This looks strange, but at least in PHP 5.3.8 it will raise an E_WARNING if the 4th parameter is null. + * fopen() will be called only once with the correct arguments. + * + * The error suppression is solely to determine whether the file exists. + * file_exists() is not used as not all wrappers support stat() to actually check for existing resources. + */ + if (($this->context && !$resource = @fopen($name, 'r', null, $this->context)) || !$resource = @fopen($name, 'r')) { + throw new NotFoundHttpException('Source image not found.'); + } + + // Closing the opened stream to avoid locking of the resource to find. + fclose($resource); + + return $this->imagine->load(file_get_contents($name, null, $this->context)); + } +} diff --git a/Imagine/Data/Transformer/PdfTransformer.php b/Imagine/Data/Transformer/PdfTransformer.php new file mode 100644 index 000000000..5d0e2acaa --- /dev/null +++ b/Imagine/Data/Transformer/PdfTransformer.php @@ -0,0 +1,38 @@ +imagick = $imagick; + } + + /** + * {@inheritDoc} + */ + public function apply($absolutePath) + { + $info = pathinfo($absolutePath); + + if (isset($info['extension']) && false !== strpos(strtolower($info['extension']), 'pdf')) { + // If it doesn't exists, extract the first page of the PDF + if (!file_exists("$absolutePath.png")) { + $this->imagick->readImage($absolutePath.'[0]'); + $this->imagick->setImageFormat('png'); + $this->imagick->writeImage("$absolutePath.png"); + $this->imagick->clear(); + } + + $absolutePath .= '.png'; + } + + return $absolutePath; + } +} diff --git a/Imagine/Data/Transformer/TransformerInterface.php b/Imagine/Data/Transformer/TransformerInterface.php new file mode 100644 index 000000000..fd7097ec7 --- /dev/null +++ b/Imagine/Data/Transformer/TransformerInterface.php @@ -0,0 +1,15 @@ +imagine = $imagine; - $this->formats = $formats; - $this->webRoot = realpath($webRoot); - } - - /** - * @param string $path - * - * @return Imagine\Image\ImageInterface - */ - public function find($path) - { - $path = $this->webRoot.'/'.ltrim($path, '/');; - - $info = pathinfo($path); - - $name = $info['dirname'].'/'.$info['filename']; - $targetFormat = empty($this->formats) || in_array($info['extension'], $this->formats) - ? $info['extension'] : null; - - if (empty($targetFormat) || !file_exists($path)) { - // attempt to determine path and format - $path = null; - foreach ($this->formats as $format) { - if ($targetFormat !== $format - && file_exists($name.'.'.$format) - ) { - $path = $name.'.'.$format; - break; - } - } - - if (!$path) { - throw new NotFoundHttpException(sprintf('Source image not found in "%s"', $path)); - } - } - - return $this->imagine->open($path); - } -} diff --git a/Imagine/DataLoader/LoaderInterface.php b/Imagine/DataLoader/LoaderInterface.php deleted file mode 100644 index cb39780f2..000000000 --- a/Imagine/DataLoader/LoaderInterface.php +++ /dev/null @@ -1,13 +0,0 @@ -filters = $filters; + } + + /** + * Gets a previously configured filter. + * + * @param string $filter + * + * @return array + * + * @throws \RuntimeException + */ + public function get($filter) + { + if (empty($this->filters[$filter])) { + throw new \RuntimeException('Filter not defined: '.$filter); + } + + return $this->filters[$filter]; + } + + /** + * Sets a configuration on the given filter. + * + * @param string $filter + * @param array $config + * + * @return array + */ + public function set($filter, array $config) + { + return $this->filters[$filter] = $config; + } +} diff --git a/Imagine/Filter/FilterManager.php b/Imagine/Filter/FilterManager.php index 3e050f4e9..a678aa72c 100644 --- a/Imagine/Filter/FilterManager.php +++ b/Imagine/Filter/FilterManager.php @@ -2,83 +2,72 @@ namespace Liip\ImagineBundle\Imagine\Filter; -use Symfony\Component\HttpFoundation\Request, - Symfony\Component\HttpFoundation\Response; - +use Imagine\Image\ImageInterface; use Liip\ImagineBundle\Imagine\Filter\Loader\LoaderInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; + class FilterManager { /** - * @var array + * @var FilterConfiguration */ - private $filters; + protected $filterConfig; /** - * @var array + * @var LoaderInterface[] */ - private $loaders; + protected $loaders = array(); /** - * @param array $filters + * Constructor. + * + * @param FilterConfiguration $filterConfig */ - public function __construct(array $filters = array()) + public function __construct(FilterConfiguration $filterConfig) { - $this->filters = $filters; - $this->loaders = array(); + $this->filterConfig = $filterConfig; } /** - * @param $name - * @param Loader\LoaderInterface $loader - * + * Adds a loader to handle the given filter. + * + * @param string $filter + * @param LoaderInterface $loader + * * @return void */ - public function addLoader($name, LoaderInterface $loader) + public function addLoader($filter, LoaderInterface $loader) { - $this->loaders[$name] = $loader; + $this->loaders[$filter] = $loader; } /** - * @param $filter - * - * @return array + * @return FilterConfiguration */ - public function getFilterConfig($filter) + public function getFilterConfiguration() { - if (empty($this->filters[$filter])) { - new \RuntimeException('Filter not defined: '.$filter); - } - - return $this->filters[$filter]; + return $this->filterConfig; } /** + * Returns a response containing the given image after applying the given filter on it. + * + * @uses FilterManager::applyFilterSet + * * @param Request $request - * @param $filter - * @param $image + * @param string $filter + * @param ImageInterface $image * @param string $localPath * * @return Response */ - public function get(Request $request, $filter, $image, $localPath) + public function get(Request $request, $filter, ImageInterface $image, $localPath) { - if (!isset($this->filters[$filter])) { - throw new \InvalidArgumentException(sprintf( - 'Could not find image filter "%s"', $filter - )); - } - - $config = $this->filters[$filter]; + $config = $this->getFilterConfiguration()->get($filter); - foreach ($config['filters'] as $filter => $options) { - if (!isset($this->loaders[$filter])) { - throw new \InvalidArgumentException(sprintf( - 'Could not find loader for "%s" filter type', $filter - )); - } - $image = $this->loaders[$filter]->load($image, $options); - } + $image = $this->applyFilter($image, $filter); if (empty($config['format'])) { $format = pathinfo($localPath, PATHINFO_EXTENSION); @@ -98,4 +87,31 @@ public function get(Request $request, $filter, $image, $localPath) return new Response($image, 200, array('Content-Type' => $contentType)); } + + /** + * Apply the provided filter set on the given Image. + * + * @param ImageInterface $image + * @param string $filter + * + * @return ImageInterface + * + * @throws \InvalidArgumentException + */ + public function applyFilter(ImageInterface $image, $filter) + { + $config = $this->getFilterConfiguration()->get($filter); + + foreach ($config['filters'] as $eachFilter => $eachOptions) { + if (!isset($this->loaders[$eachFilter])) { + throw new \InvalidArgumentException(sprintf( + 'Could not find filter loader for "%s" filter type', $eachFilter + )); + } + + $image = $this->loaders[$eachFilter]->load($image, $eachOptions); + } + + return $image; + } } diff --git a/Imagine/Filter/Loader/BackgroundFilterLoader.php b/Imagine/Filter/Loader/BackgroundFilterLoader.php new file mode 100644 index 000000000..d787cbdc8 --- /dev/null +++ b/Imagine/Filter/Loader/BackgroundFilterLoader.php @@ -0,0 +1,27 @@ +imagine = $imagine; + } + + /** + * {@inheritDoc} + */ + public function load(ImageInterface $image, array $options = array()) + { + $background = new Color(isset($options['color']) ? $options['color'] : '#fff'); + $topLeft = new Point(0, 0); + $canvas = $this->imagine->create($image->getSize(), $background); + + return $canvas->paste($image, $topLeft); + } +} diff --git a/Imagine/Filter/Loader/CropFilterLoader.php b/Imagine/Filter/Loader/CropFilterLoader.php new file mode 100644 index 000000000..7c9a72093 --- /dev/null +++ b/Imagine/Filter/Loader/CropFilterLoader.php @@ -0,0 +1,25 @@ +apply($image); + + return $image; + } +} diff --git a/Imagine/Filter/Loader/LoaderInterface.php b/Imagine/Filter/Loader/LoaderInterface.php index b834ae5d0..5deff85a0 100644 --- a/Imagine/Filter/Loader/LoaderInterface.php +++ b/Imagine/Filter/Loader/LoaderInterface.php @@ -7,10 +7,12 @@ interface LoaderInterface { /** - * @param Imagine\Image\ImagineInterface $image + * Loads and applies a filter on the given image. + * + * @param ImageInterface $image * @param array $options * - * @return Imagine\Image\ImageInterface + * @return ImageInterface */ function load(ImageInterface $image, array $options = array()); } diff --git a/Imagine/Filter/Loader/PasteFilterLoader.php b/Imagine/Filter/Loader/PasteFilterLoader.php new file mode 100644 index 000000000..bb4f06d1a --- /dev/null +++ b/Imagine/Filter/Loader/PasteFilterLoader.php @@ -0,0 +1,27 @@ +imagine = $imagine; + $this->rootPath = $rootPath; + } + + /** + * @see Liip\ImagineBundle\Imagine\Filter\Loader\LoaderInterface::load() + */ + public function load(ImageInterface $image, array $options = array()) + { + list($x, $y) = $options['start']; + $destImage = $this->imagine->open($this->rootPath.'/'.$options['image']); + + return $image->paste($destImage, new Point($x, $y)); + } +} diff --git a/Imagine/Filter/Loader/RelativeResizeFilterLoader.php b/Imagine/Filter/Loader/RelativeResizeFilterLoader.php new file mode 100644 index 000000000..88ad64a5f --- /dev/null +++ b/Imagine/Filter/Loader/RelativeResizeFilterLoader.php @@ -0,0 +1,30 @@ + +*/ +class RelativeResizeFilterLoader implements LoaderInterface +{ + /** + * {@inheritDoc} + */ + public function load(ImageInterface $image, array $options = array()) + { + if (list($method, $parameter) = each($options)) { + $filter = new RelativeResize($method, $parameter); + + return $filter->apply($image); + } + + throw new InvalidArgumentException('Expected method/parameter pair, none given'); + } +} diff --git a/Imagine/Filter/Loader/ResizeFilterLoader.php b/Imagine/Filter/Loader/ResizeFilterLoader.php new file mode 100644 index 000000000..6ac2b9a55 --- /dev/null +++ b/Imagine/Filter/Loader/ResizeFilterLoader.php @@ -0,0 +1,27 @@ + + */ +class ResizeFilterLoader implements LoaderInterface +{ + /** + * {@inheritDoc} + */ + public function load(ImageInterface $image, array $options = array()) + { + list($width, $height) = $options['size']; + + $filter = new Resize(new Box($width, $height)); + + return $filter->apply($image); + } +} diff --git a/Imagine/Filter/Loader/StripFilterLoader.php b/Imagine/Filter/Loader/StripFilterLoader.php new file mode 100644 index 000000000..e6509550e --- /dev/null +++ b/Imagine/Filter/Loader/StripFilterLoader.php @@ -0,0 +1,17 @@ +apply($image); + + return $image; + } +} diff --git a/Imagine/Filter/Loader/ThumbnailFilterLoader.php b/Imagine/Filter/Loader/ThumbnailFilterLoader.php index efdc6a22e..66020cbdc 100644 --- a/Imagine/Filter/Loader/ThumbnailFilterLoader.php +++ b/Imagine/Filter/Loader/ThumbnailFilterLoader.php @@ -2,23 +2,29 @@ namespace Liip\ImagineBundle\Imagine\Filter\Loader; -use Imagine\Image\Box; use Imagine\Filter\Basic\Thumbnail; +use Imagine\Image\Box; use Imagine\Image\ImageInterface; class ThumbnailFilterLoader implements LoaderInterface { + /** + * {@inheritDoc} + */ public function load(ImageInterface $image, array $options = array()) { - $mode = $options['mode'] === 'inset' ? - ImageInterface::THUMBNAIL_INSET : - ImageInterface::THUMBNAIL_OUTBOUND; + $mode = ImageInterface::THUMBNAIL_OUTBOUND; + if (!empty($options['mode']) && 'inset' === $options['mode']) { + $mode = ImageInterface::THUMBNAIL_INSET; + } + list($width, $height) = $options['size']; $size = $image->getSize(); $origWidth = $size->getWidth(); $origHeight = $size->getHeight(); + if (null === $width || null === $height) { if (null === $height) { $height = (int)(($width / $origWidth) * $origHeight); @@ -27,8 +33,8 @@ public function load(ImageInterface $image, array $options = array()) } } - if ((!empty($options['allow_upscale']) && $origWidth !== $width && $origHeight !== $height) - || ($origWidth > $width || $origHeight > $height) + if (($origWidth > $width || $origHeight > $height) + || (!empty($options['allow_upscale']) && ($origWidth !== $width || $origHeight !== $height)) ) { $filter = new Thumbnail(new Box($width, $height), $mode); $image = $filter->apply($image); diff --git a/Imagine/Filter/Loader/UpscaleFilterLoader.php b/Imagine/Filter/Loader/UpscaleFilterLoader.php new file mode 100644 index 000000000..27730145a --- /dev/null +++ b/Imagine/Filter/Loader/UpscaleFilterLoader.php @@ -0,0 +1,46 @@ + + */ +class UpscaleFilterLoader implements LoaderInterface +{ + /** + * {@inheritDoc} + */ + public function load(ImageInterface $image, array $options = array()) + { + if (!isset($options['min'])) { + throw new InvalidArgumentException('Missing min option.'); + } + + list($width, $height) = $options['min']; + + $size = $image->getSize(); + $origWidth = $size->getWidth(); + $origHeight = $size->getHeight(); + + if ($origWidth < $width || $origHeight < $height) { + + $widthRatio = $width / $origWidth ; + $heightRatio = $height / $origHeight; + + $ratio = $widthRatio > $heightRatio ? $widthRatio : $heightRatio; + + $filter = new Resize(new Box($origWidth * $ratio, $origHeight * $ratio)); + + return $filter->apply($image); + } + + return $image; + } +} diff --git a/Imagine/Filter/Loader/WatermarkFilterLoader.php b/Imagine/Filter/Loader/WatermarkFilterLoader.php new file mode 100644 index 000000000..2a5ca4898 --- /dev/null +++ b/Imagine/Filter/Loader/WatermarkFilterLoader.php @@ -0,0 +1,93 @@ +imagine = $imagine; + $this->rootPath = $rootPath; + } + + /** + * @see Liip\ImagineBundle\Imagine\Filter\Loader\LoaderInterface::load() + */ + public function load(ImageInterface $image, array $options = array()) + { + $options += array( + 'size' => null, + 'position' => 'center' + ); + + if (substr($options['size'], -1) == '%') { + $options['size'] = substr($options['size'], 0, -1) / 100; + } + + $watermark = $this->imagine->open($this->rootPath . '/' . $options['image']); + + $size = $image->getSize(); + $watermarkSize = $watermark->getSize(); + + // If 'null': Downscale if needed + if (!$options['size'] && ($size->getWidth() < $watermarkSize->getWidth() || $size->getHeight() < $watermarkSize->getHeight())) { + $options['size'] = 1.0; + } + + if ($options['size']) { + $factor = $options['size'] * min($size->getWidth() / $watermarkSize->getWidth(), $size->getHeight() / $watermarkSize->getHeight()); + + $watermark->resize(new Box($watermarkSize->getWidth() * $factor, $watermarkSize->getHeight() * $factor)); + $watermarkSize = $watermark->getSize(); + } + + switch ($options['position']) { + case 'topleft': + $x = 0; + $y = 0; + break; + case 'top': + $x = ($size->getWidth() - $watermarkSize->getWidth()) / 2; + $y = 0; + break; + case 'topright': + $x = $size->getWidth() - $watermarkSize->getWidth(); + $y = 0; + break; + case 'left': + $x = 0; + $y = ($size->getHeight() - $watermarkSize->getHeight()) / 2; + break; + case 'center': + $x = ($size->getWidth() - $watermarkSize->getWidth()) / 2; + $y = ($size->getHeight() - $watermarkSize->getHeight()) / 2; + break; + case 'right': + $x = $size->getWidth() - $watermarkSize->getWidth(); + $y = ($size->getHeight() - $watermarkSize->getHeight()) / 2; + break; + case 'bottomleft': + $x = 0; + $y = $size->getHeight() - $watermarkSize->getHeight(); + break; + case 'bottom': + $x = ($size->getWidth() - $watermarkSize->getWidth()) / 2; + $y = $size->getHeight() - $watermarkSize->getHeight(); + break; + case 'bottomright': + $x = $size->getWidth() - $watermarkSize->getWidth(); + $y = $size->getHeight() - $watermarkSize->getHeight(); + break; + default: + throw new \InvalidArgumentException("Unexpected position '{$options['position']}'"); + break; + } + + return $image->paste($watermark, new Point($x, $y)); + } +} diff --git a/Imagine/Filter/RelativeResize.php b/Imagine/Filter/RelativeResize.php new file mode 100644 index 000000000..14ebd4af9 --- /dev/null +++ b/Imagine/Filter/RelativeResize.php @@ -0,0 +1,42 @@ + + */ +class RelativeResize implements FilterInterface +{ + private $method; + private $parameter; + + /** + * Constructs a RelativeResize filter with the given method and argument. + * + * @param string $method BoxInterface method + * @param mixed $parameter Parameter for BoxInterface method + */ + public function __construct($method, $parameter) + { + if (!in_array($method, array('heighten', 'increase', 'scale', 'widen'))) { + throw new InvalidArgumentException(sprintf('Unsupported method: ', $method)); + } + + $this->method = $method; + $this->parameter = $parameter; + } + + /** + * {@inheritDoc} + */ + public function apply(ImageInterface $image) + { + return $image->resize(call_user_func(array($image->getSize(), $this->method), $this->parameter)); + } +} diff --git a/LiipImagineBundle.php b/LiipImagineBundle.php index c0ed338b3..70db038bf 100644 --- a/LiipImagineBundle.php +++ b/LiipImagineBundle.php @@ -3,13 +3,14 @@ namespace Liip\ImagineBundle; use Liip\ImagineBundle\DependencyInjection\Compiler\LoadersCompilerPass; + use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Bundle\Bundle; class LiipImagineBundle extends Bundle { /** - * @see Symfony\Component\HttpKernel\Bundle.Bundle::build() + * {@inheritDoc} */ public function build(ContainerBuilder $container) { diff --git a/README.md b/README.md index 6440c2582..947332e37 100644 --- a/README.md +++ b/README.md @@ -18,71 +18,20 @@ This will perform the transformation called `thumbnail`, which you can define to do a number of different things, such as resizing, cropping, drawing, masking, etc. -This bundle integrates the standalone PHP "[Imagine library](/avalanche123/Imagine)". +This bundle integrates the standalone PHP "[Imagine library](https://github.com/avalanche123/Imagine)". -## Installation - -To install this bundle, you'll need both the [Imagine library](/avalanche123/Imagine) -and this bundle. Installation depends on how your project is setup: - -### Step 1: Installation - -Add the following lines to your ``deps`` file - -``` -[Imagine] - git=http://github.com/avalanche123/Imagine.git - target=imagine - version=v0.2.0 - -[LiipImagineBundle] - git=http://github.com/liip/LiipImagineBundle.git - target=bundles/Liip/ImagineBundle -``` - -Next, update your vendors by running: - -``` bash -$ ./bin/vendors install -``` - -### Step 2: Configure the autoloader - -Add the following entries to your autoloader: - -``` php -registerNamespaces(array( - // ... - - 'Imagine' => __DIR__.'/../vendor/imagine/lib', - 'Liip' => __DIR__.'/../vendor/bundles', -)); -``` +[![Build Status](https://secure.travis-ci.org/liip/LiipImagineBundle.png)](http://travis-ci.org/liip/LiipImagineBundle) +[![Total Downloads](https://poser.pugx.org/liip/imagine-bundle/downloads.png)](https://packagist.org/packages/liip/imagine-bundle) +[![Latest Stable Version](https://poser.pugx.org/liip/imagine-bundle/v/stable.png)](https://packagist.org/packages/liip/imagine-bundle) -### Step 3: Enable the bundle -Finally, enable the bundle in the kernel: +## Installation -``` php -filter('/relative/path/to/image.jpg', 'my_thumb') ?>" /> ``` -Behind the scenes, the bundles apples the filter(s) to the image on the first +Behind the scenes, the bundles applies the filter(s) to the image on the first request and then caches the image to a similar path. On the next request, the cached image would be served directly from the file system. @@ -150,142 +99,88 @@ Note: Using the ``dev`` environment you might find that the images are not prope using the template helper. This is likely caused by having ``intercept_redirect`` enabled in your application configuration. To ensure that the images are rendered disable this option: - ``` jinja web_profiler: intercept_redirects: false ``` -## Configuration - -The default configuration for the bundle looks like this: - -``` yaml -liip_imagine: - web_root: %kernel.root_dir%/../web - cache_prefix: /media/cache - cache: true - loader: ~ - driver: gd - formats: [] - filter_sets: [] -``` - -There are several configuration options available: - - - `web_root` - must be the absolute path to you application's web root. This - is used to determine where to put generated image files, so that apache - will pick them up before handing the request to Symfony2 next time they - are requested. - - default: `%kernel.root_dir%/../web` - - - `cache_prefix` - this is also used in the path for image generation, so - as to not clutter your web root with cached images. For example by default, - the images would be written to the `web/media/cache/` directory. +## Filters - default: `/media/cache` +The LiipImagineBundle provides a set of built-in filters. +You may easily roll your own filter, see [the filters chapter in the documentation](Resources/doc/filters.md). - - `cache` - if to cache the generated image in the local file system +## Using the controller as a service - - `loader` - service id for a custom loader +If you need to use the filters in a controller, you can just load `ImagineController.php` controller as a service and handle the response: - default: null (which means the standard filesystem loader is used) - - - `driver` - one of the three drivers: `gd`, `imagick`, `gmagick` - - default: `gd` - - - `formats` - optional list of image formats to which images may be converted to. - - - `filter_sets` - specify the filter sets that you want to define and use - -Each filter set that you specify have the following options: - - - `filters` - determine the type of filter to be used (refer to *Filters* section for more information) - and options that should be passed to the specific filter type - - `path` - used in place of the filter name to determine the path in combination with the global `cache_prefix` - - `quality` - override the default quality of 100 for the generated images - - `format` - to hardcode the output format - -## Built-in Filters +``` php +class MyController extends Controller +{ + public function indexAction() + { + // RedirectResponse object + $imagemanagerResponse = $this->container + ->get('liip_imagine.controller') + ->filterAction( + $this->getRequest(), + 'uploads/foo.jpg', // original image you want to apply a filter to + 'my_thumb' // filter defined in config.yml + ); + + // string to put directly in the "src" of the tag + $cacheManager = $this->container->get('liip_imagine.cache.manager'); + $srcPath = $cacheManager->getBrowserPath('uploads/foo.jpg', 'my_thumb'); + + // .. + } +} +``` -Currently, this bundles comes with just one built-in filter: `thumbnail`. +In case you need to add more logic the recommended solution is to either extend `ImagineController.php` controller or take the code from that controller as a basis for your own controller. -### The `thumbnail` filter +## Outside the web root -The thumbnail filter, as the name implies, performs a thumbnail transformation -on your image. Configuration looks like this: +When your setup requires your source images to live outside the web root, or if that's just the way you roll, +you have to set the bundle's parameter `data_root` in the `config.yml` with the absolute path where your source images are +located: ``` yaml liip_imagine: - filter_sets: - my_thumb: - filters: - thumbnail: { size: [120, 90], mode: outbound } + data_root: /path/to/source/images/dir ``` -The `mode` can be either `outbound` or `inset`. - -## Load your Custom Filters - -The ImagineBundle allows you to load your own custom filter classes. The only -requirement is that each filter loader implement the following interface: - - Liip\ImagineBundle\Imagine\Filter\Loader\LoaderInterface - -To tell the bundle about your new filter loader, register it in the service -container and apply the `liip_imagine.filter.loader` tag to it (example here in XML): +Afterwards, you need to grant read access on Apache to access the images source directory. For achieving it you have +to add the following directive to your project's vhost file: ``` xml - - - -``` - -For more information on the service container, see the Symfony2 -[Service Container](http://symfony.com/doc/current/book/service_container.html) documentation. - -You can now reference and use your custom filter when defining filter sets you'd -like to apply in your configuration: + + -``` yaml -liip_imagine: - filter_sets: - my_special_style: - filters: - my_custom_filter: { } + Alias /FavouriteAlias /path/to/source/images/dir + + AllowOverride None + Allow from All + + ``` -For an example of a filter loader implementation, refer to -`Liip\ImagineBundle\Imagine\Filter\Loader\ThumbnailFilterLoader`. - -## Custom image loaders - -The ImagineBundle allows you to add your custom image loader classes. The only -requirement is that each data loader implement the following interface: - - Liip\ImagineBundle\Imagine\DataLoader\LoaderInterface - -To tell the bundle about your new filter loader, register it in the service -container just like any other service: +Another way would be placing the directive in a separate file living inside your project. For instance, +you can create a file `app/config/apache/photos.xml` and add to the project's vhost the following directive: ``` xml - - - %liip_imagine.formats% - + + + + Include "/path/to/your/project/app/config/apache/photos.xml" + ``` -For more information on the service container, see the Symfony2 -[Service Container](http://symfony.com/doc/current/book/service_container.html) documentation. +This way you keep the file along with your code and you are able to change your files directory access easily or create +different environment-dependant configuration files. -You can enable your custom data loader by adding it to the your configuration: +Either way, once you have granted access on Apache to read the `data_root` files, the relative path of an image with this +absolute path `/path/to/source/images/dir/logo.png` must be `/FavouriteAlias/logo.png` to be readable. -``` yaml -liip_imagine: - loader: acme_imagine.loader.my_custom -``` +## Documentation -For an example of a filter loader implementation, refer to -`Liip\ImagineBundle\Imagine\DataLoader\FileSystemLoader`. +For more detailed information about the features of this bundle, please refer to [the documentation](Resources/doc/index.md). diff --git a/Resources/config/cache_clearer.xml b/Resources/config/cache_clearer.xml new file mode 100644 index 000000000..ad956c8d8 --- /dev/null +++ b/Resources/config/cache_clearer.xml @@ -0,0 +1,19 @@ + + + + + Liip\ImagineBundle\Imagine\Cache\CacheClearer + + + + + + + + %liip_imagine.cache_prefix% + + + + diff --git a/Resources/config/imagine.xml b/Resources/config/imagine.xml index a7dfc219f..02edbb8c2 100644 --- a/Resources/config/imagine.xml +++ b/Resources/config/imagine.xml @@ -7,9 +7,10 @@ + Liip\ImagineBundle\Imagine\Filter\FilterConfiguration Liip\ImagineBundle\Imagine\Filter\FilterManager - Liip\ImagineBundle\Imagine\CachePathResolver - Liip\ImagineBundle\Imagine\DataLoader\FileSystemLoader + Liip\ImagineBundle\Imagine\Data\DataManager + Liip\ImagineBundle\Imagine\Cache\CacheManager @@ -32,71 +33,169 @@ + Liip\ImagineBundle\Imagine\Filter\Loader\RelativeResizeFilterLoader + Liip\ImagineBundle\Imagine\Filter\Loader\ResizeFilterLoader Liip\ImagineBundle\Imagine\Filter\Loader\ThumbnailFilterLoader + Liip\ImagineBundle\Imagine\Filter\Loader\CropFilterLoader + Liip\ImagineBundle\Imagine\Filter\Loader\PasteFilterLoader + Liip\ImagineBundle\Imagine\Filter\Loader\WatermarkFilterLoader + Liip\ImagineBundle\Imagine\Filter\Loader\StripFilterLoader + Liip\ImagineBundle\Imagine\Filter\Loader\BackgroundFilterLoader + Liip\ImagineBundle\Imagine\Filter\Loader\UpscaleFilterLoader + + + + Liip\ImagineBundle\Imagine\Data\Loader\FileSystemLoader + Liip\ImagineBundle\Imagine\Data\Loader\StreamLoader + + + + Liip\ImagineBundle\Imagine\Cache\Resolver\WebPathResolver + Liip\ImagineBundle\Imagine\Cache\Resolver\NoCacheResolver + + + + Liip\ImagineBundle\Form\Type\ImageType - + - - - - %liip_imagine.web_root% + + - - %liip_imagine.filter_sets% + + + %liip_imagine.data.loader.default% - - - %liip_imagine.formats% + + + %liip_imagine.web_root% + %liip_imagine.cache.resolver.default% + + + + %liip_imagine.filter_sets% - + + + %liip_imagine.controller_action% %liip_imagine.cache_prefix% %liip_imagine.filter_sets% - - - + + + - + - + - + - + + + + + + + + + - + + + + + + + + + + + %kernel.root_dir% + + + + + + %kernel.root_dir% + + + + + + + + + + + + + + + + + + + + + %liip_imagine.formats% + %liip_imagine.data_root% + + + + + + + + + %liip_imagine.cache.resolver.base_path% + + + %liip_imagine.cache_mkdir_mode% + + + + + + + + + + + + diff --git a/Resources/doc/cache-resolver/amazons3.md b/Resources/doc/cache-resolver/amazons3.md new file mode 100644 index 000000000..98a2523b4 --- /dev/null +++ b/Resources/doc/cache-resolver/amazons3.md @@ -0,0 +1,86 @@ +# AmazonS3Resolver + +The AmazonS3Resolver requires the [aws-sdk-php](https://github.com/amazonwebservices/aws-sdk-for-php). + +You can add the SDK by adding those lines to your `deps` file. + +``` ini +[aws-sdk] + git=git://github.com/amazonwebservices/aws-sdk-for-php.git +``` + +Afterwards, you only need to configure some information regarding your AWS account and the bucket. + +``` yaml +parameters: + amazon_s3.key: 'your-aws-key' + amazon_s3.secret: 'your-aws-secret' + amazon_s3.bucket: 'your-bucket.example.com' +``` + +Now you can set up the services required: + +``` yaml +services: + acme.amazon_s3: + class: AmazonS3 + arguments: + - + key: %amazon_s3.key% + secret: %amazon_s3.secret% + # more S3 specific options, see \AmazonS3::__construct() + + acme.imagine.cache.resolver.amazon_s3: + class: Liip\ImagineBundle\Imagine\Cache\Resolver\AmazonS3Resolver + arguments: + - "@acme.amazon_s3" + - "%amazon_s3.bucket%" + tags: + - { name: 'liip_imagine.cache.resolver', resolver: 'amazon_s3' } +``` + +Now you are ready to use the `AmazonS3Resolver` by configuring the bundle. +The following example will configure the resolver is default. + +``` yaml +liip_imagine: + cache: 'amazon_s3' +``` + +If you want to use other buckets for other images, simply alter the parameter names and create additional services! + +## Object URL Options + +In order to make use of the object URL options, you can simply add a call to the service, to alter those options you need. + +``` yaml +services: + acme.imagine.cache.resolver.amazon_s3: + class: Liip\ImagineBundle\Imagine\Cache\Resolver\AmazonS3Resolver + arguments: + - "@acme.amazon_s3" + - "%amazon_s3.bucket%" + calls: + # This calls $service->setObjectUrlOption('https', true); + - [ setObjectUrlOption, [ 'https', true ] ] + tags: + - { name: 'liip_imagine.cache.resolver', resolver: 'amazon_s3' } +``` + +You can also use the constructor of the resolver to directly inject multiple options. + +``` yaml +services: + acme.imagine.cache.resolver.amazon_s3: + class: Liip\ImagineBundle\Imagine\Cache\Resolver\AmazonS3Resolver + arguments: + - "@acme.amazon_s3" + - "%amazon_s3.bucket%" + - "public-read" # AmazonS3::ACL_PUBLIC (default) + - { https: true, torrent: true } + tags: + - { name: 'liip_imagine.cache.resolver', resolver: 'amazon_s3' } +``` + +- [Back to cache resolvers](../cache-resolvers.md) +- [Back to the index](../index.md) diff --git a/Resources/doc/cache-resolver/aws_s3.md b/Resources/doc/cache-resolver/aws_s3.md new file mode 100644 index 000000000..249cae39e --- /dev/null +++ b/Resources/doc/cache-resolver/aws_s3.md @@ -0,0 +1,89 @@ +# AwsS3Resolver + +The AwsS3Resolver requires the [aws-sdk-php](https://github.com/aws/aws-sdk-php). + +You can add the SDK by adding those lines to your `deps` file. + +``` ini +[aws-sdk] + git=git://github.com/aws/aws-sdk-php.git +``` + +Afterwards, you only need to configure some information regarding your AWS account and the bucket. + +``` yaml +parameters: + amazon_s3.key: 'your-aws-key' + amazon_s3.secret: 'your-aws-secret' + amazon_s3.bucket: 'your-bucket.example.com' + amazon_s3.region: 'your-bucket-region' +``` + +Now you can set up the services required: + +``` yaml +services: + acme.amazon_s3: + class: Aws\S3\S3Client + factory_class: Aws\S3\S3Client + factory_method: factory + arguments: + - + key: %amazon_s3.key% + secret: %amazon_s3.secret% + region: %amazon_s3.region% + + acme.imagine.cache.resolver.amazon_s3: + class: Liip\ImagineBundle\Imagine\Cache\Resolver\AwsS3Resolver + arguments: + - "@acme.amazon_s3" + - "%amazon_s3.bucket%" + tags: + - { name: 'liip_imagine.cache.resolver', resolver: 'amazon_s3' } +``` + +Now you are ready to use the `AwsS3Resolver` by configuring the bundle. +The following example will configure the resolver is default. + +``` yaml +liip_imagine: + cache: 'amazon_s3' +``` + +If you want to use other buckets for other images, simply alter the parameter names and create additional services! + +## Object URL Options + +In order to make use of the object URL options, you can simply add a call to the service, to alter those options you need. + +``` yaml +services: + acme.imagine.cache.resolver.amazon_s3: + class: Liip\ImagineBundle\Imagine\Cache\Resolver\AwsS3Resolver + arguments: + - "@acme.amazon_s3" + - "%amazon_s3.bucket%" + calls: + # This calls $service->setObjectUrlOption('Scheme', 'https'); + - [ setObjectUrlOption, [ 'Scheme', 'https' ] ] + tags: + - { name: 'liip_imagine.cache.resolver', resolver: 'amazon_s3' } +``` + +You can also use the constructor of the resolver to directly inject multiple options. + +``` yaml +services: + acme.imagine.cache.resolver.amazon_s3: + class: Liip\ImagineBundle\Imagine\Cache\Resolver\AwsS3Resolver + arguments: + - "@acme.amazon_s3" + - "%amazon_s3.bucket%" + - "public-read" # Aws\S3\Enum\CannedAcl::PUBLIC_READ (default) + - { Scheme: https } + tags: + - { name: 'liip_imagine.cache.resolver', resolver: 'amazon_s3' } +``` + +- [Back to cache resolvers](../cache-resolvers.md) +- [Back to the index](../index.md) diff --git a/Resources/doc/cache-resolver/cache.md b/Resources/doc/cache-resolver/cache.md new file mode 100644 index 000000000..75d1b68eb --- /dev/null +++ b/Resources/doc/cache-resolver/cache.md @@ -0,0 +1,61 @@ +# CacheResolver + +The `CacheResolver` requires the [Doctrine Cache](https://github.com/doctrine/cache). + +This resolver wraps another resolver around a `Cache`. + +Now you can set up the services required; by example using the `AmazonS3Resolver`. + +``` yaml +services: + acme.amazon_s3: + class: AmazonS3 + arguments: + - + key: %amazon_s3.key% + secret: %amazon_s3.secret% + + acme.imagine.cache.resolver.amazon_s3: + class: Liip\ImagineBundle\Imagine\Cache\Resolver\AmazonS3Resolver + arguments: + - "@acme.amazon_s3" + - "%amazon_s3.bucket%" + + memcache: + class: Memcache + calls: + - [ 'connect', [ '127.0.0.1', 11211 ] ] + + cache.memcache: + class: Doctrine\Common\Cache\MemcacheCache + calls: + - [ 'setMemcache', [ '@memcache' ] ] + + # The actual + acme.imagine.cache.resolver.amazon_s3.cache: + class: Liip\ImagineBundle\Imagine\Cache\Resolver\CacheResolver + arguments: + - "@cache.memcache" + - "@acme.imagine.cache.resolver.amazon_s3" + - + prefix: "amazon_s3" + tags: + - { name: 'liip_imagine.cache.resolver', resolver: 'cached_amazon_s3' } +``` + +There are currently three options available when configuring the `CacheResolver`: + +* `global_prefix` A prefix for all keys within the cache. This is useful to avoid colliding keys when using the same cache for different systems. +* `prefix` A "local" prefix for this wrapper. This is useful when re-using the same resolver for multiple filters. This mainly affects the clear method. +* `index_key` The name of the index key being used to save a list of created cache keys regarding one image and filter pairing. + +Now you are ready to use the `CacheResolver` by configuring the bundle. +The following example will configure the resolver is default. + +``` yaml +liip_imagine: + cache: 'cached_amazon_s3' +``` + +- [Back to cache resolvers](../cache-resolvers.md) +- [Back to the index](../index.md) diff --git a/Resources/doc/cache-resolvers.md b/Resources/doc/cache-resolvers.md new file mode 100644 index 000000000..d3c2ceff1 --- /dev/null +++ b/Resources/doc/cache-resolvers.md @@ -0,0 +1,58 @@ +# Built-In CacheResolver + +* [AmazonS3](cache-resolver/amazons3.md) +* [AwsS3](cache-resolver/aws_s3.md) - for SDK version 2 +* [CacheResolver](cache-resolver/cache.md) + +# Custom cache resolver + +The ImagineBundle allows you to add your custom cache resolver classes. The only +requirement is that each cache resolver loader implement the following interface: + + Liip\ImagineBundle\Imagine\Cache\Resolver\ResolverInterface + +To tell the bundle about your new cache resolver, register it in the service +container and apply the `liip_imagine.cache.resolver` tag to it (example here in XML): + +``` xml + + + + + %liip_imagine.web_root% + +``` + +For more information on the service container, see the Symfony2 +[Service Container](http://symfony.com/doc/current/book/service_container.html) documentation. + +You can set your custom cache reslover by adding it to the your configuration as the new +default resolver as follows: + +``` yaml +liip_imagine: + cache: my_custom_cache +``` + +Alternatively you can only set the custom cache resolver for just a specific filter set: + +``` yaml +liip_imagine: + filter_sets: + my_special_style: + cache: my_custom_cache + filters: + my_custom_filter: { } +``` + +For an example of a cache resolver implementation, refer to +`Liip\ImagineBundle\Imagine\Cache\Resolver\WebPathResolver`. + +## CacheClearer + +Custom cache resolver classes must implement the ```clear``` method, at worst doing nothing. + +When the ```console cache:clear``` command is run, the clear method of all the registered cache +resolvers is automatically called. + +[Back to the index](index.md) diff --git a/Resources/doc/configuration.md b/Resources/doc/configuration.md new file mode 100644 index 000000000..57a7c9332 --- /dev/null +++ b/Resources/doc/configuration.md @@ -0,0 +1,100 @@ +# Configuration + +The default configuration for the bundle looks like this: + +``` yaml +liip_imagine: + driver: gd + web_root: %kernel.root_dir%/../web + data_root: %liip_imagine.web_root% + cache_mkdir_mode: 0777 + cache_prefix: /media/cache + cache: web_path + cache_clearer: true + data_loader: filesystem + controller_action: liip_imagine.controller:filterAction + formats: [] + filter_sets: + + # Prototype + name: + path: ~ + quality: 100 + format: ~ + cache: ~ + data_loader: ~ + controller_action: ~ + route: [] + filters: + + # Prototype + name: [] +``` + +There are several configuration options available: + + - `web_root` - must be the absolute path to you application's web root. This + is used to determine where to put generated image files, so that apache + will pick them up before handing the request to Symfony2 next time they + are requested. + + default: `%kernel.root_dir%/../web` + + - `data_root` - the absolute path to the location that original files should + be sourced from. This option only changes the standard filesystem loader. + + default: `%kernel.root_dir%/../web` + + - `cache_mkdir_mode` - permissions to set on generated cache directories. + Must be specified as an octal number, which means it should begin with a + leading zero. mode is ignored on Windows. + + default: `0777` + + - `cache_prefix` - this is also used in the path for image generation, so + as to not clutter your web root with cached images. For example by default, + the images would be written to the `web/media/cache/` directory. + + default: `/media/cache` + + - `cache` - default cache resolver + + default: web_path (which means the standard web_path resolver is used) + + - `cache_clearer` - Whether or not to clear the image cache when the `kernel.cache_clearer` event occurs. + This option doesn't have any effect in symfony < 2.1 + + default: true + + - `data_loader` - name of a custom data loader + + default: filesystem (which means the standard filesystem loader is used) + + - `controller_action` - name of the controller action to use in the route loader + + default: liip_imagine.controller:filterAction + + - `driver` - one of the three drivers: `gd`, `imagick`, `gmagick` + + default: `gd` + + - `formats` - optional list of image formats to which images may be converted to. + + - `filter_sets` - specify the filter sets that you want to define and use + +Each filter set that you specify has the following options: + + - `filters` - determine the type of filter to be used (refer to *Filters* section for more information) + and options that should be passed to the specific filter type + - `path` - used in place of the filter name to determine the path in combination with the global `cache_prefix` + - `quality` - override the default quality of 100 for the generated images + - `cache` - override the default cache setting + - `data_loader` - override the default data loader + - `controller_action` - override the default controller action + - `route` - optional list of route requirements, defaults and options using in the route loader. Add array with keys 'requirements', 'defaults' or 'options'. + + default: empty array + + - `format` - hardcodes the output format (aka the requested format is ignored) + +[Back to the index](index.md) diff --git a/Resources/doc/data-loader/gridfs.md b/Resources/doc/data-loader/gridfs.md new file mode 100644 index 000000000..454c4478c --- /dev/null +++ b/Resources/doc/data-loader/gridfs.md @@ -0,0 +1,32 @@ +# GridFSLoader + +Load your images from [MongoDB GridFS](http://docs.mongodb.org/manual/applications/gridfs/). + +``` yaml +liip_imagine: + filter_sets: + my_special_style: + data_loader: grid_fs + filters: + my_custom_filter: { } +``` + +Add loader to your services: + +``` xml + + + + + Application\ImageBundle\Document\Image + +``` + +Reference the image by its id: + +``` jinja + +``` + +- [Back to data loaders](../data-loaders.md) +- [Back to the index](../index.md) diff --git a/Resources/doc/data-loader/stream.md b/Resources/doc/data-loader/stream.md new file mode 100644 index 000000000..e190c4bfc --- /dev/null +++ b/Resources/doc/data-loader/stream.md @@ -0,0 +1,25 @@ +# StreamLoader + +The `Liip\ImagineBundle\Imagine\Data\Loader\StreamLoader` allows to read images from any stream registered +thus allowing you to serve your images from literally anywhere. + +The example service definition shows how to use a stream wrapped by the [Gaufrette](https://github.com/KnpLabs/Gaufrette) filesystem abstraction layer. +In order to have this example working, you need to register the stream wrapper first, +refer to the [Gaufrette README](https://github.com/KnpLabs/Gaufrette/blob/master/README.markdown) on how to do this. + +If you are using the [KnpGaufretteBundle](https://github.com/KnpLabs/KnpGaufretteBundle) +you can make use of the [StreamWrapper configuration](https://github.com/KnpLabs/KnpGaufretteBundle#stream-wrapper) to register the filesystems. + +``` yaml +services: + liip_imagine.data.loader.stream.profile_photos: + class: "%liip_imagine.data.loader.stream.class%" + arguments: + - "@liip_imagine" + - 'gaufrette://profile_photos/' + tags: + - { name: 'liip_imagine.data.loader', loader: 'stream.profile_photos' } +``` + +- [Back to data loaders](../data-loaders.md) +- [Back to the index](../index.md) diff --git a/Resources/doc/data-loaders.md b/Resources/doc/data-loaders.md new file mode 100644 index 000000000..e7507e8ee --- /dev/null +++ b/Resources/doc/data-loaders.md @@ -0,0 +1,92 @@ +# Built-In DataLoader + +* [MongoDB GridFS](data-loader/gridfs.md) +* [Stream](data-loader/stream.md) + +# Other data loaders + +* [Doctrine PHPCR-ODM](http://symfony.com/doc/master/cmf/bundles/media.html#liipimagine) + You can include the CmfMediaBundle alone if you just want to use the images + but no other CMF features. + +# Custom image loaders + +The ImagineBundle allows you to add your custom image loader classes. The only +requirement is that each data loader implement the following interface: + + Liip\ImagineBundle\Imagine\Data\Loader\LoaderInterface + +To tell the bundle about your new data loader, register it in the service +container and apply the `liip_imagine.data.loader` tag to it (example here in XML): + +``` xml + + + + %liip_imagine.formats% + +``` + +For more information on the service container, see the Symfony2 +[Service Container](http://symfony.com/doc/current/book/service_container.html) documentation. + +You can set your custom data loader by adding it to the your configuration as the new +default loader as follows: + +``` yaml +liip_imagine: + data_loader: my_custom_data +``` + +Alternatively you can only set the custom data loader for just a specific filter set: + +``` yaml +liip_imagine: + filter_sets: + my_special_style: + data_loader: my_custom_data + filters: + my_custom_filter: { } +``` + + +For an example of a data loader implementation, refer to +`Liip\ImagineBundle\Imagine\Data\Loader\FileSystemLoader`. + +## Extending the image loader with data transformers + +You can extend a custom data loader to support virtually any file type using transformers. +A data tranformer is intended to transform a file before actually rendering it. You +can refer to `Liip\ImagineBundle\Imagine\Data\Loader\ExtendedFileSystemLoader` and +to `Liip\ImagineBundle\Imagine\Data\Transformer\PdfTransformer` as an example. + +ExtendedFileSystemLoader extends FileSystemLoader and takes, as argument, an array of transformers. +In the example, when a file with the pdf extension is passed to the data loader, +PdfTransformer uses a php imagick object (injected via the service container) +to extract the first page of the document and returns it to the data loader as a png image. + +To tell the bundle about the transformers, you have to register them as services +with the new loader: + +```yml +services: + imagick_object: + class: Imagick + acme_custom_transformer: + class: Acme\ImagineBundle\Imagine\Data\Transformer\MyCustomTransformer + arguments: + - '@imagick_object' + custom_loader: + class: Acme\ImagineBundle\Imagine\Data\Loader\MyCustomDataLoader + tags: + - { name: liip_imagine.data.loader, loader: custom_data_loader } + arguments: + - '@liip_imagine' + - %liip_imagine.formats% + - %liip_imagine.data_root% + - [ '@acme_custom_transformer' ] +``` + +Now you can use your custom data loader, with its transformers, setting it as in the previous section. + +[Back to the index](index.md) diff --git a/Resources/doc/filters.md b/Resources/doc/filters.md new file mode 100644 index 000000000..e0e81e58c --- /dev/null +++ b/Resources/doc/filters.md @@ -0,0 +1,196 @@ +# Filters + +## Built-in Filters + +### The `thumbnail` filter + +The thumbnail filter, as the name implies, performs a thumbnail transformation +on your image. + +The `mode` can be either `outbound` or `inset`. +Option `inset` does a relative resize, where the height and the width will not exceed the values in the configuration. +Option `outbound` does a relative resize, but the image gets cropped if width and height are not the same. + +Given an input image sized 50x40 (width x height), consider the following +annotated configuration examples: + +``` yaml +liip_imagine: + filter_sets: + my_thumb_out: + filters: + thumbnail: { size: [32, 32], mode: outbound } # Transforms 50x40 to 32x32, while cropping the width + my_thumb_in: + filters: + thumbnail: { size: [32, 32], mode: inset } # Transforms 50x40 to 32x26, no cropping +``` + +There is also an option `allow_upscale` (default: `false`). +By setting `allow_upscale` to `true`, an image which is smaller than 32x32px in the example above will be expanded to the requested size by interpolation of its content. +Without this option, a smaller image will be left as it. This means you may get images that are smaller than the specified dimensions. + +### The `relative_resize` filter + +The `relative_resize` filter may be used to `heighten`, `widen`, `increase` or +`scale` an image with respect to its existing dimensions. These options directly +correspond to methods on Imagine's `BoxInterface`. + +Given an input image sized 50x40 (width, height), consider the following +annotated configuration examples: + +``` yaml +liip_imagine: + filter_sets: + my_heighten: + filters: + relative_resize: { heighten: 60 } # Transforms 50x40 to 75x60 + my_widen: + filters: + relative_resize: { widen: 32 } # Transforms 50x40 to 32x26 + my_increase: + filters: + relative_resize: { increase: 10 } # Transforms 50x40 to 60x50 + my_widen: + filters: + relative_resize: { scale: 2.5 } # Transforms 50x40 to 125x100 +``` + +### The `upscale` filter + +The upscale filter, as the name implies, performs a upscale transformation +on your image. Configuration looks like this: + +``` yaml +liip_imagine: + filter_sets: + my_thumb: + filters: + upscale: { min: [800, 600] } +``` + +### The `crop` filter + +The crop filter, as the name implies, performs a crop transformation +on your image. Configuration looks like this: + +``` yaml +liip_imagine: + filter_sets: + my_thumb: + filters: + crop: { start: [10, 20], size: [120, 90] } +``` + +### The `strip` filter + +The strip filter removes all profiles and comments from your image. +Configuration looks like this: + +``` yaml +liip_imagine: + filter_sets: + my_thumb: + filters: + strip: ~ +``` + +### The `background` filter + +The background filter sets a background color for your image, default is white (#FFF). +Configuration looks like this: + +``` yaml +liip_imagine: + filter_sets: + my_thumb: + filters: + background: { color: '#00FFFF' } +``` + +### The `watermark` filter + +The watermark filter pastes a second image onto your image while keeping its ratio. +Configuration looks like this: + +``` yaml +liip_image: + filter_sets: + my_image: + watermark: + image: Resources/data/watermark.png + # Size of the watermark relative to the origin images size + size: 0.5 + # Position: One of topleft,top,topright,left,center,right,bottomleft,bottom,bottomright + position: center +``` + +## Load your Custom Filters + +The ImagineBundle allows you to load your own custom filter classes. The only +requirement is that each filter loader implement the following interface: + + Liip\ImagineBundle\Imagine\Filter\Loader\LoaderInterface + +To tell the bundle about your new filter loader, register it in the service +container and apply the `liip_imagine.filter.loader` tag to it (example here in XML): + +``` xml + + + +``` + +For more information on the service container, see the Symfony2 +[Service Container](http://symfony.com/doc/current/book/service_container.html) documentation. + +You can now reference and use your custom filter when defining filter sets you'd +like to apply in your configuration: + +``` yaml +liip_imagine: + filter_sets: + my_special_style: + filters: + my_custom_filter: { } +``` + +For an example of a filter loader implementation, refer to +`Liip\ImagineBundle\Imagine\Filter\Loader\ThumbnailFilterLoader`. + +## Dynamic filters + +With a custom data loader it is possible to dynamically modify the configuration that will +be applied to the image. Inside the controller you can access the ``FilterConfiguration`` +instance, dynamically adjust the filter configuration (for example based on information +associated with the image or whatever other logic you might want) and set it again. + +A simple example showing how to change the filter configuration dynamically. This example +is of course "bogus" since hardcoded values could just as well be set in the configuration +but it illustrates the core idea. + +``` php +public function filterAction(Request $request, $path, $filter) +{ + $targetPath = $this->cacheManager->resolve($request, $path, $filter); + if ($targetPath instanceof Response) { + return $targetPath; + } + + $image = $this->dataManager->find($filter, $path); + + $filterConfig = $this->filterManager->getFilterConfiguration(); + $config = $filterConfig->get($filter); + $config['filters']['thumbnail']['size'] = array(300, 100); + $filterConfig->set($filter, $config); + + $response = $this->filterManager->get($request, $filter, $image, $path); + + if ($targetPath) { + $response = $this->cacheManager->store($response, $targetPath, $filter); + } + + return $response; +} +``` + +[Back to the index](index.md) diff --git a/Resources/doc/index.md b/Resources/doc/index.md new file mode 100644 index 000000000..3705c0592 --- /dev/null +++ b/Resources/doc/index.md @@ -0,0 +1,8 @@ +# LiipImagineBundle + +* [Installation](installation.md) +* [Introduction](introduction.md) +* [Configuration](configuration.md) +* [Filters](filters.md) +* [DataLoaders](data-loaders.md) +* [CacheResolvers](cache-resolvers.md) diff --git a/Resources/doc/installation.md b/Resources/doc/installation.md new file mode 100644 index 000000000..83b644ab8 --- /dev/null +++ b/Resources/doc/installation.md @@ -0,0 +1,49 @@ +# Installation + +To install this bundle, you'll need both the [Imagine library](https://github.com/avalanche123/Imagine) +and this bundle. + +### Step 1: Download LiipImagineBundle using composer + +Tell composer to require LiipImagineBundle by running the command: + +``` bash +$ php composer.phar require "liip/imagine-bundle:dev-master" +``` + +Composer will install the bundle to your project's `vendor/liip/imagine-bundle` directory. + + +## Step 2: Enable the bundle + +Finally, enable the bundle in the kernel: + +``` php + + {% if link_url %} + + {% endif %} + + + + {% if link_url %} + + {% endif %} + + {% endif %} + + {{ block('form_widget_simple') }} + {% endspaceless %} +{% endblock %} diff --git a/Routing/ImagineLoader.php b/Routing/ImagineLoader.php index bf8414f1b..c26a673c3 100644 --- a/Routing/ImagineLoader.php +++ b/Routing/ImagineLoader.php @@ -2,20 +2,21 @@ namespace Liip\ImagineBundle\Routing; +use Symfony\Component\Config\Loader\Loader; use Symfony\Component\Routing\Route; - use Symfony\Component\Routing\RouteCollection; -use Symfony\Component\Config\Loader\Loader; class ImagineLoader extends Loader { + private $controllerAction; private $cachePrefix; private $filters; - public function __construct($cachePrefix, array $filters = array()) + public function __construct($controllerAction, $cachePrefix, array $filters = array()) { + $this->controllerAction = $controllerAction; $this->cachePrefix = $cachePrefix; - $this->filters = $filters; + $this->filters = $filters; } public function supports($resource, $type = null) @@ -26,7 +27,6 @@ public function supports($resource, $type = null) public function load($resource, $type = null) { $requirements = array('_method' => 'GET', 'filter' => '[A-z0-9_\-]*', 'path' => '.+'); - $defaults = array('_controller' => 'liip_imagine.controller:filterAction'); $routes = new RouteCollection(); if (count($this->filters) > 0) { @@ -40,10 +40,30 @@ public function load($resource, $type = null) $pattern .= '/'.$filter; } + $defaults = array( + '_controller' => empty($config['controller_action']) ? $this->controllerAction : $config['controller_action'], + 'filter' => $filter, + ); + + $routeRequirements = $requirements; + $routeDefaults = $defaults; + $routeOptions = array(); + + if (isset($config['route']['requirements'])) { + $routeRequirements = array_merge($routeRequirements, $config['route']['requirements']); + } + if (isset($config['route']['defaults'])) { + $routeDefaults = array_merge($routeDefaults, $config['route']['defaults']); + } + if (isset($config['route']['options'])) { + $routeOptions = array_merge($routeOptions, $config['route']['options']); + } + $routes->add('_imagine_'.$filter, new Route( $pattern.'/{path}', - array_merge( $defaults, array('filter' => $filter)), - $requirements + $routeDefaults, + $routeRequirements, + $routeOptions )); } } diff --git a/Templating/Helper/ImagineHelper.php b/Templating/Helper/ImagineHelper.php index 8d5a52a24..a98260db7 100644 --- a/Templating/Helper/ImagineHelper.php +++ b/Templating/Helper/ImagineHelper.php @@ -2,28 +2,28 @@ namespace Liip\ImagineBundle\Templating\Helper; -use Liip\ImagineBundle\Imagine\CachePathResolver; +use Liip\ImagineBundle\Imagine\Cache\CacheManager; use Symfony\Component\Templating\Helper\Helper; class ImagineHelper extends Helper { /** - * @var Liip\ImagineBundle\Imagine\CachePathResolver + * @var CacheManager */ - private $cachePathResolver; + protected $cacheManager; /** - * Constructs by setting $cachePathResolver + * Constructor. * - * @param Liip\ImagineBundle\Imagine\CachePathResolver $cachePathResolver + * @param CacheManager $cacheManager */ - public function __construct(CachePathResolver $cachePathResolver) + public function __construct(CacheManager $cacheManager) { - $this->cachePathResolver = $cachePathResolver; + $this->cacheManager = $cacheManager; } /** - * Gets cache path of an image to be filtered + * Gets the browser path for the image and filter to apply. * * @param string $path * @param string $filter @@ -33,15 +33,14 @@ public function __construct(CachePathResolver $cachePathResolver) */ public function filter($path, $filter, $absolute = false) { - return $this->cachePathResolver->getBrowserPath($path, $filter, $absolute); + return $this->cacheManager->getBrowserPath($path, $filter, $absolute); } /** - * (non-PHPdoc) - * @see Symfony\Component\Templating\Helper.HelperInterface::getName() + * {@inheritDoc} */ public function getName() { - return 'imagine'; + return 'liip_imagine'; } } diff --git a/Templating/ImagineExtension.php b/Templating/ImagineExtension.php index 4d6fc44f7..ef8aab02d 100644 --- a/Templating/ImagineExtension.php +++ b/Templating/ImagineExtension.php @@ -2,29 +2,27 @@ namespace Liip\ImagineBundle\Templating; -use Liip\ImagineBundle\Imagine\CachePathResolver; -use Symfony\Component\HttpKernel\Util\Filesystem; +use Liip\ImagineBundle\Imagine\Cache\CacheManager; class ImagineExtension extends \Twig_Extension { /** - * @var Liip\ImagineBundle\Imagine\CachePathResolver + * @var CacheManager */ - private $cachePathResolver; + private $cacheManager; /** - * Constructs by setting $cachePathResolver + * Constructor. * - * @param Liip\ImagineBundle\Imagine\CachePathResolver $cachePathResolver + * @param CacheManager $cacheManager */ - public function __construct(CachePathResolver $cachePathResolver) + public function __construct(CacheManager $cacheManager) { - $this->cachePathResolver = $cachePathResolver; + $this->cacheManager = $cacheManager; } /** - * (non-PHPdoc) - * @see Twig_Extension::getFilters() + * {@inheritDoc} */ public function getFilters() { @@ -34,7 +32,7 @@ public function getFilters() } /** - * Gets cache path of an image to be filtered + * Gets the browser path for the image and filter to apply. * * @param string $path * @param string $filter @@ -44,15 +42,14 @@ public function getFilters() */ public function filter($path, $filter, $absolute = false) { - return $this->cachePathResolver->getBrowserPath($path, $filter, $absolute); + return $this->cacheManager->getBrowserPath($path, $filter, $absolute); } /** - * (non-PHPdoc) - * @see Twig_ExtensionInterface::getName() + * {@inheritDoc} */ public function getName() { - return 'imagine'; + return 'liip_imagine'; } } diff --git a/Tests/AbstractTest.php b/Tests/AbstractTest.php new file mode 100644 index 000000000..13a0a53d3 --- /dev/null +++ b/Tests/AbstractTest.php @@ -0,0 +1,93 @@ +fixturesDir = __DIR__.'/Fixtures'; + + $this->tempDir = str_replace('/', DIRECTORY_SEPARATOR, sys_get_temp_dir().'/liip_imagine_test'); + + $this->filesystem = new Filesystem(); + + if ($this->filesystem->exists($this->tempDir)) { + $this->filesystem->remove($this->tempDir); + } + + $this->filesystem->mkdir($this->tempDir); + } + + public function invalidPathProvider() + { + return array( + array($this->fixturesDir.'/assets/../../foobar.png'), + array($this->fixturesDir.'/assets/some_folder/../foobar.png'), + array('../../outside/foobar.jpg'), + ); + } + + protected function createFilterConfiguration() + { + $config = new FilterConfiguration(); + $config->set('thumbnail', array( + 'size' => array(180, 180), + 'mode' => 'outbound', + )); + + return $config; + } + + protected function getMockCacheManager() + { + return $this->getMock('Liip\ImagineBundle\Imagine\Cache\CacheManager', array(), array(), '', false); + } + + protected function getMockFilterConfiguration() + { + return $this->getMock('Liip\ImagineBundle\Imagine\Filter\FilterConfiguration'); + } + + protected function getMockRouter() + { + return $this->getMock('Symfony\Component\Routing\RouterInterface'); + } + + protected function getMockResolver() + { + return $this->getMock('Liip\ImagineBundle\Imagine\Cache\Resolver\ResolverInterface'); + } + + protected function getMockImage() + { + return $this->getMock('Imagine\Image\ImageInterface'); + } + + protected function getMockImagine() + { + return $this->getMock('Imagine\Image\ImagineInterface'); + } + + protected function tearDown() + { + if (!$this->filesystem) { + return; + } + + if ($this->filesystem->exists($this->tempDir)) { + $this->filesystem->remove($this->tempDir); + } + } +} diff --git a/Tests/Controller/ImagineControllerTest.php b/Tests/Controller/ImagineControllerTest.php new file mode 100644 index 000000000..891df2c25 --- /dev/null +++ b/Tests/Controller/ImagineControllerTest.php @@ -0,0 +1,132 @@ +imagine = new $eachClass; + + break; + } catch (\Exception $e) { } + } + + if (!$this->imagine) { + $this->markTestSkipped('No Imagine could be instantiated.'); + } + + $this->webRoot = $this->tempDir.'/web'; + $this->filesystem->mkdir($this->webRoot); + + $this->cacheDir = $this->webRoot.'/media/cache'; + $this->dataDir = $this->fixturesDir.'/assets'; + + $this->configuration = new FilterConfiguration(array( + 'thumbnail' => array( + 'filters' => array( + 'thumbnail' => array( + 'size' => array(300, 150), + 'mode' => 'outbound', + ), + ), + ), + )); + } + + public function testFilterActionLive() + { + $router = $this->getMockRouter(); + $router + ->expects($this->any()) + ->method('generate') + ->with('_imagine_thumbnail', array( + 'path' => 'cats.jpeg' + ), false) + ->will($this->returnValue('/media/cache/thumbnail/cats.jpeg')) + ; + + $dataLoader = new FileSystemLoader($this->imagine, array(), $this->dataDir); + + $dataManager = new DataManager($this->configuration, 'filesystem'); + $dataManager->addLoader('filesystem', $dataLoader); + + $filterLoader = new ThumbnailFilterLoader(); + + $filterManager = new FilterManager($this->configuration); + $filterManager->addLoader('thumbnail', $filterLoader); + + $webPathResolver = new WebPathResolver($this->filesystem); + + $cacheManager = new CacheManager($this->configuration, $router, $this->webRoot, 'web_path'); + $cacheManager->addResolver('web_path', $webPathResolver); + + $controller = new ImagineController($dataManager, $filterManager, $cacheManager); + + $request = Request::create('/media/cache/thumbnail/cats.jpeg'); + $response = $controller->filterAction($request, 'cats.jpeg', 'thumbnail'); + + $targetPath = realpath($this->webRoot).'/media/cache/thumbnail/cats.jpeg'; + + $this->assertInstanceOf('Symfony\Component\HttpFoundation\Response', $response); + $this->assertEquals(201, $response->getStatusCode()); + $this->assertTrue(file_exists($targetPath)); + $this->assertNotEmpty(file_get_contents($targetPath)); + + return $controller; + } + + public function testFilterDelegatesResolverResponse() + { + $response = $this->getMock('Symfony\Component\HttpFoundation\Response'); + + $cacheManager = $this->getMockCacheManager(); + $cacheManager + ->expects($this->once()) + ->method('resolve') + ->will($this->returnValue($response)) + ; + + $dataManager = $this->getMock('Liip\ImagineBundle\Imagine\Data\DataManager', array(), array($this->configuration)); + $filterManager = $this->getMock('Liip\ImagineBundle\Imagine\Filter\FilterManager', array(), array($this->configuration)); + + $controller = new ImagineController($dataManager, $filterManager, $cacheManager); + $this->assertSame($response, $controller->filterAction(Request::create('/media/cache/thumbnail/cats.jpeg'), 'cats.jpeg', 'thumbnail')); + } +} diff --git a/Tests/DependencyInjection/LiipImagineExtensionTest.php b/Tests/DependencyInjection/LiipImagineExtensionTest.php index 8354f2f17..5ad19a3b2 100644 --- a/Tests/DependencyInjection/LiipImagineExtensionTest.php +++ b/Tests/DependencyInjection/LiipImagineExtensionTest.php @@ -1,22 +1,20 @@ - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - namespace Liip\ImagineBundle\Tests\DependencyInjection; -use Symfony\Component\DependencyInjection\ContainerBuilder; +use Liip\ImagineBundle\Tests\AbstractTest; use Liip\ImagineBundle\DependencyInjection\LiipImagineExtension; -use Symfony\Component\Yaml\Parser; + +use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\HttpKernel\Kernel; +use Symfony\Component\Yaml\Parser; -class LiipImagineExtensionTest extends \PHPUnit_Framework_TestCase +/** + * @covers Liip\ImagineBundle\DependencyInjection\Configuration + * @covers Liip\ImagineBundle\DependencyInjection\LiipImagineExtension + */ +class LiipImagineExtensionTest extends AbstractTest { /** * @var \Symfony\Component\DependencyInjection\ContainerBuilder @@ -37,26 +35,46 @@ public function testLoadWithDefaults() { $this->createEmptyConfiguration(); - $this->assertParameter(true, 'liip_imagine.cache'); + $this->assertParameter('web_path', 'liip_imagine.cache.resolver.default'); $this->assertAlias('liip_imagine.gd', 'liip_imagine'); $this->assertHasDefinition('liip_imagine.controller'); $this->assertDICConstructorArguments( $this->containerBuilder->getDefinition('liip_imagine.controller'), - array(new Reference('liip_imagine.loader.filesystem'), new Reference('liip_imagine.filter.manager'), '%liip_imagine.web_root%', new Reference('liip_imagine.cache.path.resolver')) + array(new Reference('liip_imagine.data.manager'), new Reference('liip_imagine.filter.manager'), new Reference('liip_imagine.cache.manager')) ); } - public function testLoad() + public function testCacheClearerRegistration() + { + $this->createEmptyConfiguration(); + + if ('2' == Kernel::MAJOR_VERSION && '0' == Kernel::MINOR_VERSION) { + $this->assertFalse($this->containerBuilder->hasDefinition('liip_imagine.cache.clearer')); + } else { + $this->assertTrue($this->containerBuilder->hasDefinition('liip_imagine.cache.clearer')); + + $definition = $this->containerBuilder->getDefinition('liip_imagine.cache.clearer'); + $definition->hasTag('kernel.cache_clearer'); + $this->assertCount(2, $definition->getArguments()); + } + } + + public function testCacheClearerIsNotRegistered() { $this->createFullConfiguration(); - $this->assertParameter(false, 'liip_imagine.cache'); - $this->assertAlias('liip_imagine.imagick', 'liip_imagine'); - $this->assertHasDefinition('liip_imagine.controller'); - $this->assertDICConstructorArguments( - $this->containerBuilder->getDefinition('liip_imagine.controller'), - array(new Reference('acme_liip_imagine.loader'), new Reference('liip_imagine.filter.manager'), '%liip_imagine.web_root%') - ); + $this->assertFalse($this->containerBuilder->hasDefinition('liip_imagine.cache.clearer')); + } + + public function testCustomRouteRequirements() + { + $this->createFullConfiguration(); + $param = $this->containerBuilder->getParameter('liip_imagine.filter_sets'); + + $this->assertTrue(isset($param['small']['filters']['route']['requirements'])); + + $variable1 = $param['small']['filters']['route']['requirements']['variable1']; + $this->assertEquals('value1', $variable1, sprintf('%s parameter is correct', $variable1)); } /** @@ -88,11 +106,14 @@ protected function getFullConfig() web_root: ../foo/bar cache_prefix: /imagine/cache cache: false +cache_clearer: false formats: ['json', 'xml', 'jpg', 'png', 'gif'] filter_sets: small: filters: thumbnail: { size: [100, ~], mode: inset } + route: + requirements: { variable1: 'value1' } quality: 80 medium_small_cropped: filters: @@ -115,7 +136,7 @@ protected function getFullConfig() quality: 100 '': quality: 100 -loader: acme_liip_imagine.loader +data_loader: my_loader EOF; $parser = new Parser(); diff --git a/Tests/Fixtures/AmazonS3.php b/Tests/Fixtures/AmazonS3.php new file mode 100644 index 000000000..809aa1510 --- /dev/null +++ b/Tests/Fixtures/AmazonS3.php @@ -0,0 +1,23 @@ +data[$id])) ? $this->data[$id] : false; + } + + public function contains($id) + { + return isset($this->data[$id]); + } + + public function save($id, $data, $lifeTime = 0) + { + $this->data[$id] = $data; + + return true; + } + + public function delete($id) + { + unset($this->data[$id]); + + return true; + } + + public function getStats() + { + return null; + } +} diff --git a/Tests/Fixtures/Model.php b/Tests/Fixtures/Model.php new file mode 100644 index 000000000..0de5eac3b --- /dev/null +++ b/Tests/Fixtures/Model.php @@ -0,0 +1,8 @@ +markTestSkipped('The CacheClearerInterface does not exist.'); + } + + parent::setUp(); + } + + public function testClearIgnoresCacheDirectory() + { + $cacheManager = $this->getMockCacheManager(); + $cacheManager + ->expects($this->once()) + ->method('clearResolversCache') + ->with('/media/cache') + ; + + $cacheClearer = new CacheClearer($cacheManager, '/media/cache'); + $cacheClearer->clear($this->tempDir.'/cache'); + } +} diff --git a/Tests/Imagine/Cache/CacheManagerTest.php b/Tests/Imagine/Cache/CacheManagerTest.php new file mode 100644 index 000000000..d7c61d4bb --- /dev/null +++ b/Tests/Imagine/Cache/CacheManagerTest.php @@ -0,0 +1,220 @@ +getMockFilterConfiguration(), $this->getMockRouter(), $this->fixturesDir.'/assets'); + $this->assertEquals(str_replace('/', DIRECTORY_SEPARATOR, $this->fixturesDir.'/assets'), $cacheManager->getWebRoot()); + } + + public function testAddCacheManagerAwareResolver() + { + $cacheManager = new CacheManager($this->getMockFilterConfiguration(), $this->getMockRouter(), $this->fixturesDir.'/assets'); + + $resolver = $this->getMock('Liip\ImagineBundle\Tests\Fixtures\CacheManagerAwareResolver'); + $resolver + ->expects($this->once()) + ->method('setCacheManager') + ->with($cacheManager) + ; + + $cacheManager->addResolver('thumbnail', $resolver); + } + + public function testGetBrowserPathWithoutResolver() + { + $config = $this->getMockFilterConfiguration(); + $config + ->expects($this->once()) + ->method('get') + ->with('thumbnail') + ->will($this->returnValue(array( + 'size' => array(180, 180), + 'mode' => 'outbound', + 'cache' => null, + ))) + ; + + $cacheManager = new CacheManager($config, $this->getMockRouter(), $this->fixturesDir.'/assets', 'default'); + + $this->setExpectedException('InvalidArgumentException', 'Could not find resolver for "thumbnail" filter type'); + $cacheManager->getBrowserPath('cats.jpeg', 'thumbnail', true); + } + + public function testDefaultResolverUsedIfNoneSet() + { + $resolver = $this->getMockResolver(); + $resolver + ->expects($this->once()) + ->method('getBrowserPath') + ->with('cats.jpeg', 'thumbnail', true) + ; + + $config = $this->getMockFilterConfiguration(); + $config + ->expects($this->once()) + ->method('get') + ->with('thumbnail') + ->will($this->returnValue(array( + 'size' => array(180, 180), + 'mode' => 'outbound', + 'cache' => null, + ))) + ; + + $cacheManager = new CacheManager($config, $this->getMockRouter(), $this->fixturesDir.'/assets', 'default'); + $cacheManager->addResolver('default', $resolver); + + $cacheManager->getBrowserPath('cats.jpeg', 'thumbnail', true); + } + + /** + * @dataProvider invalidPathProvider + */ + public function testResolveInvalidPath($path) + { + $cacheManager = new CacheManager($this->getMockFilterConfiguration(), $this->getMockRouter(), $this->fixturesDir.'/assets'); + + $this->setExpectedException('Symfony\Component\HttpKernel\Exception\NotFoundHttpException'); + $cacheManager->resolve(new Request(), $path, 'thumbnail'); + } + + public function testResolveWithoutResolver() + { + $cacheManager = new CacheManager($this->getMockFilterConfiguration(), $this->getMockRouter(), $this->fixturesDir.'/assets'); + + $this->assertFalse($cacheManager->resolve(new Request(), 'cats.jpeg', 'thumbnail')); + } + + public function testFallbackToDefaultResolver() + { + $response = new Response('', 200); + $request = new Request(); + + $resolver = $this->getMockResolver(); + $resolver + ->expects($this->once()) + ->method('resolve') + ->with($request, 'cats.jpeg', 'thumbnail') + ->will($this->returnValue('/thumbs/cats.jpeg')) + ; + $resolver + ->expects($this->once()) + ->method('store') + ->with($response, '/thumbs/cats.jpeg', 'thumbnail') + ->will($this->returnValue($response)) + ; + $resolver + ->expects($this->once()) + ->method('remove') + ->with('/thumbs/cats.jpeg', 'thumbnail') + ->will($this->returnValue(true)) + ; + + $config = $this->getMockFilterConfiguration(); + $config + ->expects($this->exactly(3)) + ->method('get') + ->with('thumbnail') + ->will($this->returnValue(array( + 'size' => array(180, 180), + 'mode' => 'outbound', + 'cache' => null, + ))) + ; + + $cacheManager = new CacheManager($config, $this->getMockRouter(), $this->fixturesDir.'/assets', 'default'); + $cacheManager->addResolver('default', $resolver); + + // Resolve fallback to default resolver + $this->assertEquals('/thumbs/cats.jpeg', $cacheManager->resolve($request, 'cats.jpeg', 'thumbnail')); + + // Store fallback to default resolver + $this->assertEquals($response, $cacheManager->store($response, '/thumbs/cats.jpeg', 'thumbnail')); + + // Remove fallback to default resolver + $this->assertTrue($cacheManager->remove('/thumbs/cats.jpeg', 'thumbnail')); + } + + public function testClearResolversCacheClearsAll() + { + $resolver = $this->getMockResolver(); + $resolver + ->expects($this->exactly(5)) + ->method('clear') + ->with('imagine_cache') + ; + + $cacheManager = new CacheManager($this->getMockFilterConfiguration(), $this->getMockRouter(), $this->fixturesDir.'/assets', 'default'); + + $cacheManager->addResolver('default', $resolver); + $cacheManager->addResolver('thumbnail1', $resolver); + $cacheManager->addResolver('thumbnail2', $resolver); + $cacheManager->addResolver('thumbnail3', $resolver); + $cacheManager->addResolver('thumbnail4', $resolver); + + $cacheManager->clearResolversCache('imagine_cache'); + } + + public function generateUrlProvider() + { + return array( + // Simple route generation + array(array(), '/thumbnail/cats.jpeg', 'thumbnail/cats.jpeg'), + + // 'format' given, altering URL + array(array( + 'format' => 'jpg', + ), '/thumbnail/cats.jpeg', 'thumbnail/cats.jpg'), + + // No 'extension' path info + array(array( + 'format' => 'jpg', + ), '/thumbnail/cats', 'thumbnail/cats.jpg'), + + // No 'extension' path info, and no directory + array(array( + 'format' => 'jpg', + ), '/cats', 'cats.jpg'), + ); + } + + /** + * @dataProvider generateUrlProvider + */ + public function testGenerateUrl($filterConfig, $targetPath, $expectedPath) + { + $config = $this->getMockFilterConfiguration(); + $config + ->expects($this->once()) + ->method('get') + ->with('thumbnail') + ->will($this->returnValue($filterConfig)) + ; + + $router = $this->getMockRouter(); + $router + ->expects($this->once()) + ->method('generate') + ->with('_imagine_thumbnail', array( + 'path' => $expectedPath, + ), true) + ; + + $cacheManager = new CacheManager($config, $router, $this->fixturesDir.'/assets'); + $cacheManager->generateUrl($targetPath, 'thumbnail', true); + } +} diff --git a/Tests/Imagine/Cache/Resolver/AbstractFilesystemResolverTest.php b/Tests/Imagine/Cache/Resolver/AbstractFilesystemResolverTest.php new file mode 100644 index 000000000..4c5adcc88 --- /dev/null +++ b/Tests/Imagine/Cache/Resolver/AbstractFilesystemResolverTest.php @@ -0,0 +1,78 @@ +markTestSkipped('file_get_contents can not read files with utf-8 file names on windows'); + } + + $image = $this->fixturesDir.'/assets/АГГЗ.jpeg'; + + $data = file_get_contents($image); + + $response = new Response($data, 200, array( + 'content-type' => 'image/jpeg', + )); + + $targetPath = $this->tempDir.'/cached/АГГЗ.jpeg'; + + $resolver = $this->getMockAbstractFilesystemResolver(new Filesystem()); + $resolver->store($response, $targetPath, 'mirror'); + + $this->assertTrue(file_exists($targetPath)); + $this->assertEquals($data, file_get_contents($targetPath)); + } + + public function testMkdirVerifyPermissionOnLastLevel () { + if (false !== strpos(strtolower(PHP_OS), 'win')) { + $this->markTestSkipped('mkdir mode is ignored on windows'); + } + + $resolver = $this->getMockAbstractFilesystemResolver(new Filesystem()); + + + $resolver->store(new Response(''), $this->tempDir . '/first-level/second-level/cats.jpeg', 'thumbnail'); + $this->assertEquals(040777, fileperms($this->tempDir . '/first-level/second-level')); + } + + public function testMkdirVerifyPermissionOnFirstLevel () { + if (false !== strpos(strtolower(PHP_OS), 'win')) { + $this->markTestSkipped('mkdir mode is ignored on windows'); + } + + $resolver = $this->getMockAbstractFilesystemResolver(new Filesystem()); + + $resolver->store(new Response(''), $this->tempDir . '/first-level/second-level/cats.jpeg', 'thumbnail'); + $this->assertEquals(040777, fileperms($this->tempDir . '/first-level')); + } + + public function testStoreInvalidDirectory() + { + if (false !== strpos(strtolower(PHP_OS), 'win')) { + $this->markTestSkipped('mkdir mode is ignored on windows'); + } + + $resolver = $this->getMockAbstractFilesystemResolver(new Filesystem()); + + $this->filesystem->mkdir($this->tempDir.'/unwriteable', 0555); + + $this->setExpectedException('RuntimeException', 'Could not create directory '.dirname($this->tempDir.'/unwriteable/thumbnail/cats.jpeg')); + $resolver->store(new Response(''), $this->tempDir.'/unwriteable/thumbnail/cats.jpeg', 'thumbnail'); + } + + protected function getMockAbstractFilesystemResolver($filesystem) + { + return $this->getMock('Liip\ImagineBundle\Imagine\Cache\Resolver\AbstractFilesystemResolver', array('resolve', 'clear', 'getBrowserPath', 'getFilePath'), array($filesystem)); + } +} diff --git a/Tests/Imagine/Cache/Resolver/AmazonS3ResolverTest.php b/Tests/Imagine/Cache/Resolver/AmazonS3ResolverTest.php new file mode 100644 index 000000000..d81071e61 --- /dev/null +++ b/Tests/Imagine/Cache/Resolver/AmazonS3ResolverTest.php @@ -0,0 +1,241 @@ +fixturesDir.'/AmazonS3.php'); + } + } + + public function testNoDoubleSlashesInObjectUrl() + { + $s3 = $this->getMock('AmazonS3'); + $s3 + ->expects($this->once()) + ->method('if_object_exists') + ->with('images.example.com', 'thumb/some-folder/targetpath.jpg') + ->will($this->returnValue(true)) + ; + $s3 + ->expects($this->once()) + ->method('get_object_url') + ->with('images.example.com', 'thumb/some-folder/targetpath.jpg') + ; + + $resolver = new AmazonS3Resolver($s3, 'images.example.com'); + $resolver->getBrowserPath('/some-folder/targetpath.jpg', 'thumb'); + } + + public function testObjUrlOptions() + { + $s3 = $this->getMock('AmazonS3'); + $s3 + ->expects($this->once()) + ->method('if_object_exists') + ->will($this->returnValue(true)) + ; + $s3 + ->expects($this->once()) + ->method('get_object_url') + ->with('images.example.com', 'thumb/some-folder/targetpath.jpg', 0, array('torrent' => true)) + ; + + $resolver = new AmazonS3Resolver($s3, 'images.example.com'); + $resolver->setObjectUrlOption('torrent', true); + $resolver->getBrowserPath('/some-folder/targetpath.jpg', 'thumb'); + } + + public function testBrowserPathNotExisting() + { + $s3 = $this->getMock('AmazonS3'); + $s3 + ->expects($this->once()) + ->method('if_object_exists') + ->with('images.example.com', 'thumb/some-folder/targetpath.jpg') + ->will($this->returnValue(false)) + ; + $s3 + ->expects($this->never()) + ->method('get_object_url') + ; + + $cacheManager = $this->getMockCacheManager(); + $cacheManager + ->expects($this->once()) + ->method('generateUrl') + ->with('/some-folder/targetpath.jpg', 'thumb', false) + ->will($this->returnValue('/media/cache/thumb/some-folder/targetpath.jpg')) + ; + + $resolver = new AmazonS3Resolver($s3, 'images.example.com'); + $resolver->setCacheManager($cacheManager); + + $this->assertEquals('/media/cache/thumb/some-folder/targetpath.jpg', $resolver->getBrowserPath('/some-folder/targetpath.jpg', 'thumb')); + } + + public function testLogNotCreatedObjects() + { + $response = new Response(); + $response->setContent('foo'); + $response->headers->set('Content-Type', 'image/jpeg'); + + $s3 = $this->getMock('AmazonS3'); + $s3 + ->expects($this->once()) + ->method('create_object') + ->will($this->returnValue($this->getS3ResponseMock(false))) + ; + + $logger = $this->getMockForAbstractClass('Symfony\Component\HttpKernel\Log\LoggerInterface'); + $logger + ->expects($this->once()) + ->method('warn') + ; + + $resolver = new AmazonS3Resolver($s3, 'images.example.com'); + $resolver->setLogger($logger); + + $this->assertSame($response, $resolver->store($response, 'foobar.jpg', 'thumb')); + } + + public function testCreatedObjectRedirects() + { + $response = new Response(); + $response->setContent('foo'); + $response->headers->set('Content-Type', 'image/jpeg'); + + $s3 = $this->getMock('AmazonS3'); + $s3 + ->expects($this->once()) + ->method('create_object') + ->will($this->returnValue($this->getS3ResponseMock(true))) + ; + $s3 + ->expects($this->once()) + ->method('get_object_url') + ->with('images.example.com', 'thumb/foobar.jpg', 0, array()) + ->will($this->returnValue('http://images.example.com/thumb/foobar.jpg')) + ; + + $resolver = new AmazonS3Resolver($s3, 'images.example.com'); + + $this->assertSame($response, $resolver->store($response, 'thumb/foobar.jpg', 'thumb')); + $this->assertEquals(301, $response->getStatusCode()); + $this->assertEquals('http://images.example.com/thumb/foobar.jpg', $response->headers->get('Location')); + } + + public function testResolveNewObject() + { + $s3 = $this->getMock('AmazonS3'); + $s3 + ->expects($this->once()) + ->method('if_object_exists') + ->will($this->returnValue(false)) + ; + + $resolver = new AmazonS3Resolver($s3, 'images.example.com'); + $targetPath = $resolver->resolve(new Request(), '/some-folder/targetpath.jpg', 'thumb'); + + $this->assertEquals('thumb/some-folder/targetpath.jpg', $targetPath); + } + + public function testResolveRedirectsOnExisting() + { + $s3 = $this->getMock('AmazonS3'); + $s3 + ->expects($this->once()) + ->method('if_object_exists') + ->will($this->returnValue(true)) + ; + $s3 + ->expects($this->once()) + ->method('get_object_url') + ->with('images.example.com', 'thumb/some-folder/targetpath.jpg', 0, array()) + ->will($this->returnValue('http://images.example.com/some-folder/targetpath.jpg')) + ; + + $resolver = new AmazonS3Resolver($s3, 'images.example.com'); + $response = $resolver->resolve(new Request(), '/some-folder/targetpath.jpg', 'thumb'); + + $this->assertInstanceOf('Symfony\Component\HttpFoundation\Response', $response); + $this->assertEquals(301, $response->getStatusCode()); + $this->assertEquals('http://images.example.com/some-folder/targetpath.jpg', $response->headers->get('Location')); + } + + public function testRemove() + { + $s3 = $this->getMock('AmazonS3'); + $s3 + ->expects($this->once()) + ->method('if_object_exists') + ->with('images.example.com', 'thumb/some-folder/targetpath.jpg') + ->will($this->returnValue(true)) + ; + $s3 + ->expects($this->once()) + ->method('delete_object') + ->with('images.example.com', 'thumb/some-folder/targetpath.jpg') + ->will($this->returnValue($this->getS3ResponseMock(true))) + ; + + $resolver = new AmazonS3Resolver($s3, 'images.example.com'); + $this->assertTrue($resolver->remove('thumb/some-folder/targetpath.jpg', 'thumb')); + } + + public function testRemoveNotExisting() + { + $s3 = $this->getMock('AmazonS3'); + $s3 + ->expects($this->once()) + ->method('if_object_exists') + ->with('images.example.com', 'thumb/some-folder/targetpath.jpg') + ->will($this->returnValue(false)) + ; + $s3 + ->expects($this->never()) + ->method('delete_object') + ; + + $resolver = new AmazonS3Resolver($s3, 'images.example.com'); + $this->assertTrue($resolver->remove('thumb/some-folder/targetpath.jpg', 'thumb')); + } + + public function testClearIsDisabled() + { + $s3 = $this->getMock('AmazonS3'); + $s3 + ->expects($this->never()) + ->method('delete_object') + ; + + $resolver = new AmazonS3Resolver($s3, 'images.example.com'); + $resolver->clear(''); + } + + protected function getS3ResponseMock($ok = true) + { + $s3Response = $this->getMock('AmazonS3Response'); + $s3Response + ->expects($this->once()) + ->method('isOK') + ->will($this->returnValue($ok)) + ; + + return $s3Response; + } +} diff --git a/Tests/Imagine/Cache/Resolver/AwsS3ResolverTest.php b/Tests/Imagine/Cache/Resolver/AwsS3ResolverTest.php new file mode 100644 index 000000000..4c70c1db7 --- /dev/null +++ b/Tests/Imagine/Cache/Resolver/AwsS3ResolverTest.php @@ -0,0 +1,250 @@ +fixturesDir.'/S3Client.php'); + } + + if (!class_exists('Aws\S3\Enum\CannedAcl')) { + require_once($this->fixturesDir.'/CannedAcl.php'); + } + + if (!class_exists('Guzzle\Service\Resource\Model')) { + require_once($this->fixturesDir.'/Model.php'); + } + } + + public function testNoDoubleSlashesInObjectUrl() + { + $s3 = $this->getMock('Aws\S3\S3Client'); + $s3 + ->expects($this->once()) + ->method('doesObjectExist') + ->with('images.example.com', 'thumb/some-folder/targetpath.jpg') + ->will($this->returnValue(true)) + ; + $s3 + ->expects($this->once()) + ->method('getObjectUrl') + ->with('images.example.com', 'thumb/some-folder/targetpath.jpg') + ; + + $resolver = new AwsS3Resolver($s3, 'images.example.com'); + $resolver->getBrowserPath('/some-folder/targetpath.jpg', 'thumb'); + } + + public function testObjUrlOptions() + { + $s3 = $this->getMock('Aws\S3\S3Client'); + $s3 + ->expects($this->once()) + ->method('doesObjectExist') + ->will($this->returnValue(true)) + ; + $s3 + ->expects($this->once()) + ->method('getObjectUrl') + ->with('images.example.com', 'thumb/some-folder/targetpath.jpg', 0, array('torrent' => true)) + ; + + $resolver = new AwsS3Resolver($s3, 'images.example.com'); + $resolver->setObjectUrlOption('torrent', true); + $resolver->getBrowserPath('/some-folder/targetpath.jpg', 'thumb'); + } + + public function testBrowserPathNotExisting() + { + $s3 = $this->getMock('Aws\S3\S3Client'); + $s3 + ->expects($this->once()) + ->method('doesObjectExist') + ->with('images.example.com', 'thumb/some-folder/targetpath.jpg') + ->will($this->returnValue(false)) + ; + $s3 + ->expects($this->never()) + ->method('getObjectUrl') + ; + + $cacheManager = $this->getMockCacheManager(); + $cacheManager + ->expects($this->once()) + ->method('generateUrl') + ->with('/some-folder/targetpath.jpg', 'thumb', false) + ->will($this->returnValue('/media/cache/thumb/some-folder/targetpath.jpg')) + ; + + $resolver = new AwsS3Resolver($s3, 'images.example.com'); + $resolver->setCacheManager($cacheManager); + + $this->assertEquals('/media/cache/thumb/some-folder/targetpath.jpg', $resolver->getBrowserPath('/some-folder/targetpath.jpg', 'thumb')); + } + + public function testLogNotCreatedObjects() + { + $response = new Response(); + $response->setContent('foo'); + $response->headers->set('Content-Type', 'image/jpeg'); + + $s3 = $this->getMock('Aws\S3\S3Client'); + $s3 + ->expects($this->once()) + ->method('putObject') + ->will($this->throwException(new \Exception)) + ; + + $logger = $this->getMockForAbstractClass('Symfony\Component\HttpKernel\Log\LoggerInterface'); + $logger + ->expects($this->once()) + ->method('warn') + ; + + $resolver = new AwsS3Resolver($s3, 'images.example.com'); + $resolver->setLogger($logger); + + $this->assertSame($response, $resolver->store($response, 'foobar.jpg', 'thumb')); + } + + public function testCreatedObjectRedirects() + { + $response = new Response(); + $response->setContent('foo'); + $response->headers->set('Content-Type', 'image/jpeg'); + + $responseMock = $this->getS3ResponseMock(); + + $s3 = $this->getMock('Aws\S3\S3Client'); + $s3 + ->expects($this->once()) + ->method('putObject') + ->will($this->returnValue($responseMock)) + ; + + $responseMock + ->expects($this->once()) + ->method('get') + ->with('ObjectURL') + ->will($this->returnValue('http://images.example.com/thumb/foobar.jpg')) + ; + + $resolver = new AwsS3Resolver($s3, 'images.example.com'); + + $this->assertSame($response, $resolver->store($response, 'thumb/foobar.jpg', 'thumb')); + $this->assertEquals(301, $response->getStatusCode()); + $this->assertEquals('http://images.example.com/thumb/foobar.jpg', $response->headers->get('Location')); + } + + public function testResolveNewObject() + { + $s3 = $this->getMock('Aws\S3\S3Client'); + $s3 + ->expects($this->once()) + ->method('doesObjectExist') + ->will($this->returnValue(false)) + ; + + $resolver = new AwsS3Resolver($s3, 'images.example.com'); + $targetPath = $resolver->resolve(new Request(), '/some-folder/targetpath.jpg', 'thumb'); + + $this->assertEquals('thumb/some-folder/targetpath.jpg', $targetPath); + } + + public function testResolveRedirectsOnExisting() + { + $s3 = $this->getMock('Aws\S3\S3Client'); + $s3 + ->expects($this->once()) + ->method('doesObjectExist') + ->will($this->returnValue(true)) + ; + $s3 + ->expects($this->once()) + ->method('getObjectUrl') + ->with('images.example.com', 'thumb/some-folder/targetpath.jpg', 0, array()) + ->will($this->returnValue('http://images.example.com/some-folder/targetpath.jpg')) + ; + + $resolver = new AwsS3Resolver($s3, 'images.example.com'); + $response = $resolver->resolve(new Request(), '/some-folder/targetpath.jpg', 'thumb'); + + $this->assertInstanceOf('Symfony\Component\HttpFoundation\Response', $response); + $this->assertEquals(301, $response->getStatusCode()); + $this->assertEquals('http://images.example.com/some-folder/targetpath.jpg', $response->headers->get('Location')); + } + + public function testRemove() + { + $s3 = $this->getMock('Aws\S3\S3Client'); + $s3 + ->expects($this->once()) + ->method('doesObjectExist') + ->with('images.example.com', 'thumb/some-folder/targetpath.jpg') + ->will($this->returnValue(true)) + ; + $s3 + ->expects($this->once()) + ->method('deleteObject') + ->with(array( + 'Bucket' => 'images.example.com', + 'Key' => 'thumb/some-folder/targetpath.jpg', + )) + ->will($this->returnValue($this->getS3ResponseMock(true))) + ; + + $resolver = new AwsS3Resolver($s3, 'images.example.com'); + $this->assertTrue($resolver->remove('thumb/some-folder/targetpath.jpg', 'thumb')); + } + + public function testRemoveNotExisting() + { + $s3 = $this->getMock('Aws\S3\S3Client'); + $s3 + ->expects($this->once()) + ->method('doesObjectExist') + ->with('images.example.com', 'thumb/some-folder/targetpath.jpg') + ->will($this->returnValue(false)) + ; + $s3 + ->expects($this->never()) + ->method('deleteObject') + ; + + $resolver = new AwsS3Resolver($s3, 'images.example.com'); + $this->assertTrue($resolver->remove('thumb/some-folder/targetpath.jpg', 'thumb')); + } + + public function testClearIsDisabled() + { + $s3 = $this->getMock('Aws\S3\S3Client'); + $s3 + ->expects($this->never()) + ->method('deleteObject') + ; + + $resolver = new AwsS3Resolver($s3, 'images.example.com'); + $resolver->clear(''); + } + + protected function getS3ResponseMock($ok = true) + { + $s3Response = $this->getMock('Guzzle\Service\Resource\Model'); + + return $s3Response; + } +} diff --git a/Tests/Imagine/Cache/Resolver/CacheResolverTest.php b/Tests/Imagine/Cache/Resolver/CacheResolverTest.php new file mode 100644 index 000000000..bcaff8e09 --- /dev/null +++ b/Tests/Imagine/Cache/Resolver/CacheResolverTest.php @@ -0,0 +1,128 @@ +getMockResolver(); + $resolver + ->expects($this->once()) + ->method('resolve') + ->with($request, $this->path, $this->filter) + ->will($this->returnValue($this->targetPath)) + ; + + $cacheResolver = new CacheResolver(new MemoryCache(), $resolver); + + $this->assertEquals($this->targetPath, $cacheResolver->resolve($request, $this->path, $this->filter)); + + // Call multiple times to verify the cache is used. + $this->assertEquals($this->targetPath, $cacheResolver->resolve($request, $this->path, $this->filter)); + $this->assertEquals($this->targetPath, $cacheResolver->resolve($request, $this->path, $this->filter)); + } + + public function testStoreIsForwardedToResolver() + { + $response = new Response(); + + $resolver = $this->getMockResolver(); + $resolver + ->expects($this->exactly(2)) + ->method('store') + ->with($response, $this->targetPath, $this->filter) + ->will($this->returnValue($response)) + ; + + $cacheResolver = new CacheResolver(new MemoryCache(), $resolver); + + // Call twice, as this method should not be cached. + $this->assertSame($response, $cacheResolver->store($response, $this->targetPath, $this->filter)); + $this->assertSame($response, $cacheResolver->store($response, $this->targetPath, $this->filter)); + } + + public function testGetBrowserPath() + { + $absolute = 'http://example.com' . $this->targetPath; + $relative = $this->targetPath; + + $resolver = $this->getMockResolver(); + $resolver + ->expects($this->at(0)) + ->method('getBrowserPath') + ->with($this->path, $this->filter, true) + ->will($this->returnValue($absolute)) + ; + $resolver + ->expects($this->at(1)) + ->method('getBrowserPath') + ->with($this->path, $this->filter, false) + ->will($this->returnValue($relative)) + ; + + $cacheResolver = new CacheResolver(new MemoryCache(), $resolver); + + $this->assertEquals($absolute, $cacheResolver->getBrowserPath($this->path, $this->filter, true)); + $this->assertEquals($absolute, $cacheResolver->getBrowserPath($this->path, $this->filter, true)); + + $this->assertEquals($relative, $cacheResolver->getBrowserPath($this->path, $this->filter, false)); + $this->assertEquals($relative, $cacheResolver->getBrowserPath($this->path, $this->filter, false)); + } + + /** + * @depends testResolveIsSavedToCache + */ + public function testRemoveUsesIndex() + { + $request = new Request(); + + $resolver = $this->getMockResolver(); + $resolver + ->expects($this->once()) + ->method('resolve') + ->with($request, $this->path, $this->filter) + ->will($this->returnValue($this->targetPath)) + ; + $resolver + ->expects($this->once()) + ->method('remove') + ->will($this->returnValue(true)) + ; + + $cache = new MemoryCache(); + + $cacheResolver = new CacheResolver($cache, $resolver); + $cacheResolver->resolve($request, $this->path, $this->filter); + + /* + * Three items: + * * The result of resolve. + * * The result of reverse for the targetPath. + * * The index of both entries. + */ + $this->assertCount(3, $cache->data); + + $this->assertTrue($cacheResolver->remove($this->targetPath, $this->filter)); + + // Cache including index has been removed. + $this->assertCount(0, $cache->data); + } +} diff --git a/Tests/Imagine/Cache/Resolver/WebPathResolverTest.php b/Tests/Imagine/Cache/Resolver/WebPathResolverTest.php new file mode 100644 index 000000000..fcc96be5c --- /dev/null +++ b/Tests/Imagine/Cache/Resolver/WebPathResolverTest.php @@ -0,0 +1,275 @@ +config = $this->getMockFilterConfiguration(); + $this->config + ->expects($this->any()) + ->method('get') + ->with('thumbnail') + ->will($this->returnValue(array( + 'size' => array(180, 180), + 'mode' => 'outbound', + 'cache' => null, + ))) + ; + + $this->webRoot = $this->tempDir.'/root/web'; + $this->dataRoot = $this->fixturesDir.'/assets'; + $this->cacheDir = $this->webRoot.'/media/cache'; + + $this->filesystem->mkdir($this->cacheDir); + + $this->cacheManager = $this->getMock('Liip\ImagineBundle\Imagine\Cache\CacheManager', array( + 'generateUrl', + ), array( + $this->config, $this->getMockRouter(), $this->webRoot, 'web_path' + )); + + $this->resolver = new WebPathResolver($this->filesystem); + $this->cacheManager->addResolver('web_path', $this->resolver); + } + + public function testDefaultBehavior() + { + $this->cacheManager + ->expects($this->atLeastOnce()) + ->method('generateUrl') + ->will($this->returnValue(str_replace('/', DIRECTORY_SEPARATOR, '/media/cache/thumbnail/cats.jpeg'))) + ; + + $request = $this->getMock('Symfony\Component\HttpFoundation\Request'); + $request + ->expects($this->atLeastOnce()) + ->method('getBaseUrl') + ->will($this->returnValue('/app.php')) + ; + + // Resolve the requested image for the given filter. + $targetPath = $this->resolver->resolve($request, 'cats.jpeg', 'thumbnail'); + // The realpath() is important for filesystems that are virtual in some way (encrypted, different mount options, ..) + $this->assertEquals(str_replace('/', DIRECTORY_SEPARATOR, realpath($this->cacheDir).'/thumbnail/cats.jpeg'), $targetPath, + '->resolve() correctly converts the requested file into target path within webRoot.'); + $this->assertFalse(file_exists($targetPath), + '->resolve() does not create the file within the target path.'); + + // Store the cached version of that image. + $content = file_get_contents($this->dataRoot.'/cats.jpeg'); + $response = new Response($content); + $this->resolver->store($response, $targetPath, 'thumbnail'); + $this->assertEquals(201, $response->getStatusCode(), + '->store() alters the HTTP response code to "201 - Created".'); + $this->assertTrue(file_exists($targetPath), + '->store() creates the cached image file to be served.'); + $this->assertEquals($content, file_get_contents($targetPath), + '->store() writes the content of the original Response into the cache file.'); + + // Remove the cached image. + $this->assertTrue($this->resolver->remove($targetPath, 'thumbnail'), + '->remove() reports removal of cached image file correctly.'); + $this->assertFalse(file_exists($targetPath), + '->remove() actually removes the cached file from the filesystem.'); + } + + /** + * @depends testDefaultBehavior + */ + public function testMissingRewrite() + { + $this->cacheManager + ->expects($this->atLeastOnce()) + ->method('generateUrl') + ->will($this->returnValue('/media/cache/thumbnail/cats.jpeg')) + ; + + $request = $this->getMock('Symfony\Component\HttpFoundation\Request'); + $request + ->expects($this->atLeastOnce()) + ->method('getBaseUrl') + ->will($this->returnValue('')) + ; + + // The file has already been cached by this resolver. + $targetPath = $this->resolver->resolve($request, 'cats.jpeg', 'thumbnail'); + $this->filesystem->mkdir(dirname($targetPath)); + file_put_contents($targetPath, file_get_contents($this->dataRoot.'/cats.jpeg')); + + $response = $this->resolver->resolve($request, 'cats.jpeg', 'thumbnail'); + $this->assertInstanceOf('Symfony\Component\HttpFoundation\Response', $response, + '->resolve() returns a Response instance if the target file already exists.'); + $this->assertEquals(302, $response->getStatusCode(), + '->resolve() returns the HTTP response code "302 - Found".'); + $this->assertEquals('/media/cache/thumbnail/cats.jpeg', $response->headers->get('Location'), + '->resolve() returns the expected Location of the cached image.'); + } + + /** + * @depends testMissingRewrite + */ + public function testMissingRewriteWithBaseUrl() + { + $this->cacheManager + ->expects($this->atLeastOnce()) + ->method('generateUrl') + ->will($this->returnValue('/app_dev.php/media/cache/thumbnail/cats.jpeg')) + ; + + $request = $this->getMock('Symfony\Component\HttpFoundation\Request'); + $request + ->expects($this->atLeastOnce()) + ->method('getBaseUrl') + ->will($this->returnValue('/app_dev.php')) + ; + + // The file has already been cached by this resolver. + $targetPath = $this->resolver->resolve($request, 'cats.jpeg', 'thumbnail'); + $this->filesystem->mkdir(dirname($targetPath)); + file_put_contents($targetPath, file_get_contents($this->dataRoot.'/cats.jpeg')); + + $response = $this->resolver->resolve($request, 'cats.jpeg', 'thumbnail'); + $this->assertInstanceOf('Symfony\Component\HttpFoundation\Response', $response, + '->resolve() returns a Response instance if the target file already exists.'); + $this->assertEquals(302, $response->getStatusCode(), + '->resolve() returns the HTTP response code "302 - Found".'); + $this->assertEquals('/media/cache/thumbnail/cats.jpeg', $response->headers->get('Location'), + '->resolve() returns the expected Location of the cached image.'); + } + + /** + * @depends testDefaultBehavior + */ + public function testResolveWithBasePath() + { + $this->cacheManager + ->expects($this->atLeastOnce()) + ->method('generateUrl') + ->will($this->returnValue(str_replace('/', DIRECTORY_SEPARATOR, '/sandbox/app_dev.php/media/cache/thumbnail/cats.jpeg'))) + ; + + $request = $this->getMock('Symfony\Component\HttpFoundation\Request'); + $request + ->expects($this->atLeastOnce()) + ->method('getBaseUrl') + ->will($this->returnValue(str_replace('/', DIRECTORY_SEPARATOR, '/sandbox/app_dev.php'))) + ; + + // Resolve the requested image for the given filter. + $targetPath = $this->resolver->resolve($request, 'cats.jpeg', 'thumbnail'); + // The realpath() is important for filesystems that are virtual in some way (encrypted, different mount options, ..) + $this->assertEquals(str_replace('/', DIRECTORY_SEPARATOR, realpath($this->cacheDir).'/thumbnail/cats.jpeg'), $targetPath, + '->resolve() correctly converts the requested file into target path within webRoot.'); + $this->assertFalse(file_exists($targetPath), + '->resolve() does not create the file within the target path.'); + + // Store the cached version of that image. + $content = file_get_contents($this->dataRoot.'/cats.jpeg'); + $response = new Response($content); + $this->resolver->store($response, $targetPath, 'thumbnail'); + $this->assertEquals(201, $response->getStatusCode(), + '->store() alters the HTTP response code to "201 - Created".'); + $this->assertTrue(file_exists($targetPath), + '->store() creates the cached image file to be served.'); + $this->assertEquals($content, file_get_contents($targetPath), + '->store() writes the content of the original Response into the cache file.'); + + // Remove the cached image. + $this->assertTrue($this->resolver->remove($targetPath, 'thumbnail'), + '->remove() reports removal of cached image file correctly.'); + $this->assertFalse(file_exists($targetPath), + '->remove() actually removes the cached file from the filesystem.'); + } + + /** + * @depends testMissingRewrite + * @depends testResolveWithBasePath + */ + public function testMissingRewriteWithBasePathWithScriptname() + { + $this->cacheManager + ->expects($this->atLeastOnce()) + ->method('generateUrl') + ->will($this->returnValue('/sandbox/app_dev.php/media/cache/thumbnail/cats.jpeg')) + ; + + $request = $this->getMock('Symfony\Component\HttpFoundation\Request'); + $request + ->expects($this->atLeastOnce()) + ->method('getBasePath') + ->will($this->returnValue('/sandbox')) + ; + $request + ->expects($this->atLeastOnce()) + ->method('getBaseUrl') + ->will($this->returnValue('/sandbox/app_dev.php')) + ; + + // The file has already been cached by this resolver. + $targetPath = $this->resolver->resolve($request, 'cats.jpeg', 'thumbnail'); + $this->filesystem->mkdir(dirname($targetPath)); + file_put_contents($targetPath, file_get_contents($this->dataRoot.'/cats.jpeg')); + + $response = $this->resolver->resolve($request, 'cats.jpeg', 'thumbnail'); + $this->assertInstanceOf('Symfony\Component\HttpFoundation\Response', $response, + '->resolve() returns a Response instance if the target file already exists.'); + $this->assertEquals(302, $response->getStatusCode(), + '->resolve() returns the HTTP response code "302 - Found".'); + $this->assertEquals('/sandbox/media/cache/thumbnail/cats.jpeg', $response->headers->get('Location'), + '->resolve() returns the expected Location of the cached image.'); + } + + public function testClear() + { + $filename = $this->cacheDir.'/thumbnails/cats.jpeg'; + $this->filesystem->mkdir(dirname($filename)); + file_put_contents($filename, '42'); + $this->assertTrue(file_exists($filename)); + + $this->resolver->clear('/media/cache'); + + $this->assertFalse(file_exists($filename)); + } + + public function testClearWithoutPrefix() + { + $filename = $this->cacheDir.'/thumbnails/cats.jpeg'; + $this->filesystem->mkdir(dirname($filename)); + file_put_contents($filename, '42'); + $this->assertTrue(file_exists($filename)); + + try { + // This would effectively clear the web root. + $this->resolver->clear(''); + + $this->fail('Clear should not work without a valid cache prefix'); + } catch (\Exception $e) { } + + $this->assertTrue(file_exists($filename)); + } +} diff --git a/Tests/Imagine/Data/DataManagerTest.php b/Tests/Imagine/Data/DataManagerTest.php new file mode 100644 index 000000000..5e10ffc20 --- /dev/null +++ b/Tests/Imagine/Data/DataManagerTest.php @@ -0,0 +1,68 @@ +getMockLoader(); + $loader + ->expects($this->once()) + ->method('find') + ->with('cats.jpeg') + ; + + $config = $this->getMockFilterConfiguration(); + $config + ->expects($this->once()) + ->method('get') + ->with('thumbnail') + ->will($this->returnValue(array( + 'size' => array(180, 180), + 'mode' => 'outbound', + 'data_loader' => null, + ))) + ; + + $dataManager = new DataManager($config, 'default'); + $dataManager->addLoader('default', $loader); + + $dataManager->find('thumbnail', 'cats.jpeg'); + } + + public function testFindWithoutLoader() + { + $config = $this->getMockFilterConfiguration(); + $config + ->expects($this->once()) + ->method('get') + ->with('thumbnail') + ->will($this->returnValue(array( + 'size' => array(180, 180), + 'mode' => 'outbound', + 'data_loader' => null, + ))) + ; + + $dataManager = new DataManager($config); + + $this->setExpectedException('InvalidArgumentException', 'Could not find data loader for "thumbnail" filter type'); + $dataManager->find('thumbnail', 'cats.jpeg'); + } + + protected function getMockLoader() + { + return $this->getMock('Liip\ImagineBundle\Imagine\Data\Loader\LoaderInterface'); + } +} diff --git a/Tests/Imagine/Data/Loader/FileSystemLoaderTest.php b/Tests/Imagine/Data/Loader/FileSystemLoaderTest.php new file mode 100644 index 000000000..98c5073bf --- /dev/null +++ b/Tests/Imagine/Data/Loader/FileSystemLoaderTest.php @@ -0,0 +1,104 @@ +imagine = $this->getMockImagine(); + } + + /** + * @dataProvider invalidPathProvider + */ + public function testFindInvalidPath($path) + { + $loader = new FileSystemLoader($this->imagine, array(), $this->fixturesDir.'/assets'); + + $this->setExpectedException('Symfony\Component\HttpKernel\Exception\NotFoundHttpException'); + + $loader->find($path); + } + + public function testFindNotExisting() + { + $this->imagine + ->expects($this->never()) + ->method('open') + ; + + $loader = new FileSystemLoader($this->imagine, array('jpeg'), $this->tempDir); + + $file = realpath($this->tempDir).'/invalid.jpeg'; + $this->setExpectedException('Symfony\Component\HttpKernel\Exception\NotFoundHttpException', 'Source image not found in "'.$file.'"'); + + $loader->find('/invalid.jpeg'); + } + + public function testFindWithNoExtensionDoesNotThrowNotice() + { + $loader = new FileSystemLoader($this->imagine, array(), $this->tempDir); + + $this->setExpectedException('Symfony\Component\HttpKernel\Exception\NotFoundHttpException'); + + $loader->find('/invalid'); + } + + public function testFindRetrievesImage() + { + $image = $this->getMockImage(); + + $this->imagine + ->expects($this->once()) + ->method('open') + ->with(realpath($this->fixturesDir.'/assets/cats.jpeg')) + ->will($this->returnValue($image)) + ; + + $loader = new FileSystemLoader($this->imagine, array('jpeg'), $this->fixturesDir.'/assets'); + $this->assertSame($image, $loader->find('/cats.jpeg')); + } + + public function testFindGuessesFormat() + { + $image = $this->getMockImage(); + + $this->imagine + ->expects($this->once()) + ->method('open') + ->with(realpath($this->fixturesDir.'/assets/cats.jpeg')) + ->will($this->returnValue($image)) + ; + + $loader = new FileSystemLoader($this->imagine, array('jpeg'), $this->fixturesDir.'/assets'); + $this->assertSame($image, $loader->find('/cats.jpg')); + } + + public function testFindFileWithoutExtension() + { + $image = $this->getMockImage(); + + $this->filesystem->copy($this->fixturesDir.'/assets/cats.jpeg', $this->tempDir.'/cats'); + + $this->imagine + ->expects($this->once()) + ->method('open') + ->with(realpath($this->tempDir.'/cats')) + ->will($this->returnValue($image)) + ; + + $loader = new FileSystemLoader($this->imagine, array(), $this->tempDir); + $this->assertSame($image, $loader->find('/cats.jpeg')); + } +} diff --git a/Tests/Imagine/Data/Loader/StreamLoaderTest.php b/Tests/Imagine/Data/Loader/StreamLoaderTest.php new file mode 100644 index 000000000..22331c10c --- /dev/null +++ b/Tests/Imagine/Data/Loader/StreamLoaderTest.php @@ -0,0 +1,73 @@ +imagine = $this->getMockImagine(); + } + + public function testFindInvalidFile() + { + $this->imagine + ->expects($this->never()) + ->method('load') + ; + + $loader = new StreamLoader($this->imagine, 'file://'); + + $this->setExpectedException('Symfony\Component\HttpKernel\Exception\NotFoundHttpException'); + $loader->find($this->tempDir.'/invalid.jpeg'); + } + + public function testFindLoadsFile() + { + $image = $this->getMockImage(); + + $this->imagine + ->expects($this->once()) + ->method('load') + ->with(file_get_contents($this->fixturesDir.'/assets/cats.jpeg')) + ->will($this->returnValue($image)) + ; + + $loader = new StreamLoader($this->imagine, 'file://'); + $this->assertSame($image, $loader->find($this->fixturesDir.'/assets/cats.jpeg')); + } + + public function testFindWithContext() + { + $image = $this->getMockImage(); + + $this->imagine + ->expects($this->once()) + ->method('load') + ->with(file_get_contents($this->fixturesDir.'/assets/cats.jpeg')) + ->will($this->returnValue($image)) + ; + + $context = stream_context_create(); + + $loader = new StreamLoader($this->imagine, 'file://', $context); + $this->assertSame($image, $loader->find($this->fixturesDir.'/assets/cats.jpeg')); + } + + public function testConstructorInvalidContext() + { + $this->setExpectedException('InvalidArgumentException', 'The given context is no valid resource.'); + + new StreamLoader($this->imagine, 'file://', true); + } +} diff --git a/Tests/Imagine/Data/Transformer/PdfTransformerTest.php b/Tests/Imagine/Data/Transformer/PdfTransformerTest.php new file mode 100644 index 000000000..b582eb317 --- /dev/null +++ b/Tests/Imagine/Data/Transformer/PdfTransformerTest.php @@ -0,0 +1,54 @@ +markTestSkipped('Imagick is not available.'); + } + + parent::setUp(); + } + + public function testApplyWritesPng() + { + $pdfFilename = $this->tempDir.'/cats.pdf'; + $pngFilename = $pdfFilename.'.png'; + + $pdf = $this->fixturesDir.'/assets/cats.pdf'; + $this->filesystem->copy($pdf, $pdfFilename); + $this->assertTrue(file_exists($pdfFilename)); + + $transformer = new PdfTransformer(new \Imagick()); + $absolutePath = $transformer->apply($pdfFilename); + + $this->assertEquals($pngFilename, $absolutePath); + $this->assertTrue(file_exists($pngFilename)); + $this->assertNotEmpty(file_get_contents($pngFilename)); + } + + public function testApplyDoesNotOverwriteExisting() + { + $pdfFilename = $this->tempDir.'/cats.pdf'; + $pngFilename = $pdfFilename.'.png'; + $this->filesystem->touch(array( + $pdfFilename, + $pngFilename, + )); + + $transformer = new PdfTransformer(new \Imagick()); + $absolutePath = $transformer->apply($pdfFilename); + + $this->assertEquals($pngFilename, $absolutePath); + $this->assertEmpty(file_get_contents($pngFilename)); + } +} diff --git a/Tests/Imagine/Filter/FilterConfigurationTest.php b/Tests/Imagine/Filter/FilterConfigurationTest.php new file mode 100644 index 000000000..763d52328 --- /dev/null +++ b/Tests/Imagine/Filter/FilterConfigurationTest.php @@ -0,0 +1,38 @@ + array( + 'thumbnail' => array( + 'size' => array(180, 180), + 'mode' => 'outbound', + ), + ), + 'cache' => 'web_path', + ); + + $filterConfiguration = new FilterConfiguration(); + $filterConfiguration->set('profile_photo', $config); + + $this->assertEquals($config, $filterConfiguration->get('profile_photo')); + } + + public function testGetUndefinedFilter() + { + $filterConfiguration = new FilterConfiguration(); + + $this->setExpectedException('RuntimeException', 'Filter not defined: thumbnail'); + $filterConfiguration->get('thumbnail'); + } +} diff --git a/Tests/Imagine/Filter/FilterManagerTest.php b/Tests/Imagine/Filter/FilterManagerTest.php new file mode 100644 index 000000000..aa0307b18 --- /dev/null +++ b/Tests/Imagine/Filter/FilterManagerTest.php @@ -0,0 +1,223 @@ +getMockFilterConfiguration(); + $config + ->expects($this->atLeastOnce()) + ->method('get') + ->with('thumbnail') + ->will($this->returnValue(array( + 'filters' => array( + 'thumbnail' => array( + 'size' => array(180, 180), + 'mode' => 'outbound', + ), + ), + ))) + ; + $filterManager = new FilterManager($config); + + $this->setExpectedException('InvalidArgumentException', 'Could not find filter loader for "thumbnail" filter type'); + $filterManager->get(new Request(), 'thumbnail', $this->getMockImage(), 'cats.jpeg'); + } + + public function testGetDefaultBehavior() + { + $thumbConfig = array( + 'size' => array(180, 180), + 'mode' => 'outbound', + ); + + $config = $this->getMockFilterConfiguration(); + $config + ->expects($this->atLeastOnce()) + ->method('get') + ->with('thumbnail') + ->will($this->returnValue(array( + 'filters' => array( + 'thumbnail' => $thumbConfig, + ), + ))) + ; + + $image = $this->getMockImage(); + $image + ->expects($this->once()) + ->method('get') + ->with('jpeg', array('quality' => 100)) + ->will($this->returnSelf()) + ; + + $loader = $this->getMockLoader(); + $loader + ->expects($this->once()) + ->method('load') + ->with($image, $thumbConfig) + ->will($this->returnValue($image)) + ; + + $filterManager = new FilterManager($config); + $filterManager->addLoader('thumbnail', $loader); + + $request = new Request(); + $response = $filterManager->get($request, 'thumbnail', $image, $this->fixturesDir.'/assets/cats.jpeg'); + + $this->assertInstanceOf('Symfony\Component\HttpFoundation\Response', $response); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('image/jpeg', $response->headers->get('Content-Type')); + } + + public function testGetConfigAltersFormatAndQuality() + { + $thumbConfig = array( + 'size' => array(180, 180), + 'mode' => 'outbound', + ); + + $config = $this->getMockFilterConfiguration(); + $config + ->expects($this->atLeastOnce()) + ->method('get') + ->with('thumbnail') + ->will($this->returnValue(array( + 'filters' => array( + 'thumbnail' => $thumbConfig, + ), + 'format' => 'jpg', + 'quality' => 80, + ))) + ; + + $image = $this->getMockImage(); + $image + ->expects($this->once()) + ->method('get') + ->with('jpg', array('quality' => 80)) + ->will($this->returnSelf()) + ; + + $loader = $this->getMockLoader(); + $loader + ->expects($this->once()) + ->method('load') + ->with($image, $thumbConfig) + ->will($this->returnValue($image)) + ; + + $filterManager = new FilterManager($config); + $filterManager->addLoader('thumbnail', $loader); + + $request = new Request(); + $response = $filterManager->get($request, 'thumbnail', $image, $this->fixturesDir.'/assets/cats.jpeg'); + + $this->assertInstanceOf('Symfony\Component\HttpFoundation\Response', $response); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('image/jpg', $response->headers->get('Content-Type')); + } + + public function testGetRequestKnowsContentType() + { + $thumbConfig = array( + 'size' => array(180, 180), + 'mode' => 'outbound', + ); + + $config = $this->getMockFilterConfiguration(); + $config + ->expects($this->atLeastOnce()) + ->method('get') + ->with('thumbnail') + ->will($this->returnValue(array( + 'filters' => array( + 'thumbnail' => $thumbConfig, + ), + 'format' => 'jpg', + ))) + ; + + $image = $this->getMockImage(); + $image + ->expects($this->once()) + ->method('get') + ->with('jpg', array('quality' => 100)) + ->will($this->returnSelf()) + ; + + $loader = $this->getMockLoader(); + $loader + ->expects($this->once()) + ->method('load') + ->with($image, $thumbConfig) + ->will($this->returnValue($image)) + ; + + $filterManager = new FilterManager($config); + $filterManager->addLoader('thumbnail', $loader); + + $request = $this->getMock('Symfony\Component\HttpFoundation\Request'); + $request + ->expects($this->once()) + ->method('getMimeType') + ->with('jpg') + ->will($this->returnValue('image/jpeg')) + ; + + $response = $filterManager->get($request, 'thumbnail', $image, $this->fixturesDir.'/assets/cats.jpeg'); + + $this->assertInstanceOf('Symfony\Component\HttpFoundation\Response', $response); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('image/jpeg', $response->headers->get('Content-Type')); + } + + public function testApplyFilterSet() + { + $image = $this->getMockImage(); + + $thumbConfig = array( + 'size' => array(180, 180), + 'mode' => 'outbound', + ); + + $config = $this->getMockFilterConfiguration(); + $config + ->expects($this->atLeastOnce()) + ->method('get') + ->with('thumbnail') + ->will($this->returnValue(array( + 'filters' => array( + 'thumbnail' => $thumbConfig, + ), + ))) + ; + + $loader = $this->getMockLoader(); + $loader + ->expects($this->once()) + ->method('load') + ->with($image, $thumbConfig) + ->will($this->returnValue($image)) + ; + + $filterManager = new FilterManager($config); + $filterManager->addLoader('thumbnail', $loader); + + $this->assertSame($image, $filterManager->applyFilter($image, 'thumbnail')); + } + + protected function getMockLoader() + { + return $this->getMock('Liip\ImagineBundle\Imagine\Filter\Loader\LoaderInterface'); + } +} diff --git a/Tests/bootstrap.php b/Tests/bootstrap.php index 56c074c0e..7839509b8 100644 --- a/Tests/bootstrap.php +++ b/Tests/bootstrap.php @@ -1,26 +1,8 @@ registerNamespace('Symfony', SYMFONY_SRC_DIR); -$loader->registerNamespace('Imagine', IMAGINE_SRC_DIR); -$loader->register(); +$autoload = require_once $file; diff --git a/composer.json b/composer.json new file mode 100644 index 000000000..a37aaadea --- /dev/null +++ b/composer.json @@ -0,0 +1,39 @@ +{ + "name": "liip/imagine-bundle", + "type": "symfony-bundle", + "description": "This Bundle assists in imagine manipulation using the imagine library", + "keywords": ["imagine", "image"], + "homepage": "http://liip.ch", + "license": "MIT", + "minimum-stability": "dev", + "authors": [ + { + "name": "Liip and other contributors", + "homepage": "https://github.com/liip/LiipImagineBundle/contributors" + } + ], + + "require": { + "php": ">=5.3.2", + "imagine/Imagine": "0.5.*", + "symfony/finder": ">=2.0.16,~2.0", + "symfony/filesystem": ">=2.0.16,~2.0", + "symfony/options-resolver": ">=2.0.16,~2.0", + "symfony/framework-bundle": ">=2.0.16,~2.0" + }, + + "require-dev": { + "twig/twig": ">=1.0,<2.0-dev", + "symfony/yaml": ">=2.0.16,~2.0" + }, + + "suggest": { + "twig/twig": ">=1.0,<2.0-dev" + }, + + "autoload": { + "psr-0": { "Liip\\ImagineBundle": "" } + }, + + "target-dir": "Liip/ImagineBundle" +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist index c22edde42..85d1871a4 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,19 +1,10 @@ - + + - - ./Tests/ + + ./Tests @@ -23,14 +14,8 @@ ./Resources ./Tests + ./vendor - - - - - - -