ImageMedium.php 17 KB

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