From 83fdecbdd165aae2786f2e57d3876cc345218a7f Mon Sep 17 00:00:00 2001 From: Matias Griese Date: Wed, 7 Dec 2016 19:51:06 +0200 Subject: [PATCH] Added stream support for images (`![Sepia Image](image://image.jpg?sepia)`) Added stream support for links (`[Download PDF](user://data/pdf/my.pdf)`) --- CHANGELOG.md | 4 +- system/src/Grav/Common/Helpers/Excerpts.php | 159 +++++++++----- system/src/Grav/Common/Page/Media.php | 195 +++++------------- .../Grav/Common/Page/Medium/AbstractMedia.php | 154 ++++++++++++++ .../Grav/Common/Page/Medium/GlobalMedia.php | 117 +++++++++++ system/src/Grav/Common/Page/Medium/Medium.php | 4 +- 6 files changed, 425 insertions(+), 208 deletions(-) create mode 100644 system/src/Grav/Common/Page/Medium/AbstractMedia.php create mode 100644 system/src/Grav/Common/Page/Medium/GlobalMedia.php diff --git a/CHANGELOG.md b/CHANGELOG.md index df6aaeaf2..5584e98f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,9 @@ * Add `ignore_empty` property to be used on array fields, if positive only save options with a value * Use new `permissions` field in user account * Add `range(int start, int end, int step)` twig function to generate an array of numbers between start and end, inclusive - * New retina Media image derivatives array support (`![](image.jpg?derivatives=[640,1024,1440]`) [#1147](https://github.com/getgrav/grav/pull/1147) + * New retina Media image derivatives array support (`![](image.jpg?derivatives=[640,1024,1440])`) [#1147](https://github.com/getgrav/grav/pull/1147) + * Added stream support for images (`![Sepia Image](image://image.jpg?sepia)`) + * Added stream support for links (`[Download PDF](user://data/pdf/my.pdf)`) 1. [](#improved) * Added alias `selfupdate` to the `self-upgrade` `bin/gpm` CLI command * Synced `webserver-configs/htaccess.txt` with `.htaccess` diff --git a/system/src/Grav/Common/Helpers/Excerpts.php b/system/src/Grav/Common/Helpers/Excerpts.php index 3ab86ce03..05afd3df4 100644 --- a/system/src/Grav/Common/Helpers/Excerpts.php +++ b/system/src/Grav/Common/Helpers/Excerpts.php @@ -10,9 +10,9 @@ namespace Grav\Common\Helpers; use Grav\Common\Grav; use Grav\Common\Uri; -use Grav\Common\Utils; use Grav\Common\Page\Medium\Medium; use RocketTheme\Toolbox\Event\Event; +use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator; class Excerpts { @@ -115,11 +115,11 @@ class Excerpts */ public static function processLinkExcerpt($excerpt, $page, $type = 'link') { - $url = $excerpt['element']['attributes']['href']; + $url = htmlspecialchars_decode(urldecode($excerpt['element']['attributes']['href'])); - $url_parts = parse_url(htmlspecialchars_decode(urldecode($url))); + $url_parts = static::parseUrl($url); - // if there is a query, then parse it and build action calls + // If there is a query, then parse it and build action calls. if (isset($url_parts['query'])) { $actions = array_reduce(explode('&', $url_parts['query']), function ($carry, $item) { $parts = explode('=', $item, 2); @@ -129,19 +129,19 @@ class Excerpts return $carry; }, []); - // valid attributes supported + // Valid attributes supported. $valid_attributes = ['rel', 'target', 'id', 'class', 'classes']; - // Unless told to not process, go through actions + // Unless told to not process, go through actions. if (array_key_exists('noprocess', $actions)) { unset($actions['noprocess']); } else { - // loop through actions for the image and call them + // Loop through actions for the image and call them. foreach ($actions as $attrib => $value) { $key = $attrib; if (in_array($attrib, $valid_attributes)) { - // support both class and classes + // support both class and classes. if ($attrib == 'classes') { $attrib = 'class'; } @@ -154,25 +154,33 @@ class Excerpts $url_parts['query'] = http_build_query($actions, null, '&', PHP_QUERY_RFC3986); } - // if no query elements left, unset query + // If no query elements left, unset query. if (empty($url_parts['query'])) { unset ($url_parts['query']); } - // set path to / if not set + // Set path to / if not set. if (empty($url_parts['path'])) { $url_parts['path'] = ''; } - // if special scheme, just return - if(isset($url_parts['scheme']) && !Utils::startsWith($url_parts['scheme'], 'http')) { + // If scheme isn't http(s).. + if (!empty($url_parts['scheme']) && !in_array($url_parts['scheme'], ['http', 'https'])) { + // Handle custom streams. + if ($type !== 'image' && !empty($url_parts['stream']) && !empty($url_parts['path'])) { + $url_parts['path'] = Grav::instance()['base_url_relative'] . '/' . static::resolveStream("{$url_parts['scheme']}://{$url_parts['path']}"); + unset($url_parts['stream'], $url_parts['scheme']); + + $excerpt['element']['attributes']['href'] = Uri::buildUrl($url_parts); + } + return $excerpt; } - // handle paths and such + // Handle paths and such. $url_parts = Uri::convertUrl($page, $url_parts, $type); - // build the URL from the component parts and set it on the element + // Build the URL from the component parts and set it on the element. $excerpt['element']['attributes']['href'] = Uri::buildUrl($url_parts); return $excerpt; @@ -187,62 +195,65 @@ class Excerpts */ public static function processImageExcerpt($excerpt, $page) { - $url = $excerpt['element']['attributes']['src']; + $url = htmlspecialchars_decode(urldecode($excerpt['element']['attributes']['src'])); + $url_parts = static::parseUrl($url); - $url_parts = parse_url(htmlspecialchars_decode(urldecode($url))); + $media = null; + $filename = null; - if (isset($url_parts['scheme']) && !Utils::startsWith($url_parts['scheme'], 'http')) { - $stream_path = $url_parts['scheme'] . '://' . $url_parts['host'] . $url_parts['path']; - $url_parts['path'] = $stream_path; - unset($url_parts['host']); - unset($url_parts['scheme']); - } + if (!empty($url_parts['stream'])) { + $filename = $url_parts['scheme'] . '://' . (isset($url_parts['path']) ? $url_parts['path'] : ''); - $this_host = isset($url_parts['host']) && $url_parts['host'] == Grav::instance()['uri']->host(); + $media = $page->media(); - // if there is no host set but there is a path, the file is local - if ((!isset($url_parts['host']) || $this_host) && isset($url_parts['path'])) { + } else { + // File is also local if scheme is http(s) and host matches. + $local_file = isset($url_parts['path']) + && (empty($url_parts['scheme']) || in_array($url_parts['scheme'], ['http', 'https'])) + && (empty($url_parts['host']) || $url_parts['host'] == Grav::instance()['uri']->host()); - $path_parts = pathinfo($url_parts['path']); - $media = null; + if ($local_file) { + $filename = basename($url_parts['path']); + $folder = dirname($url_parts['path']); - // get the local path to page media if possible - if ($path_parts['dirname'] == $page->url(false, false, false)) { - // get the media objects for this page - $media = $page->media(); - } else { - // see if this is an external page to this one - $base_url = rtrim(Grav::instance()['base_url_relative'] . Grav::instance()['pages']->base(), '/'); - $page_route = '/' . ltrim(str_replace($base_url, '', $path_parts['dirname']), '/'); - - $ext_page = Grav::instance()['pages']->dispatch($page_route, true); - if ($ext_page) { - $media = $ext_page->media(); + // Get the local path to page media if possible. + if ($folder === $page->url(false, false, false)) { + // Get the media objects for this page. + $media = $page->media(); } else { - Grav::instance()->fireEvent('onMediaLocate', new Event(['route' => $page_route, 'media' => &$media])); + // see if this is an external page to this one + $base_url = rtrim(Grav::instance()['base_url_relative'] . Grav::instance()['pages']->base(), '/'); + $page_route = '/' . ltrim(str_replace($base_url, '', $folder), '/'); + + $ext_page = Grav::instance()['pages']->dispatch($page_route, true); + if ($ext_page) { + $media = $ext_page->media(); + } else { + Grav::instance()->fireEvent('onMediaLocate', new Event(['route' => $page_route, 'media' => &$media])); + } } } + } - // if there is a media file that matches the path referenced.. - if ($media && isset($media->all()[$path_parts['basename']])) { - // get the medium object - /** @var Medium $medium */ - $medium = $media->all()[$path_parts['basename']]; + // If there is a media file that matches the path referenced.. + if ($media && $filename && isset($media[$filename])) { + // Get the medium object. + /** @var Medium $medium */ + $medium = $media[$filename]; - // Process operations - $medium = static::processMediaActions($medium, $url_parts); + // Process operations + $medium = static::processMediaActions($medium, $url_parts); - $alt = isset($excerpt['element']['attributes']['alt']) ? $excerpt['element']['attributes']['alt'] : ''; - $title = isset($excerpt['element']['attributes']['title']) ? $excerpt['element']['attributes']['title'] : ''; - $class = isset($excerpt['element']['attributes']['class']) ? $excerpt['element']['attributes']['class'] : ''; - $id = isset($excerpt['element']['attributes']['id']) ? $excerpt['element']['attributes']['id'] : ''; + $alt = isset($excerpt['element']['attributes']['alt']) ? $excerpt['element']['attributes']['alt'] : ''; + $title = isset($excerpt['element']['attributes']['title']) ? $excerpt['element']['attributes']['title'] : ''; + $class = isset($excerpt['element']['attributes']['class']) ? $excerpt['element']['attributes']['class'] : ''; + $id = isset($excerpt['element']['attributes']['id']) ? $excerpt['element']['attributes']['id'] : ''; - $excerpt['element'] = $medium->parseDownElement($title, $alt, $class, $id, true); + $excerpt['element'] = $medium->parseDownElement($title, $alt, $class, $id, true); - } else { - // not a current page media file, see if it needs converting to relative - $excerpt['element']['attributes']['src'] = Uri::buildUrl($url_parts); - } + } else { + // Not a current page media file, see if it needs converting to relative. + $excerpt['element']['attributes']['src'] = Uri::buildUrl($url_parts); } return $excerpt; @@ -300,4 +311,40 @@ class Excerpts return $medium; } + /** + * Variation of parse_url() which works also with local streams. + * + * @param string $url + * @return array|bool + */ + protected static function parseUrl($url) + { + $url_parts = parse_url($url); + + if (isset($url_parts['scheme'])) { + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + + // Special handling for the streams. + if ($locator->schemeExists($url_parts['scheme'])) { + if (isset($url_parts['host'])) { + // Merge host and path into a path. + $url_parts['path'] = $url_parts['host'] . (isset($url_parts['path']) ? '/' . $url_parts['path'] : ''); + unset($url_parts['host']); + } + + $url_parts['stream'] = true; + } + } + + return $url_parts; + } + + protected static function resolveStream($url) + { + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + + return $locator->isStream($url) ? ($locator->findResource($url, false) ?: $locator->findResource($url, false, true)) : $url; + } } diff --git a/system/src/Grav/Common/Page/Media.php b/system/src/Grav/Common/Page/Media.php index 16dfdf313..7d1522f9e 100644 --- a/system/src/Grav/Common/Page/Media.php +++ b/system/src/Grav/Common/Page/Media.php @@ -8,51 +8,78 @@ namespace Grav\Common\Page; -use Grav\Common\Getters; -use Grav\Common\Page\Medium\Medium; +use Grav\Common\Page\Medium\AbstractMedia; +use Grav\Common\Page\Medium\GlobalMedia; use Grav\Common\Page\Medium\MediumFactory; +use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator; -class Media extends Getters +class Media extends AbstractMedia { - protected $gettersVariable = 'instances'; + protected static $global; + protected $path; - - protected $instances = []; - protected $images = []; - protected $videos = []; - protected $audios = []; - protected $files = []; - + /** * @param $path */ public function __construct($path) { + $this->path = $path; + + if (!isset(static::$global)) { + // Add fallback to global media. + static::$global = new GlobalMedia($path); + } + + $this->init(); + } + + /** + * @param mixed $offset + * + * @return bool + */ + public function offsetExists($offset) + { + return parent::offsetExists($offset) ?: isset(static::$global[$offset]); + } + + /** + * @param mixed $offset + * + * @return mixed + */ + public function offsetGet($offset) + { + return parent::offsetGet($offset) ?: static::$global[$offset]; + } + + /** + * Initialize class. + */ + protected function init() + { + // Handle special cases where page doesn't exist in filesystem. - if (!is_dir($path)) { + if (!is_dir($this->path)) { return; } - $this->path = $path; - - $iterator = new \FilesystemIterator($path, \FilesystemIterator::UNIX_PATHS | \FilesystemIterator::SKIP_DOTS); + $iterator = new \FilesystemIterator($this->path, \FilesystemIterator::UNIX_PATHS | \FilesystemIterator::SKIP_DOTS); $media = []; /** @var \DirectoryIterator $info */ foreach ($iterator as $path => $info) { // Ignore folders and Markdown files. - if (!$info->isFile() || $info->getExtension() == 'md' || $info->getBasename() === '.DS_Store') { + if (!$info->isFile() || $info->getExtension() == 'md' || $info->getBasename()[0] === '.') { continue; } // Find out what type we're dealing with list($basename, $ext, $type, $extra) = $this->getFileParts($info->getFilename()); - $media["{$basename}.{$ext}"] = isset($media["{$basename}.{$ext}"]) ? $media["{$basename}.{$ext}"] : []; - if ($type === 'alternative') { - $media["{$basename}.{$ext}"][$type] = isset($media["{$basename}.{$ext}"][$type]) ? $media["{$basename}.{$ext}"][$type] : []; $media["{$basename}.{$ext}"][$type][$extra] = [ 'file' => $path, 'size' => $info->getSize() ]; } else { $media["{$basename}.{$ext}"][$type] = [ 'file' => $path, 'size' => $info->getSize() ]; @@ -124,134 +151,4 @@ class Media extends Getters $this->add($name, $medium); } } - - /** - * Get medium by filename. - * - * @param string $filename - * @return Medium|null - */ - public function get($filename) - { - return isset($this->instances[$filename]) ? $this->instances[$filename] : null; - } - - /** - * Get a list of all media. - * - * @return array|Medium[] - */ - public function all() - { - ksort($this->instances, SORT_NATURAL | SORT_FLAG_CASE); - return $this->instances; - } - - /** - * Get a list of all image media. - * - * @return array|Medium[] - */ - public function images() - { - ksort($this->images, SORT_NATURAL | SORT_FLAG_CASE); - return $this->images; - } - - /** - * Get a list of all video media. - * - * @return array|Medium[] - */ - public function videos() - { - ksort($this->videos, SORT_NATURAL | SORT_FLAG_CASE); - return $this->videos; - } - - /** - * Get a list of all audio media. - * - * @return array|Medium[] - */ - public function audios() - { - ksort($this->audios, SORT_NATURAL | SORT_FLAG_CASE); - return $this->audios; - } - - /** - * Get a list of all file media. - * - * @return array|Medium[] - */ - public function files() - { - ksort($this->files, SORT_NATURAL | SORT_FLAG_CASE); - return $this->files; - } - - /** - * @internal - */ - protected function add($name, $file) - { - $this->instances[$name] = $file; - switch ($file->type) { - case 'image': - $this->images[$name] = $file; - break; - case 'video': - $this->videos[$name] = $file; - break; - case 'audio': - $this->audios[$name] = $file; - break; - default: - $this->files[$name] = $file; - } - } - - /** - * Get filename, extension and meta part. - * - * @param string $filename - * @return array - */ - protected function getFileParts($filename) - { - $fileParts = explode('.', $filename); - - $name = array_shift($fileParts); - $type = 'base'; - $extra = null; - - if (preg_match('/(.*)@(\d+)x\.(.*)$/', $filename, $matches)) { - $name = $matches[1]; - $extension = $matches[3]; - $extra = (int) $matches[2]; - $type = 'alternative'; - - if ($extra === 1) { - $type = 'base'; - $extra = null; - } - } else { - $extension = null; - while (($part = array_shift($fileParts)) !== null) { - if ($part != 'meta' && $part != 'thumb') { - if (isset($extension)) { - $name .= '.' . $extension; - } - $extension = $part; - } else { - $type = $part; - $extra = '.' . $part . '.' . implode('.', $fileParts); - break; - } - } - } - - return array($name, $extension, $type, $extra); - } } diff --git a/system/src/Grav/Common/Page/Medium/AbstractMedia.php b/system/src/Grav/Common/Page/Medium/AbstractMedia.php new file mode 100644 index 000000000..8d86ea027 --- /dev/null +++ b/system/src/Grav/Common/Page/Medium/AbstractMedia.php @@ -0,0 +1,154 @@ +offsetGet($filename); + } + + /** + * Get a list of all media. + * + * @return array|Medium[] + */ + public function all() + { + ksort($this->instances, SORT_NATURAL | SORT_FLAG_CASE); + return $this->instances; + } + + /** + * Get a list of all image media. + * + * @return array|Medium[] + */ + public function images() + { + ksort($this->images, SORT_NATURAL | SORT_FLAG_CASE); + return $this->images; + } + + /** + * Get a list of all video media. + * + * @return array|Medium[] + */ + public function videos() + { + ksort($this->videos, SORT_NATURAL | SORT_FLAG_CASE); + return $this->videos; + } + + /** + * Get a list of all audio media. + * + * @return array|Medium[] + */ + public function audios() + { + ksort($this->audios, SORT_NATURAL | SORT_FLAG_CASE); + return $this->audios; + } + + /** + * Get a list of all file media. + * + * @return array|Medium[] + */ + public function files() + { + ksort($this->files, SORT_NATURAL | SORT_FLAG_CASE); + return $this->files; + } + + /** + * @param string $name + * @param Medium $file + */ + protected function add($name, $file) + { + $this->instances[$name] = $file; + switch ($file->type) { + case 'image': + $this->images[$name] = $file; + break; + case 'video': + $this->videos[$name] = $file; + break; + case 'audio': + $this->audios[$name] = $file; + break; + default: + $this->files[$name] = $file; + } + } + + /** + * Get filename, extension and meta part. + * + * @param string $filename + * @return array + */ + protected function getFileParts($filename) + { + if (preg_match('/(.*)@(\d+)x\.(.*)$/', $filename, $matches)) { + $name = $matches[1]; + $extension = $matches[3]; + $extra = (int) $matches[2]; + $type = 'alternative'; + + if ($extra === 1) { + $type = 'base'; + $extra = null; + } + } else { + $fileParts = explode('.', $filename); + + $name = array_shift($fileParts); + $extension = null; + $extra = null; + $type = 'base'; + + while (($part = array_shift($fileParts)) !== null) { + if ($part != 'meta' && $part != 'thumb') { + if (isset($extension)) { + $name .= '.' . $extension; + } + $extension = $part; + } else { + $type = $part; + $extra = '.' . $part . '.' . implode('.', $fileParts); + break; + } + } + } + + return array($name, $extension, $type, $extra); + } +} diff --git a/system/src/Grav/Common/Page/Medium/GlobalMedia.php b/system/src/Grav/Common/Page/Medium/GlobalMedia.php new file mode 100644 index 000000000..6b5629eca --- /dev/null +++ b/system/src/Grav/Common/Page/Medium/GlobalMedia.php @@ -0,0 +1,117 @@ +resolveStream($offset)); + } + + /** + * @param mixed $offset + * + * @return mixed + */ + public function offsetGet($offset) + { + return parent::offsetGet($offset) ?: $this->addMedium($offset); + } + + /** + * @param string $filename + * @return string|null + */ + protected function resolveStream($filename) + { + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + + return $locator->isStream($filename) ? ($locator->findResource($filename) ?: null) : null; + } + + /** + * @param string $stream + * @return Medium|null + */ + protected function addMedium($stream) + { + $filename = $this->resolveStream($stream); + if (!$filename) { + return null; + } + + $path = dirname($filename); + list($basename, $ext,, $extra) = $this->getFileParts(basename($filename)); + $medium = MediumFactory::fromFile($filename); + + if (empty($medium)) { + return null; + } + + $medium->set('size', filesize($filename)); + $scale = (int) ($extra ?: 1); + + if ($scale !== 1) { + $altMedium = $medium; + + // Create scaled down regular sized image. + $medium = MediumFactory::scaledFromMedium($altMedium, $scale, 1)['file']; + + if (empty($medium)) { + return null; + } + + // Add original sized image as alternative. + $medium->addAlternative($scale, $altMedium['file']); + + // Locate or generate smaller retina images. + for ($i = $scale-1; $i > 1; $i--) { + $altFilename = "{$path}/{$basename}@{$i}x.{$ext}"; + + if (file_exists($altFilename)) { + $scaled = MediumFactory::fromFile($altFilename); + } else { + $scaled = MediumFactory::scaledFromMedium($altMedium, $scale, $i)['file']; + } + + if ($scaled) { + $medium->addAlternative($i, $scaled); + } + } + } + + $meta = "{$path}/{$basename}.{$ext}.yaml"; + if (file_exists($meta)) { + $medium->addMetaFile($meta); + } + $meta = "{$path}/{$basename}.{$ext}.meta.yaml"; + if (file_exists($meta)) { + $medium->addMetaFile($meta); + } + + $thumb = "{$path}/{$basename}.thumb.{$ext}"; + if (file_exists($thumb)) { + $medium->set('thumbnails.page', $thumb); + } + + $this->add($stream, $medium); + + return $medium; + } +} diff --git a/system/src/Grav/Common/Page/Medium/Medium.php b/system/src/Grav/Common/Page/Medium/Medium.php index b9e338854..342efb3ae 100644 --- a/system/src/Grav/Common/Page/Medium/Medium.php +++ b/system/src/Grav/Common/Page/Medium/Medium.php @@ -150,8 +150,8 @@ class Medium extends Data implements RenderableInterface /** * Get/set querystring for the file's url * - * @param string $hash - * @param boolean $withHash + * @param string $querystring + * @param boolean $withQuestionmark * @return string */ public function querystring($querystring = null, $withQuestionmark = true)