<?php /** * @package Regular Labs Library * @version 25.7.12430 * * @author Peter van Westen <[email protected]> * @link https://regularlabs.com * @copyright Copyright © 2025 Regular Labs All Rights Reserved * @license GNU General Public License version 2 or later */ namespace RegularLabs\Library; defined('_JEXEC') or die; require_once dirname(__FILE__, 2) . '/vendor/autoload.php'; use Exception; use RegularLabs\Scoped\Intervention\Image\Exception\NotReadableException as NotReadableException; use RegularLabs\Scoped\Intervention\Image\ImageManagerStatic as ImageManager; use Joomla\CMS\Uri\Uri as JUri; use Joomla\Filesystem\File as JFile; class Image { static $data_files = []; private $attributes; private $description; private $file; private $input; private $is_resized; private $output; private $settings; /** * @param object $attributes @deprecated use SET methods instead */ public function __construct(?string $file = null, ?object $attributes = null) { $this->settings = (object) ['resize' => (object) ['enabled' => \true, 'quality' => 70, 'method' => 'crop', 'folder' => 'resized', 'max_age' => 0, 'force_overwrite' => \false, 'use_retina' => \true, 'retina_pixel_density' => 1.5], 'title' => (object) ['enabled' => \true, 'format' => 'uppercase_first', 'lowercase_words' => 'a,the,to,at,in,with,and,but,or'], 'lazy_loading' => \false]; if ($file) { $this->setFile($file); } if ($attributes) { $this->setFromOldAttributes($attributes); } $this->resetOutput(); } public static function cleanPath(string $path): string { $path = str_replace('\\', '/', $path); $path_site = str_replace('\\', '/', JPATH_SITE) . '/'; if (str_starts_with($path, $path_site)) { $path = substr($path, strlen($path_site)); } $path = ltrim(str_replace(JUri::root(), '', $path), '/'); $path = strtok($path, '?'); $path = strtok($path, '#'); return $path; } public function createResizeFolder(): self { $path = $this->getResizeFolderPath(); if (is_dir($path)) { return $this; } if (!@mkdir($path, 0755, \true)) { $this->settings->resize->folder = ''; } return $this; } public function exists(?string $file = null): bool { $file = urldecode($file ?: $this->getFilePath()); return $file && file_exists($file) && is_file($file); } public function forceOverwriteResized(): self { return $this->setResizeAttribute('force_overwrite', 1.0E-5); } public function getAlt(): string { if (isset($this->attributes->alt)) { return $this->attributes->alt; } $alt = $this->getDataFileDataByType('alt'); if (!is_null($alt)) { return htmlentities($alt); } return $this->getTitle(\true); } public function getDataFileData(): mixed { $image_data = $this->getFolderFileData(); return $image_data[$this->getFileStem()] ?? null; } public function getDataFileDataByType(string $type = 'title'): mixed { $image_data = $this->getDataFileData(); if (isset($image_data[$type])) { return $image_data[$type]; } $default_data = $this->getDefaultDataFileData(); return $default_data[$type] ?? null; } public function getDefaultDataFileData(): mixed { $image_data = $this->getFolderFileData(); return $image_data['*'] ?? null; } public function getDescription(): string { if (!is_null($this->description)) { return $this->description; } return $this->getDataFileDataByType('description') ?? ''; } public function setDescription($description): self { $this->description = $description; return $this; } public function getFile(): string { $this->prepareInput(); return $this->input->file; } public function setFile($file): self { $this->file = $file; $this->resetInput(); return $this; } public function getFileExtension(): string { $this->prepareInput(); return $this->input->file_extension; } public function getFileInfo(string $file, string $file_path): object { $file_path = urldecode($file_path); $path_info = @pathinfo($file_path); if (\RegularLabs\Library\File::isInternal($file_path)) { $size_info = @getimagesize($file_path); } $file_name = $path_info['basename'] ?? basename($file_path); $file_stem = $path_info['filename'] ?? JFile::stripExt($file_name); $file_extension = $path_info['extension'] ?? JFile::getExt($file_name); return (object) ['folder' => \RegularLabs\Library\File::getDirName($file), 'folder_path' => $path_info['dirname'] ?? null, 'file' => $file, 'file_path' => $file_path, 'file_name' => $file_name, 'file_stem' => $file_stem, 'file_extension' => $file_extension, 'mime' => $size_info['mime'] ?? null, 'width' => $size_info[0] ?? null, 'height' => $size_info[1] ?? null]; } public function getFileName(): string { $this->prepareInput(); return $this->input->file_name; } public function getFilePath(): string { $this->prepareInput(); return $this->input->file_path; } public function getFileStem(): string { $this->prepareInput(); return $this->input->file_stem; } public function getFolder(): string { $this->prepareInput(); return $this->input->folder; } public function getFolderPath(): string { $this->prepareInput(); return $this->input->folder_path; } public function getHeight(): int { $this->prepareOutput(); return $this->output->height; } public function getOriginalHeight(): int { $this->prepareInput(); return $this->input->height; } public function getOriginalWidth(): int { $this->prepareInput(); return $this->input->width; } public function getOutputFile(): string { $this->prepareInput(); if ($this->isExternal() || !$this->exists()) { return $this->input->file; } $this->prepareOutput(); return $this->output->file; } public function getOutputFilePath(): string { $this->prepareOutput(); return $this->output->file_path; } /** * @depecated Use getOutputFile() instead */ public function getPath(): string { return $this->getOutputFile(); } public function getSrcSet(?string $pixel_density = null): ?string { if ($this->isExternal()) { return null; } if (!$this->settings->resize->use_retina) { return null; } if ($this->getFilePath() == $this->getOutputFilePath()) { return null; } $pixel_density = $pixel_density ?: $this->settings->resize->retina_pixel_density; $single = $this->getOutputFile(); $double = $this->getOutputFile2x(); if ($double == $single) { return null; } return $double . ' ' . (float) $pixel_density . 'x, ' . $single . ' 1x'; } public function getTagAttributes(): object { $ordered_keys = ['src', 'srcset', 'alt', 'title', 'width', 'height', 'class', 'loading']; krsort($ordered_keys); $tag_width = $this->output->width; $tag_height = $this->output->height; $this->attributes = $this->attributes ?: (object) []; $this->attributes->src = $this->getOutputFile(); $this->attributes->srcset = $this->getSrcset(); $this->attributes->alt = $this->getAlt(); $this->attributes->title = $this->getTitle(\true); $this->attributes->width = $this->output->width ?: $this->attributes->width ?? 0; $this->attributes->height = $this->output->height ?: $this->attributes->height ?? 0; if ($this->attributes->width < $tag_width) { $this->attributes->width = $tag_width; $this->attributes->height = round($this->attributes->height * ($tag_width / $this->attributes->width)); } if ($this->attributes->height < $tag_height) { $this->attributes->height = $tag_height; $this->attributes->width = round($this->attributes->width * ($tag_height / $this->attributes->height)); } $this->attributes->width = $this->attributes->width ?: null; $this->attributes->height = $this->attributes->height ?: null; $attributes = (array) $this->attributes; foreach ($ordered_keys as $key) { if (!key_exists($key, $attributes)) { continue; } $value = $attributes[$key]; if (is_null($value)) { continue; } unset($attributes[$key]); $attributes = [$key => $value, ...$attributes]; } return (object) $attributes; } public function getTitle(bool $force = \false): string { if (isset($this->attributes->title)) { return $this->attributes->title; } $title = $this->getDataFileDataByType('title'); if (!is_null($title)) { // Remove HTML tags $title = strip_tags($title); return htmlentities($title); } return $this->getTitleFromName($force); } public function getWidth(): int { $this->prepareOutput(); return $this->output->width; } public function isResized(): bool { if (!is_null($this->is_resized)) { return $this->is_resized; } $this->is_resized = \false; $this->prepareInput(); $parent_folder_name = \RegularLabs\Library\File::getBaseName($this->input->folder_path); $resize_folder_name = $this->settings->resize->folder; // Image is not inside the resize folder if ($parent_folder_name != $resize_folder_name) { return \false; } $parent_folder = \RegularLabs\Library\File::getDirName($this->input->folder_path); $file_name = $this->input->file_name; // Check if image with same name exists in parent folder if ($this->exists($parent_folder . '/' . \RegularLabs\Library\StringHelper::utf8_decode($file_name))) { $this->is_resized = \true; return \true; } // Remove any dimensions from the file // image_300x200.jpg => image.jpg $file_name = \RegularLabs\Library\RegEx::replace('_[0-9]+x[0-9]*(\.[^.]+)$', '\1', $file_name); // Check again if image with same name (but without dimensions) exists in parent folder if ($this->exists($parent_folder . '/' . \RegularLabs\Library\StringHelper::utf8_decode($file_name))) { $this->is_resized = \true; return \true; } return \false; } public function outputExists(): bool { $file = $this->getOutputFilePath(); return $file && file_exists($file) && is_file($file); } public function render(string $outer_class = ''): string { $attributes = (array) $this->getTagAttributes(); $image_tag = '<img ' . \RegularLabs\Library\HtmlTag::flattenAttributes($attributes) . ' />'; if (!$outer_class) { return $image_tag; } return '<div class="' . htmlspecialchars($outer_class) . '">' . $image_tag . '</div>'; } /** * @depecated Use render() instead */ public function renderTag(): string { return $this->render($this->attributes->{'outer-class'} ?? ''); } public function setAlt($alt): self { return $this->setTagAttribute('alt', $alt); } public function setAutoTitles(bool $enabled, string $title_format = 'titlecase', string $lowercase_words = 'a,the,to,at,in,with,and,but,or'): self { $this->settings->title->enabled = $enabled; $this->settings->title->format = $title_format; $this->settings->title->lowercase_words = $lowercase_words; return $this; } public function setDimensions(int|float|string $width, int|float|string $height): self { $this->setResizeMethod(empty($width) || empty($height) ? 'scale' : 'crop'); $this->setOutputSetting('width', (int) $width); $this->setOutputSetting('height', (int) $height); return $this; } public function setEnableResize(bool $enabled): self { $this->settings->resize->enabled = $enabled; return $this; } public function setHeight(int|float|string $height): self { return $this->setOutputSetting('height', (int) $height); } public function setItemProp(?string $itemprop): self { $itemprop = $itemprop ? 'image' : null; $this->setTagAttribute('itemprop', $itemprop); return $this; } public function setLazyLoading(?bool $enabled): self { return $this->setTagAttribute('loading', $enabled ? 'lazy' : null); } public function setLowerCaseWords(array $words): self { $this->settings->title->lowercase_words = $words; return $this; } public function setOutputFileData(): self { if (!empty($this->output->file)) { return $this; } $this->prepareInput(); $output = clone $this->input; if (!empty($this->output->width) || !empty($this->output->height)) { $output->width = $this->output->width; $output->height = $this->output->height; } $this->output = $output; return $this; } public function setOutputSetting(string $key, mixed $value): self { if (is_null($this->output)) { $this->output = (object) ['width' => 0, 'height' => 0]; } if ($this->output->{$key} == $value) { return $this; } $this->output->{$key} = $value; $this->resetOutput(); return $this; } public function setResizeAttribute(string $key, mixed $value): self { if ($this->settings->resize->{$key} == $value) { return $this; } $this->settings->resize->{$key} = $value; $this->resetOutput(); return $this; } public function setResizeFolder(string $folder = 'resized'): self { return $this->setResizeAttribute('folder', $folder); } public function setResizeMaxAge(int $age_in_days): self { return $this->setResizeAttribute('max_age', $age_in_days); } public function setResizeMethod(string $method): self { $this->settings->resize->method = $method; return $this; } public function setResizeQuality(string|int $quality): self { return $this->setResizeAttribute('quality', $this->parseQuality($quality)); } public function setRetinaPixelDensity(string $pixel_density): self { return $this->setResizeAttribute('retina_pixel_density', $pixel_density); } public function setTagAttribute(string $key, mixed $value): self { if (is_null($this->attributes)) { $this->attributes = (object) []; } $this->attributes->{$key} = $value; return $this; } public function setTagAttributes(object $attributes): self { foreach ($attributes as $key => $value) { $this->setTagAttribute($key, $value); } return $this; } public function setTitle(string $title): self { return $this->setTagAttribute('title', $title); } public function setUseRetina(bool $use_retina): self { return $this->setResizeAttribute('use_retina', $use_retina); } public function setWidth(int|float|string $width): self { return $this->setOutputSetting('width', (int) $width); } private function getFolderFileData(): array { $folder = $this->getFolderPath(); if (isset(self::$data_files[$folder])) { return self::$data_files[$folder]; } self::$data_files[$folder] = []; if (!$this->exists($folder . '/data.txt')) { return self::$data_files[$folder]; } $data = file_get_contents($folder . '/data.txt'); $data = str_replace("\r", '', $data); $data = explode("\n", $data); foreach ($data as $data_line) { if (empty($data_line) || $data_line[0] == '#' || !str_contains($data_line, '=')) { continue; } [$key, $val] = explode('=', $data_line, 2); if (!\RegularLabs\Library\RegEx::match('^(?<file>.*?)\[(?<type>.*)\]$', $key, $match)) { continue; } $file = $match['file']; $type = $match['type']; if (!isset(self::$data_files[$folder][$file])) { self::$data_files[$folder][$file] = []; } self::$data_files[$folder][$file][$type] = $val; } return self::$data_files[$folder]; } private function getLowercaseWords(): array { $words = $this->settings->title->lowercase_words; $words = \RegularLabs\Library\ArrayHelper::implode($words, ','); $words = \RegularLabs\Library\StringHelper::strtolower($words); return explode(',', ' ' . str_replace(',', ' , ', $words . ' ')); } private function getOutputFile2x(): string { if ($this->isExternal()) { return $this->getOutputFile(); } $double_width = $this->output->width * 2; $double_height = $this->output->height * 2; if ($double_width == $this->input->width && $double_height == $this->input->height) { return $this->getOutputFile(); } $double_size = \RegularLabs\Library\ObjectHelper::clone($this); $double_size->setDimensions($double_width, $double_height); return $double_size->getOutputFile(); } private function getResizeBoundry(): string { if ($this->input->width / $this->output->width > $this->input->height / $this->output->height) { return 'width'; } return 'height'; } private function getResizeDimensions(): array { if (!$this->output->width) { return [null, $this->output->height]; } if (!$this->output->height) { return [$this->output->width, null]; } if ($this->input->width / $this->output->width > $this->input->height / $this->output->height) { return [null, $this->output->height]; } return [$this->output->width, null]; } private function getResizeFolder(): string { if (!$this->settings->resize->folder) { $this->setResizeFolder(); } return $this->getFolder() . '/' . $this->settings->resize->folder; } private function getResizeFolderPath(): string { if (!$this->settings->resize->folder) { $this->setResizeFolder(); } return $this->getFolderPath() . '/' . $this->settings->resize->folder; } private function getResizedFileName(): string { $this->prepareInput(); return $this->input->file_stem . '_' . $this->output->width . 'x' . $this->output->height . '.' . $this->input->file_extension; } private function getTitleFromName(bool $force = \false): string { if (!$force && !$this->settings->title->enabled) { return ''; } $title = \RegularLabs\Library\StringHelper::toSpaceSeparated($this->input->file_stem); switch ($this->settings->title->format) { case 'lowercase': return \RegularLabs\Library\StringHelper::strtolower($title); case 'uppercase': return \RegularLabs\Library\StringHelper::strtoupper($title); case 'uppercase_first': return \RegularLabs\Library\StringHelper::strtoupper(\RegularLabs\Library\StringHelper::substr($title, 0, 1)) . \RegularLabs\Library\StringHelper::strtolower(\RegularLabs\Library\StringHelper::substr($title, 1)); case 'titlecase': return function_exists('mb_convert_case') ? mb_convert_case(\RegularLabs\Library\StringHelper::strtolower($title), \MB_CASE_TITLE) : ucwords(strtolower($title)); case 'titlecase_smart': $title = function_exists('mb_convert_case') ? mb_convert_case(\RegularLabs\Library\StringHelper::strtolower($title), \MB_CASE_TITLE) : ucwords(strtolower($title)); $lowercase_words = $this->getLowercaseWords(); return str_ireplace($lowercase_words, $lowercase_words, $title); default: return $title; } } private function handleDimensions(): self { if (!$this->input->width || !$this->input->height) { return $this; } // Width and height are both not set, so revert to original dimensions if (!$this->output->height && !$this->output->width) { $this->output->width = $this->input->width; $this->output->height = $this->input->height; return $this; } if (!$this->outputExists()) { return $this; } if ($this->settings->resize->method == 'crop') { $this->output->width = $this->output->width ?: $this->output->height; $this->output->height = $this->output->height ?: $this->output->width; return $this->resize(); } // Width and height are both set if ($this->output->width && $this->output->height) { $boundry = $this->getResizeBoundry(); $this->output->width = $boundry == 'width' ? $this->output->width : round($this->output->height / $this->input->height * $this->input->width); $this->output->height = $boundry == 'height' ? $this->output->height : round($this->output->width / $this->input->width * $this->input->height); return $this->resize(); } $this->output->width = $this->output->width ?: round($this->output->height / $this->input->height * $this->input->width); $this->output->height = $this->output->height ?: round($this->output->width / $this->input->width * $this->input->height); return $this->resize(); } private function isExternal(): bool { $this->prepareInput(); return \RegularLabs\Library\File::isExternal($this->input->file); } private function limitDimensions(): self { if ($this->output->width <= $this->input->width && $this->output->height <= $this->input->height) { return $this; } if ($this->output->width > $this->input->width) { $this->output->height = $this->output->height / $this->output->width * $this->input->width; $this->output->width = $this->input->width; } if ($this->output->height > $this->input->height) { $this->output->width = $this->output->width / $this->output->height * $this->input->height; $this->output->height = $this->input->height; } $this->output->width = round($this->output->width); $this->output->height = round($this->output->height); return $this; } private function parseQuality(string|int $quality = 90): int { if (is_int($quality)) { return $quality; } return match ($quality) { 'low' => 30, 'medium' => 60, default => 90, }; } private function prepareInput(): self { if (!is_null($this->input)) { return $this; } if (!$this->file) { throw new Exception('No file set'); } if (\RegularLabs\Library\File::isExternal($this->file)) { $this->input = $this->getFileInfo($this->file, $this->file); return $this; } $file = self::cleanPath($this->file); $this->input = $this->getFileInfo($file, JPATH_ROOT . '/' . ltrim($file, '/')); return $this; } private function prepareOutput(): self { if (!empty($this->output->file)) { return $this; } $this->prepareInput(); if (empty($this->output->width) && empty($this->output->height)) { $this->output = clone $this->input; return $this; } $this->setOutputFileData(); $this->handleDimensions(); return $this; } private function resetInput(): self { $this->input = null; $this->resetOutput(); return $this; } private function resetOutput(): self { if (is_null($this->output)) { $this->output = (object) ['width' => 0, 'height' => 0]; } unset($this->output->file); return $this; } /** * Method to create a resized version of the current image and save them to disk */ private function resize(?string $width = null, ?string $height = null, ?int $quality = null): self { if (!$this->settings->resize->enabled) { return $this; } if ($this->isResized()) { return $this; } if ($this->isExternal()) { return $this; } if (!is_null($width) || !is_null($height)) { $this->setDimensions($width, $height); } if (!is_null($quality)) { $this->setResizeQuality($quality); } if ($this->output->width == $this->input->width && $this->output->height == $this->input->height) { $this->output = clone $this->input; return $this; } $this->limitDimensions(); $file = $this->getResizedFileName(); $this->output->file = $this->getResizeFolder() . '/' . rawurlencode($file); $this->output->file_path = $this->getResizeFolderPath() . '/' . $file; $file_exists = $this->outputExists(); $file_is_outdated = $this->settings->resize->force_overwrite; if (!$file_is_outdated && $file_exists && $this->settings->resize->max_age > 0) { $max_age_in_seconds = ceil($this->settings->resize->max_age * 60 * 60 * 24); $min_time = time() - $max_age_in_seconds; $file_is_outdated = filemtime($this->output->file_path) < $min_time; } if ($file_exists && !$file_is_outdated) { //$this->output = $this->getFileInfo($this->output->file, $this->output->file_path); return $this; } [$resize_width, $resize_height] = $this->getResizeDimensions(); try { $resized = ImageManager::make($this->getFilePath())->orientate()->resize($resize_width, $resize_height, function ($constraint) { $constraint->aspectRatio(); }); if ($this->settings->resize->method == 'crop' || $this->output->width && $this->output->height) { $resized->crop($this->output->width, $this->output->height); } $this->createResizeFolder(); $resized->save($this->output->file_path, $this->settings->resize->quality); } catch (NotReadableException $exception) { $resized = null; } if (!$resized) { $this->output = clone $this->input; $this->is_resized = \false; } $this->output = $this->getFileInfo($this->output->file, $this->output->file_path); return $this; } private function setFromOldAttributes(object $attributes): void { if (isset($attributes->alt)) { $this->setAlt($attributes->alt); } if (isset($attributes->title)) { $this->setTitle($attributes->title); } if (isset($attributes->class)) { $this->setTagAttribute('class', $attributes->class); } if (isset($attributes->{'outer-class'})) { $this->setTagAttribute('outer-class', $attributes->{'outer-class'}); } if (isset($attributes->{'resize-folder'})) { $folder = \RegularLabs\Library\File::getBaseName($attributes->{'resize-folder'}); $this->setResizeFolder($folder); } if (isset($attributes->quality)) { $this->setResizeQuality($attributes->quality); } $this->setDimensions($attributes->width ?? 0, $attributes->height ?? 0); } }