vendor/contao/image/src/Resizer.php line 69

Open in your IDE?
  1. <?php
  2. declare(strict_types=1);
  3. /*
  4. * This file is part of Contao.
  5. *
  6. * (c) Leo Feyer
  7. *
  8. * @license LGPL-3.0-or-later
  9. */
  10. namespace Contao\Image;
  11. use Contao\Image\Exception\InvalidArgumentException;
  12. use Contao\Image\Metadata\ImageMetadata;
  13. use Contao\Image\Metadata\MetadataReaderWriter;
  14. use Contao\ImagineSvg\Image as SvgImage;
  15. use Imagine\Exception\InvalidArgumentException as ImagineInvalidArgumentException;
  16. use Imagine\Exception\RuntimeException as ImagineRuntimeException;
  17. use Imagine\Filter\Basic\Autorotate;
  18. use Imagine\Gd\Image as GdImage;
  19. use Imagine\Image\ImageInterface as ImagineImageInterface;
  20. use Imagine\Image\Palette\RGB;
  21. use Symfony\Component\Filesystem\Filesystem;
  22. use Symfony\Component\Filesystem\Path;
  23. /**
  24. * @method __construct(string $cacheDir, string $secret, ResizeCalculator $calculator = null, Filesystem $filesystem = null, MetadataReaderWriter $metadataReaderWriter = null)
  25. */
  26. class Resizer implements ResizerInterface
  27. {
  28. /**
  29. * @var Filesystem
  30. *
  31. * @internal
  32. */
  33. protected $filesystem;
  34. /**
  35. * @var string
  36. *
  37. * @internal
  38. */
  39. protected $cacheDir;
  40. /**
  41. * @var ResizeCalculator
  42. */
  43. private $calculator;
  44. /**
  45. * @var MetadataReaderWriter
  46. */
  47. private $metadataReaderWriter;
  48. /**
  49. * @var string|null
  50. */
  51. private $secret;
  52. /**
  53. * @param string $cacheDir
  54. * @param string $secret
  55. * @param ResizeCalculator|null $calculator
  56. * @param Filesystem|null $filesystem
  57. * @param MetadataReaderWriter|null $metadataReaderWriter
  58. */
  59. public function __construct(string $cacheDir/*, string $secret, ResizeCalculator $calculator = null, Filesystem $filesystem = null, MetadataReaderWriter $metadataReaderWriter = null*/)
  60. {
  61. if (\func_num_args() > 1 && \is_string(func_get_arg(1))) {
  62. $secret = func_get_arg(1);
  63. $calculator = \func_num_args() > 2 ? func_get_arg(2) : null;
  64. $filesystem = \func_num_args() > 3 ? func_get_arg(3) : null;
  65. $metadataReaderWriter = \func_num_args() > 4 ? func_get_arg(4) : null;
  66. } else {
  67. trigger_deprecation('contao/image', '1.2', 'Not passing a secret to "%s()" has been deprecated and will no longer work in version 2.0.', __METHOD__);
  68. $secret = null;
  69. $calculator = \func_num_args() > 1 ? func_get_arg(1) : null;
  70. $filesystem = \func_num_args() > 2 ? func_get_arg(2) : null;
  71. $metadataReaderWriter = \func_num_args() > 3 ? func_get_arg(3) : null;
  72. }
  73. if (null === $calculator) {
  74. $calculator = new ResizeCalculator();
  75. }
  76. if (null === $filesystem) {
  77. $filesystem = new Filesystem();
  78. }
  79. if (null === $metadataReaderWriter) {
  80. $metadataReaderWriter = new MetadataReaderWriter();
  81. }
  82. if (!$calculator instanceof ResizeCalculator) {
  83. $type = \is_object($calculator) ? \get_class($calculator) : \gettype($calculator);
  84. throw new \TypeError(sprintf('%s(): Argument #3 ($calculator) must be of type ResizeCalculator|null, %s given', __METHOD__, $type));
  85. }
  86. if (!$filesystem instanceof Filesystem) {
  87. $type = \is_object($filesystem) ? \get_class($filesystem) : \gettype($filesystem);
  88. throw new \TypeError(sprintf('%s(): Argument #4 ($filesystem) must be of type ResizeCalculator|null, %s given', __METHOD__, $type));
  89. }
  90. if (!$metadataReaderWriter instanceof MetadataReaderWriter) {
  91. $type = \is_object($metadataReaderWriter) ? \get_class($metadataReaderWriter) : \gettype($metadataReaderWriter);
  92. throw new \TypeError(sprintf('%s(): Argument #5 ($metadataReaderWriter) must be of type MetadataReaderWriter|null, %s given', __METHOD__, $type));
  93. }
  94. if ('' === $secret) {
  95. throw new InvalidArgumentException('$secret must not be empty');
  96. }
  97. $this->cacheDir = $cacheDir;
  98. $this->calculator = $calculator;
  99. $this->filesystem = $filesystem;
  100. $this->metadataReaderWriter = $metadataReaderWriter;
  101. $this->secret = $secret;
  102. }
  103. /**
  104. * {@inheritdoc}
  105. */
  106. public function resize(ImageInterface $image, ResizeConfiguration $config, ResizeOptions $options): ImageInterface
  107. {
  108. if (
  109. $image->getDimensions()->isUndefined()
  110. || ($config->isEmpty() && $this->canSkipResize($image, $options))
  111. ) {
  112. $image = $this->createImage($image, $image->getPath());
  113. } else {
  114. $image = $this->processResize($image, $config, $options);
  115. }
  116. if (null !== $options->getTargetPath()) {
  117. $this->filesystem->copy($image->getPath(), $options->getTargetPath(), true);
  118. $image = $this->createImage($image, $options->getTargetPath());
  119. }
  120. return $image;
  121. }
  122. /**
  123. * Executes the resize operation via Imagine.
  124. *
  125. * @internal Do not call this method in your code; it will be made private in a future version
  126. */
  127. protected function executeResize(ImageInterface $image, ResizeCoordinates $coordinates, string $path, ResizeOptions $options): ImageInterface
  128. {
  129. $dir = \dirname($path);
  130. if (!$this->filesystem->exists($dir)) {
  131. $this->filesystem->mkdir($dir);
  132. }
  133. $imagineOptions = $options->getImagineOptions();
  134. $imagineImage = $image->getImagine()->open($image->getPath());
  135. if (ImageDimensions::ORIENTATION_NORMAL !== $image->getDimensions()->getOrientation()) {
  136. (new Autorotate())->apply($imagineImage);
  137. }
  138. if ($imagineImage instanceof SvgImage || $imagineImage instanceof GdImage) {
  139. // Backwards compatibility with imagine/imagine <= 1.2.4 and contao/imagine-svg <= 1.0.3
  140. $filter = ImagineImageInterface::FILTER_UNDEFINED;
  141. } else {
  142. $filter = $imagineOptions['resampling-filter'] ?? ImagineImageInterface::FILTER_LANCZOS;
  143. }
  144. $imagineImage
  145. ->resize($coordinates->getSize(), $filter)
  146. ->crop($coordinates->getCropStart(), $coordinates->getCropSize())
  147. ->usePalette(new RGB())
  148. ->strip()
  149. ;
  150. if (isset($imagineOptions['interlace'])) {
  151. try {
  152. $imagineImage->interlace($imagineOptions['interlace']);
  153. } catch (ImagineInvalidArgumentException|ImagineRuntimeException $e) {
  154. // Ignore failed interlacing
  155. }
  156. }
  157. if (!isset($imagineOptions['format'])) {
  158. $imagineOptions['format'] = strtolower(pathinfo($path, PATHINFO_EXTENSION));
  159. }
  160. // Fix bug with undefined index notice in Imagine
  161. if ('webp' === $imagineOptions['format'] && !isset($imagineOptions['webp_quality'])) {
  162. $imagineOptions['webp_quality'] = 80;
  163. }
  164. $tmpPath1 = $this->filesystem->tempnam($dir, 'img');
  165. $tmpPath2 = $this->filesystem->tempnam($dir, 'img');
  166. $this->filesystem->chmod([$tmpPath1, $tmpPath2], 0666, umask());
  167. if ($options->getPreserveCopyrightMetadata() && ($metadata = $this->getMetadata($image))->getAll()) {
  168. $imagineImage->save($tmpPath1, $imagineOptions);
  169. try {
  170. $this->metadataReaderWriter->applyCopyrightToFile($tmpPath1, $tmpPath2, $metadata, $options->getPreserveCopyrightMetadata());
  171. } catch (\Throwable $exception) {
  172. $this->filesystem->rename($tmpPath1, $tmpPath2, true);
  173. }
  174. } else {
  175. $imagineImage->save($tmpPath2, $imagineOptions);
  176. }
  177. $this->filesystem->remove($tmpPath1);
  178. // Atomic write operation
  179. $this->filesystem->rename($tmpPath2, $path, true);
  180. return $this->createImage($image, $path);
  181. }
  182. /**
  183. * Creates a new image instance for the specified path.
  184. *
  185. * @internal Do not call this method in your code; it will be made private in a future version
  186. */
  187. protected function createImage(ImageInterface $image, string $path): ImageInterface
  188. {
  189. return new Image($path, $image->getImagine(), $this->filesystem);
  190. }
  191. /**
  192. * Processes the resize and executes it if not already cached.
  193. *
  194. * @internal
  195. */
  196. protected function processResize(ImageInterface $image, ResizeConfiguration $config, ResizeOptions $options): ImageInterface
  197. {
  198. $coordinates = $this->calculator->calculate($config, $image->getDimensions(), $image->getImportantPart());
  199. // Skip resizing if it would have no effect
  200. if (
  201. $this->canSkipResize($image, $options)
  202. && !$image->getDimensions()->isRelative()
  203. && $coordinates->isEqualTo($image->getDimensions()->getSize())
  204. ) {
  205. return $this->createImage($image, $image->getPath());
  206. }
  207. $cachePath = Path::join($this->cacheDir, $this->createCachePath($image->getPath(), $coordinates, $options, false));
  208. if (!$options->getBypassCache()) {
  209. if ($this->filesystem->exists($cachePath)) {
  210. return $this->createImage($image, $cachePath);
  211. }
  212. $legacyCachePath = Path::join($this->cacheDir, $this->createCachePath($image->getPath(), $coordinates, $options, true));
  213. if ($this->filesystem->exists($legacyCachePath)) {
  214. trigger_deprecation('contao/image', '1.2', 'Reusing old cached images like "%s" from version 1.1 has been deprecated and will no longer work in version 2.0. Clear the image cache directory "%s" and regenerate all images to get rid of this message.', $legacyCachePath, $this->cacheDir);
  215. return $this->createImage($image, $legacyCachePath);
  216. }
  217. }
  218. return $this->executeResize($image, $coordinates, $cachePath, $options);
  219. }
  220. private function canSkipResize(ImageInterface $image, ResizeOptions $options): bool
  221. {
  222. if (!$options->getSkipIfDimensionsMatch()) {
  223. return false;
  224. }
  225. if (ImageDimensions::ORIENTATION_NORMAL !== $image->getDimensions()->getOrientation()) {
  226. return false;
  227. }
  228. if (
  229. isset($options->getImagineOptions()['format'])
  230. && $options->getImagineOptions()['format'] !== strtolower(pathinfo($image->getPath(), PATHINFO_EXTENSION))
  231. ) {
  232. return false;
  233. }
  234. return true;
  235. }
  236. /**
  237. * Returns the relative target cache path.
  238. */
  239. private function createCachePath(string $path, ResizeCoordinates $coordinates, ResizeOptions $options, bool $useLegacyHash): string
  240. {
  241. $imagineOptions = $options->getImagineOptions();
  242. ksort($imagineOptions);
  243. $hashData = array_merge(
  244. [
  245. Path::makeRelative($path, $this->cacheDir),
  246. filemtime($path),
  247. $coordinates->getHash(),
  248. ],
  249. array_keys($imagineOptions),
  250. array_map(
  251. static function ($value) {
  252. return \is_array($value) ? implode(',', $value) : $value;
  253. },
  254. array_values($imagineOptions)
  255. )
  256. );
  257. $preserveMeta = $options->getPreserveCopyrightMetadata();
  258. if ($preserveMeta !== (new ResizeOptions())->getPreserveCopyrightMetadata()) {
  259. ksort($preserveMeta, SORT_STRING);
  260. $hashData[] = json_encode($preserveMeta);
  261. }
  262. if ($useLegacyHash || null === $this->secret) {
  263. $hash = substr(md5(implode('|', $hashData)), 0, 9);
  264. } else {
  265. $hash = hash_hmac('sha256', implode('|', $hashData), $this->secret, true);
  266. $hash = substr($this->encodeBase32($hash), 0, 16);
  267. }
  268. $pathinfo = pathinfo($path);
  269. $extension = $options->getImagineOptions()['format'] ?? strtolower($pathinfo['extension']);
  270. return Path::join($hash[0], $pathinfo['filename'].'-'.substr($hash, 1).'.'.$extension);
  271. }
  272. private function getMetadata(ImageInterface $image): ImageMetadata
  273. {
  274. try {
  275. return $this->metadataReaderWriter->parse($image->getPath());
  276. } catch (\Throwable $exception) {
  277. return new ImageMetadata([]);
  278. }
  279. }
  280. /**
  281. * Encode a string with Crockford’s Base32 in lowercase
  282. * (0123456789abcdefghjkmnpqrstvwxyz).
  283. */
  284. private function encodeBase32(string $bytes): string
  285. {
  286. $result = [];
  287. foreach (str_split($bytes, 5) as $chunk) {
  288. $result[] = substr(
  289. str_pad(
  290. strtr(
  291. base_convert(bin2hex(str_pad($chunk, 5, "\0")), 16, 32),
  292. 'ijklmnopqrstuv',
  293. 'jkmnpqrstvwxyz' // Crockford's Base32
  294. ),
  295. 8,
  296. '0',
  297. STR_PAD_LEFT
  298. ),
  299. 0,
  300. (int) ceil(\strlen($chunk) * 8 / 5)
  301. );
  302. }
  303. return implode('', $result);
  304. }
  305. }