ImageMedium.php 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663
  1. <?php
  2. /**
  3. * @package Grav\Common\Page
  4. *
  5. * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
  6. * @license MIT License; see LICENSE file for details.
  7. */
  8. namespace Grav\Common\Page\Medium;
  9. use Grav\Common\Data\Blueprint;
  10. use Grav\Common\Grav;
  11. use Grav\Common\Utils;
  12. use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
  13. class ImageMedium extends Medium
  14. {
  15. /**
  16. * @var array
  17. */
  18. protected $thumbnailTypes = ['page', 'media', 'default'];
  19. /**
  20. * @var ImageFile
  21. */
  22. protected $image;
  23. /**
  24. * @var string
  25. */
  26. protected $format = 'guess';
  27. /**
  28. * @var int
  29. */
  30. protected $quality;
  31. /**
  32. * @var int
  33. */
  34. protected $default_quality;
  35. /**
  36. * @var bool
  37. */
  38. protected $debug_watermarked = false;
  39. /**
  40. * @var array
  41. */
  42. public static $magic_actions = [
  43. 'resize', 'forceResize', 'cropResize', 'crop', 'zoomCrop',
  44. 'negate', 'brightness', 'contrast', 'grayscale', 'emboss',
  45. 'smooth', 'sharp', 'edge', 'colorize', 'sepia', 'enableProgressive',
  46. 'rotate', 'flip', 'fixOrientation', 'gaussianBlur'
  47. ];
  48. /**
  49. * @var array
  50. */
  51. public static $magic_resize_actions = [
  52. 'resize' => [0, 1],
  53. 'forceResize' => [0, 1],
  54. 'cropResize' => [0, 1],
  55. 'crop' => [0, 1, 2, 3],
  56. 'zoomCrop' => [0, 1]
  57. ];
  58. /**
  59. * @var string
  60. */
  61. protected $sizes = '100vw';
  62. /**
  63. * Construct.
  64. *
  65. * @param array $items
  66. * @param Blueprint $blueprint
  67. */
  68. public function __construct($items = [], Blueprint $blueprint = null)
  69. {
  70. parent::__construct($items, $blueprint);
  71. $config = Grav::instance()['config'];
  72. $path = $this->get('filepath');
  73. if (!$path || !file_exists($path) || !filesize($path)) {
  74. return;
  75. }
  76. $image_info = getimagesize($path);
  77. $this->def('width', $image_info[0]);
  78. $this->def('height', $image_info[1]);
  79. $this->def('mime', $image_info['mime']);
  80. $this->def('debug', $config->get('system.images.debug'));
  81. $this->set('thumbnails.media', $this->get('filepath'));
  82. $this->default_quality = $config->get('system.images.default_image_quality', 85);
  83. $this->reset();
  84. if ($config->get('system.images.cache_all', false)) {
  85. $this->cache();
  86. }
  87. }
  88. public function __destruct()
  89. {
  90. unset($this->image);
  91. }
  92. public function __clone()
  93. {
  94. $this->image = $this->image ? clone $this->image : null;
  95. parent::__clone();
  96. }
  97. /**
  98. * Add meta file for the medium.
  99. *
  100. * @param string $filepath
  101. * @return $this
  102. */
  103. public function addMetaFile($filepath)
  104. {
  105. parent::addMetaFile($filepath);
  106. // Apply filters in meta file
  107. $this->reset();
  108. return $this;
  109. }
  110. /**
  111. * Clear out the alternatives
  112. */
  113. public function clearAlternatives()
  114. {
  115. $this->alternatives = [];
  116. }
  117. /**
  118. * Return PATH to image.
  119. *
  120. * @param bool $reset
  121. * @return string path to image
  122. */
  123. public function path($reset = true)
  124. {
  125. $output = $this->saveImage();
  126. if ($reset) {
  127. $this->reset();
  128. }
  129. return $output;
  130. }
  131. /**
  132. * Return URL to image.
  133. *
  134. * @param bool $reset
  135. * @return string
  136. */
  137. public function url($reset = true)
  138. {
  139. /** @var UniformResourceLocator $locator */
  140. $locator = Grav::instance()['locator'];
  141. $image_path = $locator->findResource('cache://images', true) ?: $locator->findResource('cache://images', true, true);
  142. $saved_image_path = $this->saveImage();
  143. $output = preg_replace('|^' . preg_quote(GRAV_ROOT, '|') . '|', '', $saved_image_path);
  144. if ($locator->isStream($output)) {
  145. $output = $locator->findResource($output, false);
  146. }
  147. if (Utils::startsWith($output, $image_path)) {
  148. $image_dir = $locator->findResource('cache://images', false);
  149. $output = '/' . $image_dir . preg_replace('|^' . preg_quote($image_path, '|') . '|', '', $output);
  150. }
  151. if ($reset) {
  152. $this->reset();
  153. }
  154. return trim(Grav::instance()['base_url'] . '/' . $this->urlQuerystring($output), '\\');
  155. }
  156. /**
  157. * Simply processes with no extra methods. Useful for triggering events.
  158. *
  159. * @return $this
  160. */
  161. public function cache()
  162. {
  163. if (!$this->image) {
  164. $this->image();
  165. }
  166. return $this;
  167. }
  168. /**
  169. * Return srcset string for this Medium and its alternatives.
  170. *
  171. * @param bool $reset
  172. * @return string
  173. */
  174. public function srcset($reset = true)
  175. {
  176. if (empty($this->alternatives)) {
  177. if ($reset) {
  178. $this->reset();
  179. }
  180. return '';
  181. }
  182. $srcset = [];
  183. foreach ($this->alternatives as $ratio => $medium) {
  184. $srcset[] = $medium->url($reset) . ' ' . $medium->get('width') . 'w';
  185. }
  186. $srcset[] = str_replace(' ', '%20', $this->url($reset)) . ' ' . $this->get('width') . 'w';
  187. return implode(', ', $srcset);
  188. }
  189. /**
  190. * Allows the ability to override the image's pretty name stored in cache
  191. *
  192. * @param string $name
  193. */
  194. public function setImagePrettyName($name)
  195. {
  196. $this->set('prettyname', $name);
  197. if ($this->image) {
  198. $this->image->setPrettyName($name);
  199. }
  200. }
  201. public function getImagePrettyName()
  202. {
  203. if ($this->get('prettyname')) {
  204. return $this->get('prettyname');
  205. }
  206. $basename = $this->get('basename');
  207. if (preg_match('/[a-z0-9]{40}-(.*)/', $basename, $matches)) {
  208. $basename = $matches[1];
  209. }
  210. return $basename;
  211. }
  212. /**
  213. * Generate alternative image widths, using either an array of integers, or
  214. * a min width, a max width, and a step parameter to fill out the necessary
  215. * widths. Existing image alternatives won't be overwritten.
  216. *
  217. * @param int|int[] $min_width
  218. * @param int $max_width
  219. * @param int $step
  220. * @return $this
  221. */
  222. public function derivatives($min_width, $max_width = 2500, $step = 200)
  223. {
  224. if (!empty($this->alternatives)) {
  225. $max = max(array_keys($this->alternatives));
  226. $base = $this->alternatives[$max];
  227. } else {
  228. $base = $this;
  229. }
  230. $widths = [];
  231. if (func_num_args() === 1) {
  232. foreach ((array) func_get_arg(0) as $width) {
  233. if ($width < $base->get('width')) {
  234. $widths[] = $width;
  235. }
  236. }
  237. } else {
  238. $max_width = min($max_width, $base->get('width'));
  239. for ($width = $min_width; $width < $max_width; $width = $width + $step) {
  240. $widths[] = $width;
  241. }
  242. }
  243. foreach ($widths as $width) {
  244. // Only generate image alternatives that don't already exist
  245. if (array_key_exists((int) $width, $this->alternatives)) {
  246. continue;
  247. }
  248. $derivative = MediumFactory::fromFile($base->get('filepath'));
  249. // It's possible that MediumFactory::fromFile returns null if the
  250. // original image file no longer exists and this class instance was
  251. // retrieved from the page cache
  252. if (null !== $derivative) {
  253. $index = 2;
  254. $alt_widths = array_keys($this->alternatives);
  255. sort($alt_widths);
  256. foreach ($alt_widths as $i => $key) {
  257. if ($width > $key) {
  258. $index += max($i, 1);
  259. }
  260. }
  261. $basename = preg_replace('/(@\d+x){0,1}$/', "@{$width}w", $base->get('basename'), 1);
  262. $derivative->setImagePrettyName($basename);
  263. $ratio = $base->get('width') / $width;
  264. $height = $derivative->get('height') / $ratio;
  265. $derivative->resize($width, $height);
  266. $derivative->set('width', $width);
  267. $derivative->set('height', $height);
  268. $this->addAlternative($ratio, $derivative);
  269. }
  270. }
  271. return $this;
  272. }
  273. /**
  274. * Parsedown element for source display mode
  275. *
  276. * @param array $attributes
  277. * @param bool $reset
  278. * @return array
  279. */
  280. public function sourceParsedownElement(array $attributes, $reset = true)
  281. {
  282. empty($attributes['src']) && $attributes['src'] = $this->url(false);
  283. $srcset = $this->srcset($reset);
  284. if ($srcset) {
  285. empty($attributes['srcset']) && $attributes['srcset'] = $srcset;
  286. $attributes['sizes'] = $this->sizes();
  287. }
  288. return ['name' => 'img', 'attributes' => $attributes];
  289. }
  290. /**
  291. * Reset image.
  292. *
  293. * @return $this
  294. */
  295. public function reset()
  296. {
  297. parent::reset();
  298. if ($this->image) {
  299. $this->image();
  300. $this->medium_querystring = [];
  301. $this->filter();
  302. $this->clearAlternatives();
  303. }
  304. $this->format = 'guess';
  305. $this->quality = $this->default_quality;
  306. $this->debug_watermarked = false;
  307. return $this;
  308. }
  309. /**
  310. * Turn the current Medium into a Link
  311. *
  312. * @param bool $reset
  313. * @param array $attributes
  314. * @return Link
  315. */
  316. public function link($reset = true, array $attributes = [])
  317. {
  318. $attributes['href'] = $this->url(false);
  319. $srcset = $this->srcset(false);
  320. if ($srcset) {
  321. $attributes['data-srcset'] = $srcset;
  322. }
  323. return parent::link($reset, $attributes);
  324. }
  325. /**
  326. * Turn the current Medium into a Link with lightbox enabled
  327. *
  328. * @param int $width
  329. * @param int $height
  330. * @param bool $reset
  331. * @return Link
  332. */
  333. public function lightbox($width = null, $height = null, $reset = true)
  334. {
  335. if ($this->mode !== 'source') {
  336. $this->display('source');
  337. }
  338. if ($width && $height) {
  339. $this->__call('cropResize', [$width, $height]);
  340. }
  341. return parent::lightbox($width, $height, $reset);
  342. }
  343. /**
  344. * Sets or gets the quality of the image
  345. *
  346. * @param int $quality 0-100 quality
  347. * @return int|$this
  348. */
  349. public function quality($quality = null)
  350. {
  351. if ($quality) {
  352. if (!$this->image) {
  353. $this->image();
  354. }
  355. $this->quality = $quality;
  356. return $this;
  357. }
  358. return $this->quality;
  359. }
  360. /**
  361. * Sets image output format.
  362. *
  363. * @param string $format
  364. * @return $this
  365. */
  366. public function format($format)
  367. {
  368. if (!$this->image) {
  369. $this->image();
  370. }
  371. $this->format = $format;
  372. return $this;
  373. }
  374. /**
  375. * Set or get sizes parameter for srcset media action
  376. *
  377. * @param string $sizes
  378. * @return string
  379. */
  380. public function sizes($sizes = null)
  381. {
  382. if ($sizes) {
  383. $this->sizes = $sizes;
  384. return $this;
  385. }
  386. return empty($this->sizes) ? '100vw' : $this->sizes;
  387. }
  388. /**
  389. * Allows to set the width attribute from Markdown or Twig
  390. * Examples: ![Example](myimg.png?width=200&height=400)
  391. * ![Example](myimg.png?resize=100,200&width=100&height=200)
  392. * ![Example](myimg.png?width=auto&height=auto)
  393. * ![Example](myimg.png?width&height)
  394. * {{ page.media['myimg.png'].width().height().html }}
  395. * {{ page.media['myimg.png'].resize(100,200).width(100).height(200).html }}
  396. *
  397. * @param mixed $value A value or 'auto' or empty to use the width of the image
  398. * @return $this
  399. */
  400. public function width($value = 'auto')
  401. {
  402. if (!$value || $value === 'auto') {
  403. $this->attributes['width'] = $this->get('width');
  404. } else {
  405. $this->attributes['width'] = $value;
  406. }
  407. return $this;
  408. }
  409. /**
  410. * Allows to set the height attribute from Markdown or Twig
  411. * Examples: ![Example](myimg.png?width=200&height=400)
  412. * ![Example](myimg.png?resize=100,200&width=100&height=200)
  413. * ![Example](myimg.png?width=auto&height=auto)
  414. * ![Example](myimg.png?width&height)
  415. * {{ page.media['myimg.png'].width().height().html }}
  416. * {{ page.media['myimg.png'].resize(100,200).width(100).height(200).html }}
  417. *
  418. * @param mixed $value A value or 'auto' or empty to use the height of the image
  419. * @return $this
  420. */
  421. public function height($value = 'auto')
  422. {
  423. if (!$value || $value === 'auto') {
  424. $this->attributes['height'] = $this->get('height');
  425. } else {
  426. $this->attributes['height'] = $value;
  427. }
  428. return $this;
  429. }
  430. /**
  431. * Forward the call to the image processing method.
  432. *
  433. * @param string $method
  434. * @param mixed $args
  435. * @return $this|mixed
  436. */
  437. public function __call($method, $args)
  438. {
  439. if ($method === 'cropZoom') {
  440. $method = 'zoomCrop';
  441. }
  442. if (!\in_array($method, self::$magic_actions, true)) {
  443. return parent::__call($method, $args);
  444. }
  445. // Always initialize image.
  446. if (!$this->image) {
  447. $this->image();
  448. }
  449. try {
  450. call_user_func_array([$this->image, $method], $args);
  451. foreach ($this->alternatives as $medium) {
  452. if (!$medium->image) {
  453. $medium->image();
  454. }
  455. $args_copy = $args;
  456. // regular image: resize 400x400 -> 200x200
  457. // --> @2x: resize 800x800->400x400
  458. if (isset(self::$magic_resize_actions[$method])) {
  459. foreach (self::$magic_resize_actions[$method] as $param) {
  460. if (isset($args_copy[$param])) {
  461. $args_copy[$param] *= $medium->get('ratio');
  462. }
  463. }
  464. }
  465. call_user_func_array([$medium, $method], $args_copy);
  466. }
  467. } catch (\BadFunctionCallException $e) {
  468. }
  469. return $this;
  470. }
  471. /**
  472. * Gets medium image, resets image manipulation operations.
  473. *
  474. * @return $this
  475. */
  476. protected function image()
  477. {
  478. $locator = Grav::instance()['locator'];
  479. $file = $this->get('filepath');
  480. // Use existing cache folder or if it doesn't exist, create it.
  481. $cacheDir = $locator->findResource('cache://images', true) ?: $locator->findResource('cache://images', true, true);
  482. // Make sure we free previous image.
  483. unset($this->image);
  484. $this->image = ImageFile::open($file)
  485. ->setCacheDir($cacheDir)
  486. ->setActualCacheDir($cacheDir)
  487. ->setPrettyName($this->getImagePrettyName());
  488. return $this;
  489. }
  490. /**
  491. * Save the image with cache.
  492. *
  493. * @return string
  494. */
  495. protected function saveImage()
  496. {
  497. if (!$this->image) {
  498. return parent::path(false);
  499. }
  500. $this->filter();
  501. if (isset($this->result)) {
  502. return $this->result;
  503. }
  504. if (!$this->debug_watermarked && $this->get('debug')) {
  505. $ratio = $this->get('ratio');
  506. if (!$ratio) {
  507. $ratio = 1;
  508. }
  509. $locator = Grav::instance()['locator'];
  510. $overlay = $locator->findResource("system://assets/responsive-overlays/{$ratio}x.png") ?: $locator->findResource('system://assets/responsive-overlays/unknown.png');
  511. $this->image->merge(ImageFile::open($overlay));
  512. }
  513. return $this->image->cacheFile($this->format, $this->quality, false, [$this->get('width'), $this->get('height'), $this->get('modified')]);
  514. }
  515. /**
  516. * Filter image by using user defined filter parameters.
  517. *
  518. * @param string $filter Filter to be used.
  519. */
  520. public function filter($filter = 'image.filters.default')
  521. {
  522. $filters = (array) $this->get($filter, []);
  523. foreach ($filters as $params) {
  524. $params = (array) $params;
  525. $method = array_shift($params);
  526. $this->__call($method, $params);
  527. }
  528. }
  529. /**
  530. * Return the image higher quality version
  531. *
  532. * @return ImageMedium the alternative version with higher quality
  533. */
  534. public function higherQualityAlternative()
  535. {
  536. if ($this->alternatives) {
  537. $max = reset($this->alternatives);
  538. foreach($this->alternatives as $alternative)
  539. {
  540. if($alternative->quality() > $max->quality())
  541. {
  542. $max = $alternative;
  543. }
  544. }
  545. return $max;
  546. }
  547. return $this;
  548. }
  549. }