Skip to content

Commit

Permalink
refactor: asset image functions (#2001)
Browse files Browse the repository at this point in the history
  • Loading branch information
ArnaudLigny authored Jul 3, 2024
1 parent 2aef209 commit b66a5b9
Show file tree
Hide file tree
Showing 6 changed files with 176 additions and 98 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
runs-on: ${{ matrix.os }}
continue-on-error: ${{ matrix.experimental }}
env:
extensions: :psr, mbstring, intl, gettext, fileinfo, gd, sodium
extensions: :psr, mbstring, intl, gettext, fileinfo, gd, sodium, exif
ext-cache-key: cache-ext-v1

strategy:
Expand Down
2 changes: 1 addition & 1 deletion docs/3-Templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -505,7 +505,7 @@ _Examples:_
# image width in pixels
{{ asset('image.png').width }}px
# photo's date in seconds
{{ asset('photo.jpeg').exif.DateTimeOriginal|date('U') }}
{{ asset('photo.jpeg').exif.EXIF.DateTimeOriginal|date('U') }}
# MP3 song duration in minutes
{{ asset('title.mp3').audio.duration|round }} min
# file integrity hash
Expand Down
87 changes: 19 additions & 68 deletions src/Assets/Asset.php
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ public function __construct(Builder $builder, string|array $paths, array|null $o
if ($this->data['type'] == 'image') {
$this->data['width'] = $this->getWidth();
$this->data['height'] = $this->getHeight();
if ($this->data['subtype'] == 'jpeg') {
if ($this->data['subtype'] == 'image/jpeg') {
$this->data['exif'] = Util\File::readExif($file[$i]['filepath']);
}
}
Expand Down Expand Up @@ -436,47 +436,22 @@ public function resize(int $width): self
$assetResized->data['width'] = $width;

if ($this->isImageInCdn()) {
return $assetResized; // returns the asset with the new width only: CDN do the rest of the job
return $assetResized; // returns asset with the new width only: CDN do the rest of the job
}

$quality = $this->config->get('assets.images.quality');
$quality = $this->config->get('assets.images.quality') ?? 75;
$cache = new Cache($this->builder, (string) $this->builder->getConfig()->get('cache.assets.dir'));
$cacheKey = $cache->createKeyFromAsset($assetResized, ["{$width}x", "q$quality"]);
if (!$cache->has($cacheKey)) {
if ($assetResized->data['type'] !== 'image') {
throw new RuntimeException(sprintf('Not able to resize "%s".', $assetResized->data['path']));
}
if (!\extension_loaded('gd')) {
throw new RuntimeException('GD extension is required to use images resize.');
}

try {
$img = ImageManager::make($assetResized->data['content_source'])->encode($assetResized->data['ext']);
$img->resize($width, null, function (\Intervention\Image\Constraint $constraint) {
$constraint->aspectRatio();
$constraint->upsize();
});
} catch (\Exception $e) {
throw new RuntimeException(sprintf('Not able to resize image "%s": %s', $assetResized->data['path'], $e->getMessage()));
}
$assetResized->data['content'] = Image::resize($assetResized, $width, $quality);
$assetResized->data['path'] = '/' . Util::joinPath(
(string) $this->config->get('assets.target'),
(string) $this->config->get('assets.images.resize.dir'),
(string) $width,
$assetResized->data['path']
);

try {
if ($assetResized->data['subtype'] == 'image/jpeg') {
$img->interlace();
}
$assetResized->data['content'] = (string) $img->encode($assetResized->data['ext'], $quality);
$img->destroy();
$assetResized->data['height'] = $assetResized->getHeight();
$assetResized->data['size'] = \strlen($assetResized->data['content']);
} catch (\Exception $e) {
throw new RuntimeException(sprintf('Not able to encode image "%s": %s', $assetResized->data['path'], $e->getMessage()));
}
$assetResized->data['height'] = $assetResized->getHeight();
$assetResized->data['size'] = \strlen($assetResized->data['content']);

$cache->set($cacheKey, $assetResized->data);
}
Expand All @@ -492,8 +467,8 @@ public function resize(int $width): self
*/
public function webp(?int $quality = null): self
{
if ($this->data['type'] !== 'image') {
throw new RuntimeException(sprintf('can\'t convert "%s" (%s) to WebP: it\'s not an image file.', $this->data['path'], $this->data['type']));
if ($this->data['type'] != 'image') {
throw new RuntimeException(sprintf('Not able to convert "%s" (%s) to WebP: not an image.', $this->data['path'], $this->data['type']));
}

if ($quality === null) {
Expand All @@ -505,7 +480,7 @@ public function webp(?int $quality = null): self
$assetWebp['ext'] = $format;

if ($this->isImageInCdn()) {
return $assetWebp; // returns the asset with the new extension ('webp') only: CDN do the rest of the job
return $assetWebp; // returns the asset with the new extension only: CDN do the rest of the job
}

$cache = new Cache($this->builder, (string) $this->builder->getConfig()->get('cache.assets.dir'));
Expand All @@ -515,12 +490,10 @@ public function webp(?int $quality = null): self
}
$cacheKey = $cache->createKeyFromAsset($assetWebp, $tags);
if (!$cache->has($cacheKey)) {
$img = ImageManager::make($assetWebp['content']);
$assetWebp['content'] = (string) $img->encode($format, $quality);
$img->destroy();
$assetWebp['path'] = preg_replace('/\.' . $this->data['ext'] . '$/m', ".$format", $this->data['path']);
$assetWebp['subtype'] = "image/$format";
$assetWebp['size'] = \strlen($assetWebp['content']);
$assetWebp->data['content'] = Image::convert($assetWebp, $format, $quality);
$assetWebp->data['path'] = preg_replace('/\.' . $this->data['ext'] . '$/m', ".$format", $this->data['path']);
$assetWebp->data['subtype'] = "image/$format";
$assetWebp->data['size'] = \strlen($assetWebp->data['content']);

$cache->set($cacheKey, $assetWebp->data);
}
Expand Down Expand Up @@ -607,14 +580,14 @@ public function getVideo(): array
}

/**
* Returns the data URL (encoded in Base64).
* Returns the Data URL (encoded in Base64).
*
* @throws RuntimeException
*/
public function dataurl(): string
{
if ($this->data['type'] == 'image' && !$this->isSVG()) {
return (string) ImageManager::make($this->data['content'])->encode('data-url', $this->config->get('assets.images.quality'));
if ($this->data['type'] == 'image' && !Image::isSVG($this)) {
return Image::getDataUrl($this, $this->config->get('assets.images.quality') ?? 75);
}

return sprintf('data:%s;base64,%s', $this->data['subtype'], base64_encode($this->data['content']));
Expand Down Expand Up @@ -651,7 +624,7 @@ public function save(): void
*/
public function isImageInCdn()
{
if ($this->data['type'] != 'image' || (bool) $this->config->get('assets.images.cdn.enabled') !== true || ($this->isSVG() && (bool) $this->config->get('assets.images.cdn.svg') !== true)) {
if ($this->data['type'] != 'image' || (bool) $this->config->get('assets.images.cdn.enabled') !== true || (Image::isSVG($this) && (bool) $this->config->get('assets.images.cdn.svg') !== true)) {
return false;
}
// remote image?
Expand Down Expand Up @@ -828,7 +801,7 @@ private function getWidth(): int
if ($this->data['type'] != 'image') {
return 0;
}
if ($this->isSVG() && false !== $svg = $this->getSvgAttributes()) {
if (Image::isSVG($this) && false !== $svg = Image::getSvgAttributes($this)) {
return (int) $svg->width;
}
if (false === $size = $this->getImageSize()) {
Expand All @@ -848,7 +821,7 @@ private function getHeight(): int
if ($this->data['type'] != 'image') {
return 0;
}
if ($this->isSVG() && false !== $svg = $this->getSvgAttributes()) {
if (Image::isSVG($this) && false !== $svg = Image::getSvgAttributes($this)) {
return (int) $svg->height;
}
if (false === $size = $this->getImageSize()) {
Expand Down Expand Up @@ -882,28 +855,6 @@ private function getImageSize()
return $size;
}

/**
* Returns true if asset is a SVG.
*/
private function isSVG(): bool
{
return \in_array($this->data['subtype'], ['image/svg', 'image/svg+xml']) || $this->data['ext'] == 'svg';
}

/**
* Returns SVG attributes.
*
* @return \SimpleXMLElement|false
*/
private function getSvgAttributes()
{
if (false === $xml = simplexml_load_string($this->data['content_source'])) {
return false;
}

return $xml->attributes();
}

/**
* Replaces some characters by '_'.
*/
Expand Down
160 changes: 137 additions & 23 deletions src/Assets/Image.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,131 @@

class Image
{
/**
* Resize an image Asset.
*
* @throws RuntimeException
*/
public static function resize(Asset $asset, int $width, int $quality): string
{
try {
// is image Asset?
if ($asset['type'] !== 'image') {
throw new RuntimeException(sprintf('Not an image.'));
}
// is GD is installed
if (!\extension_loaded('gd')) {
throw new RuntimeException('GD extension is required.');
}
// creates image object from source
$image = ImageManager::make($asset['content_source']);
// resizes to $width with constraint the aspect-ratio and unwanted upsizing
$image->resize($width, null, function (\Intervention\Image\Constraint $constraint) {
$constraint->aspectRatio();
$constraint->upsize();
});
// interlaces (PNG) or progressives (JPEG) image
$image->interlace();
// save image in extension format and given quality
$imageAsString = (string) $image->encode($asset['ext'], $quality);
// destroy image object
$image->destroy();

return $imageAsString;
} catch (\Exception $e) {
throw new RuntimeException(sprintf('Not able to resize "%s": %s', $asset['path'], $e->getMessage()));
}
}

/**
* Converts an image Asset to the target format.
*
* @throws RuntimeException
*/
public static function convert(Asset $asset, string $format, int $quality): string
{
try {
if ($asset['type'] !== 'image') {
throw new RuntimeException(sprintf('Not an image.'));
}
$image = ImageManager::make($asset['content']);
$imageAsString = (string) $image->encode($format, $quality);
$image->destroy();

return $imageAsString;
} catch (\Exception $e) {
throw new RuntimeException(sprintf('Not able to convert "%s": %s', $asset['path'], $e->getMessage()));
}
}

/**
* Returns the Data URL (encoded in Base64).
*
* @throws RuntimeException
*/
public static function getDataUrl(Asset $asset, int $quality): string
{
try {
if ($asset['type'] != 'image' || self::isSVG($asset)) {
throw new RuntimeException(sprintf('Not an image.'));
}
$image = ImageManager::make($asset['content']);
$imageAsDataUrl = (string) $image->encode('data-url', $quality);
$image->destroy();

return $imageAsDataUrl;
} catch (\Exception $e) {
throw new RuntimeException(sprintf('Can\'t get Data URL of "%s": %s', $asset['path'], $e->getMessage()));
}
}

/**
* Returns the dominant hexadecimal color of an image asset.
*
* @throws RuntimeException
*/
public static function getDominantColor(Asset $asset): string
{
try {
if ($asset['type'] != 'image' || self::isSVG($asset)) {
throw new RuntimeException(sprintf('Not an image.'));
}

$assetColor = clone $asset;
$assetColor = $assetColor->resize(100);
$image = ImageManager::make($assetColor['content']);
$color = $image->limitColors(1)->pickColor(0, 0, 'hex');
$image->destroy();

return $color;
} catch (\Exception $e) {
throw new RuntimeException(sprintf('Can\'t get dominant color of "%s": %s', $asset['path'], $e->getMessage()));
}
}

/**
* Returns a Low Quality Image Placeholder (LQIP) as data URL.
*
* @throws RuntimeException
*/
public static function getLqip(Asset $asset): string
{
try {
if ($asset['type'] !== 'image') {
throw new RuntimeException(sprintf('Not an image.'));
}
$assetLqip = clone $asset;
$assetLqip = $assetLqip->resize(100);
$image = ImageManager::make($assetLqip['content']);
$imageAsString = (string) $image->blur(50)->encode('data-url');
$image->destroy();

return $imageAsString;
} catch (\Exception $e) {
throw new RuntimeException(sprintf('can\'t create LQIP of "%s": %s', $asset['path'], $e->getMessage()));
}
}

/**
* Build the `srcset` attribute for responsive images.
* e.g.: `srcset="/img-480.jpg 480w, /img-800.jpg 800w"`.
Expand Down Expand Up @@ -82,39 +207,28 @@ public static function isAnimatedGif(Asset $asset): bool
}

/**
* Returns the dominant hexadecimal color of an image asset.
*
* @throws RuntimeException
* Returns true if asset is a SVG.
*/
public static function getDominantColor(Asset $asset): string
public static function isSVG(Asset $asset): bool
{
if ($asset['type'] !== 'image') {
throw new RuntimeException(sprintf('can\'t get dominant color of "%s": it\'s not an image file.', $asset['path']));
}

$assetColor = clone $asset;
$assetColor = $assetColor->resize(100);
$img = ImageManager::make($assetColor['content']);
$color = $img->limitColors(1)->pickColor(0, 0, 'hex');
$img->destroy();

return $color;
return \in_array($asset['subtype'], ['image/svg', 'image/svg+xml']) || $asset['ext'] == 'svg';
}

/**
* Returns a Low Quality Image Placeholder (LQIP) as data URL.
* Returns SVG attributes.
*
* @throws RuntimeException
* @return \SimpleXMLElement|false
*/
public static function getLqip(Asset $asset): string
public static function getSvgAttributes(Asset $asset)
{
if ($asset['type'] !== 'image') {
throw new RuntimeException(sprintf('can\'t create LQIP of "%s": it\'s not an image file.', $asset['path']));
if (!self::isSVG($asset)) {
return false;
}

$assetLqip = clone $asset;
$assetLqip = $assetLqip->resize(100);
if (false === $xml = simplexml_load_string($asset['content_source'] ?? '')) {
return false;
}

return (string) ImageManager::make($assetLqip['content'])->blur(50)->encode('data-url');
return $xml->attributes();
}
}
Binary file modified tests/fixtures/website/assets/images/japon_sample.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading

0 comments on commit b66a5b9

Please sign in to comment.