From 15a4046402201b624e631522432db9c1fb16aace Mon Sep 17 00:00:00 2001 From: philipp lang Date: Mon, 6 Sep 2021 02:31:27 +0200 Subject: [PATCH] Add uploads folder --- .gitignore | 1 + Plugin.php | 158 +++++++------------------------- classes/FileObserver.php | 70 ++++++++++++++ classes/FirstLetterStrategy.php | 38 -------- classes/ImageResizer.php | 115 +++++++++++++++++++++++ classes/ResizeJob.php | 13 +++ classes/TagGenerator.php | 106 +++++++++++++++++++++ classes/TagPresenter.php | 72 --------------- composer.json | 3 +- composer.lock | 89 +++++++++++++++++- console/ResizeMake.php | 140 ++++------------------------ models/setting/fields.yaml | 34 +++---- phpunit.xml | 29 ++++++ tests/DeleteTest.php | 60 ++++++++++++ tests/ImageTagTest.php | 88 ++++++++++++++++++ tests/MoveTest.php | 69 ++++++++++++++ tests/ResizerTest.php | 158 ++++++++++++++++++++++++++++++++ tests/TestCase.php | 41 +++++++++ tests/stub/img.jpg | Bin 0 -> 133339 bytes 19 files changed, 909 insertions(+), 375 deletions(-) create mode 100644 classes/FileObserver.php delete mode 100644 classes/FirstLetterStrategy.php create mode 100644 classes/ImageResizer.php create mode 100644 classes/ResizeJob.php create mode 100644 classes/TagGenerator.php delete mode 100644 classes/TagPresenter.php create mode 100644 phpunit.xml create mode 100644 tests/DeleteTest.php create mode 100644 tests/ImageTagTest.php create mode 100644 tests/MoveTest.php create mode 100644 tests/ResizerTest.php create mode 100644 tests/TestCase.php create mode 100644 tests/stub/img.jpg diff --git a/.gitignore b/.gitignore index 846b879..a2741e5 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ /formwidgets/responsiveimage/assets/css/responsiveimage.build.css.map /formwidgets/responsiveimage/assets/js/responsiveimage.build.js.map /formwidgets/responsiveimage/assets/js/responsiveimage.build.js.LICENSE.txt +/.phpunit.result.cache diff --git a/Plugin.php b/Plugin.php index 1f26c32..743e082 100644 --- a/Plugin.php +++ b/Plugin.php @@ -1,16 +1,21 @@ bind(ImageResizer::class, function() { + $disk = (new File())->getDisk(); + $dir = (new File(['is_public' => true]))->getStorageDirectory().'c/'; + $media = MediaLibrary::instance(); - } + return new ImageResizer($disk, $dir, $media); + }); - /** - * Registers any front-end components implemented in this plugin. - * - * @return array - */ - public function registerComponents() - { - return []; // Remove this line to activate + Event::listen('media.file.upload', function($widget, $filePath, $uploadedFile) { + if (app(FileObserver::class)->shouldProcessFile($filePath)) { + Queue::push(ResizeJob::class, [$filePath]); + } + }); + Event::listen('media.file.delete', function($widget, $filePath) { + app(FileObserver::class)->delete($filePath); + }); - return [ - 'Aweos\Resizer\Components\MyComponent' => 'myComponent', - ]; - } - - /** - * Registers any back-end permissions used by this plugin. - * - * @return array - */ - public function registerPermissions() - { - return []; // Remove this line to activate - - return [ - 'aweos.resizer.some_permission' => [ - 'tab' => 'resizer', - 'label' => 'Some permission' - ], - ]; - } - - /** - * Registers back-end navigation items for this plugin. - * - * @return array - */ - public function registerNavigation() - { - return []; // Remove this line to activate - - return [ - 'resizer' => [ - 'label' => 'resizer', - 'url' => Backend::url('aweos/resizer/mycontroller'), - 'icon' => 'icon-leaf', - 'permissions' => ['aweos.resizer.*'], - 'order' => 500, - ], - ]; + Event::listen('media.file.move', function($widget, $old, $new) { + app(FileObserver::class)->rename($old, $new); + }); + Event::listen('media.file.rename', function($widget, $old, $new) { + app(FileObserver::class)->rename($old, $new); + }); } public function registerSettings() { @@ -123,75 +99,9 @@ class Plugin extends PluginBase public function registerMarkupTags() { return [ 'filters' => [ - 'resize' => function($media, $breakpoints = [], $name = '') { - return Cache::remember('resized_image.'.$name.$media, 3600, function() use ($media, $breakpoints) { - ksort($breakpoints); - $l = MediaLibrary::instance(); - $folders = Setting::get('folders'); - $sizes = Setting::get('srcx'); - - $media = '/'.ltrim($media, '/'); - $filename = basename($media); - $dirname = dirname($media); - $extension = pathinfo($media, PATHINFO_EXTENSION); - - if (! $l->folderExists($dirname.'/c')) { - return 'src="'.$l->getPathUrl($media).'"'; - } - - $sizes = collect($l->listFolderContents($dirname.'/c')) - ->filter(function($f) use ($sizes, $filename) { - foreach ($sizes as $size) { - if (pathinfo($f->path, PATHINFO_FILENAME).'.'.pathinfo($filename, PATHINFO_EXTENSION) - == pathinfo($filename, PATHINFO_FILENAME).'-'.$size.'t.'.pathinfo($filename, PATHINFO_EXTENSION)) { - return true; - } - - if (pathinfo($f->path, PATHINFO_FILENAME).'.'.pathinfo($filename, PATHINFO_EXTENSION) - == pathinfo($filename, PATHINFO_FILENAME).'-'.$size.'.'.pathinfo($filename, PATHINFO_EXTENSION)) { - return true; - } - } - - return false; - }) - ->map(function($fileVersion) use ($l) { - $sizes = getimagesize(Storage::path('media/'.$fileVersion->path)); - - return [$l->getPathUrl($fileVersion->path), $sizes[0], $sizes[1]]; - })->sortBy(function($size) { - return $size[1]; - }) - ; - - if ($sizes->isEmpty()) { - return 'src="'.$l->getPathUrl($media).'"'; - } - - $srcset = 'srcset="'; - $s = ''; - - foreach($sizes as $size) { - $srcset .= $size[0].' '.$size[1].'w, '; - $s .= '(max-width: '.$size[1].'px) '.$size[1].'px, '; - } - - $s = 'sizes="'.substr($s, 0, -2).', 1920px"'; - - if (!empty($breakpoints)) { - $s = $this->breakpointsToSizes($breakpoints); - } - - $srcset = substr($srcset, 0, -2).'"'; - - $normalSize = 'width="'.$sizes->last()[1].'" height="'.$sizes->last()[2].'"'; - - return 'src="'.$l->getPathUrl($media).'" '.$srcset.' '.$s.' '.$normalSize; - }); + 'resize' => function($media, $size = 'original') { + return app(TagGenerator::class)->generate($media, $size); }, - 'responsiveimage' => function($id, $data = []) { - return (new TagPresenter($id, $data))->cacheOutput(); - } ] ]; } diff --git a/classes/FileObserver.php b/classes/FileObserver.php new file mode 100644 index 0000000..025623d --- /dev/null +++ b/classes/FileObserver.php @@ -0,0 +1,70 @@ +normalizePath($mediaPath)}"; + } + + public function normalizePath(string $path): string + { + return preg_replace('|^/*|', '', $path); + } + + public function fullMediaPath(string $path): string + { + return "media/{$this->normalizePath($path)}"; + } + + public function shouldProcessFile($file): bool + { + return collect(Setting::get('folders'))->pluck('folder')->first( + fn ($folder) => starts_with($file, $folder.'/') + ) !== null; + } + + public function delete($path): void + { + $dir = pathinfo($path, PATHINFO_DIRNAME); + $base = $dir.'/'.pathinfo($path, PATHINFO_FILENAME); + + Storage::delete("uploads/public/c{$path}"); + + collect(Storage::files("uploads/public/c{$dir}"))->filter( + fn ($file) => preg_match('|'.preg_quote("uploads/public/c{$base}", '|').'-[0-9]+x[0-9]+\.[a-zA-Z]+$|', $file) + )->each(function ($path) { + Storage::delete($path); + }); + + if (empty(Storage::allFiles("uploads/public/c{$dir}"))) { + Storage::deleteDirectory("uploads/public/c{$dir}"); + } + } + + public function rename(string $old, string $new): void + { + $dir = pathinfo($old, PATHINFO_DIRNAME); + $base = pathinfo($old, PATHINFO_FILENAME); + $newBase = pathinfo($new, PATHINFO_FILENAME); + $newDir = pathinfo($new, PATHINFO_DIRNAME); + + foreach (Storage::files("uploads/public/c{$dir}") as $file) { + if (!preg_match_all('|'.preg_quote("uploads/public/c{$dir}/{$base}", '|').'(-[0-9]+x[0-9]+)?(\.[a-zA-Z]+)$|', $file, $matches)) { + continue; + } + + $size = $matches[1][0]; + $ext = $matches[2][0]; + + Storage::move($file, "uploads/public/c{$newDir}/{$newBase}{$size}{$ext}"); + } + } + +} diff --git a/classes/FirstLetterStrategy.php b/classes/FirstLetterStrategy.php deleted file mode 100644 index 58911d1..0000000 --- a/classes/FirstLetterStrategy.php +++ /dev/null @@ -1,38 +0,0 @@ -disk = $disk; + $this->uploadDir = $uploadDir; + $this->media = $media; + } + + public function generate(string $file): void + { + $this->source = app(FileObserver::class)->normalizePath($file); + if ($this->media->findFiles($this->source)[0]->getFileType() !== 'image') { + return; + } + $this->copyOriginalImage(); + $this->generateVersions(); + } + + public function copyOriginalImage() + { + $this->disk->put($this->destinationFilename(), $this->media->get($this->source)); + } + + private function destinationFilename(): string + { + return $this->uploadDir.$this->source; + } + + private function dimensions(): Collection + { + [$width, $height] = getimagesize(Storage::path($this->destinationFilename())); + + return collect(compact('width', 'height')); + } + + private function sizes(): Collection + { + $sizes = Setting::get('sizes'); + + return collect(array_column($sizes, 'aspect_ratio'))->map( + fn ($ratio) => collect(array_combine(['width', 'height'], explode('x', $ratio))), + ); + } + + private function possibleSizes(): Collection + { + $return = collect([]); + $ratios = collect(); + $ratios->push($this->dimensions()); + $ratios = $ratios->merge($this->sizes()); + + foreach (collect(Setting::get('breakpoints'))->push($this->dimensions()->get('width'))->unique() as $size) { + foreach ($ratios as $ratio) { + $height = $size * $ratio->get('height') / $ratio->get('width'); + + $return->push(collect([ + 'width' => round($size), + 'height' => round($height), + ])); + } + } + + return $return; + } + + private function generateVersions(): void + { + foreach ($this->possibleSizes() as $size) { + $temp = microtime().'.jpg'; + $this->disk->copy($this->destinationFilename(), $temp); + $fullOriginalPath = $this->disk->path($this->destinationFilename()); + $pathinfo = collect(pathinfo($this->destinationFilename())); + + $r = app(ImageManager::class)->make($this->disk->path($temp)) + ->fit($size->get('width'), $size->get('height'), fn ($constraint) => $constraint->upsize()) + ->save($this->disk->path($temp)); + list($destWidth, $destHeight) = getimagesize($this->disk->path($temp)); + + $versionFilename = $pathinfo->get('dirname'). + '/'. + $pathinfo->get('filename'). + '-'. + $destWidth. + 'x'. + $destHeight. + '.'. + $pathinfo->get('extension'); + + if ($this->disk->exists($versionFilename)) { + $this->disk->delete($versionFilename); + } + + $this->disk->move($temp, $versionFilename); + } + } + +} diff --git a/classes/ResizeJob.php b/classes/ResizeJob.php new file mode 100644 index 0000000..e19dd5f --- /dev/null +++ b/classes/ResizeJob.php @@ -0,0 +1,13 @@ +generate($file); + } +} diff --git a/classes/TagGenerator.php b/classes/TagGenerator.php new file mode 100644 index 0000000..77afd96 --- /dev/null +++ b/classes/TagGenerator.php @@ -0,0 +1,106 @@ +media = MediaLibrary::instance(); + $this->breakpoints = Setting::get('breakpoints'); + ksort($this->breakpoints); + } + + public function cacheOutput() { + return Cache::rememberForever('responsive-image-'.$this->attachment->id, function() { + return $this->output(); + }).' '.$this->attrs; + } + + public function size(string $name): array + { + $size = collect(Setting::get('sizes'))->filter(fn ($size) => $size['name'] === $name) + ->first(); + + return explode('x', $size['aspect_ratio']); + } + + public function possibleFiles(string $ratio): Collection + { + if (Storage::mimeType(app(FileObserver::class)->fullMediaPath($this->path)) !== 'image/jpeg') { + return collect([]); + } + + $filename = pathinfo($this->path, PATHINFO_FILENAME); + $basePath = app(FileObserver::class)->versionsPath(pathinfo($this->path, PATHINFO_DIRNAME)); + [$originalWidth, $originalHeight] = getimagesize(Storage::path(app(FileObserver::class)->fullMediaPath($this->path))); + $aspectRatio = $ratio === 'original' + ? $originalWidth / $originalHeight + : $this->size($ratio)[0] / $this->size($ratio)[1]; + + $result = collect([]); + + foreach (Storage::files($basePath) as $file) { + if (!preg_match('|'.preg_quote($basePath.'/'.$filename, '|').'-([0-9]+x[0-9]+)\.([a-zA-Z]+)$|', $file, $matches)) { + continue; + } + + $width = explode('x', $matches[1])[0]; + $height = explode('x', $matches[1])[1]; + if ($width / ($height+1) > $aspectRatio || $width / ($height-1) < $aspectRatio) { + continue; + } + + $result->push(collect([ + 'url' => Storage::url($file), + 'width' => $width, + 'height' => $height, + ])); + } + + return $result->sortBy('width'); + } + + public function generate(string $path, ?string $ratio = 'original'): string + { + $this->path = $path; + $files = $this->possibleFiles($ratio); + + if ($files->isEmpty()) { + return 'src="/storage/app/media/'.$path.'"'; + } + + $sizes = $files->map(function($file) { + return "(max-width: {$file->get('width')}px) {$file->get('width')}px"; + }); + + $srcset = $files->map(function($file) { + return "{$file->get('url')} {$file->get('width')}w"; + }); + + return $this->htmlAttributes(collect([ + 'width' => $files->last()->get('width'), + 'height' => $files->last()->get('height'), + 'sizes' => $sizes->implode(', '), + 'srcset' => $srcset->implode(', '), + 'src' => $files->last()->get('url'), + ])); + } + + private function htmlAttributes($attr) { + return $attr->map(function($value, $key) { + return "{$key}=\"{$value}\""; + })->implode(' '); + } +} diff --git a/classes/TagPresenter.php b/classes/TagPresenter.php deleted file mode 100644 index aa78373..0000000 --- a/classes/TagPresenter.php +++ /dev/null @@ -1,72 +0,0 @@ -attachment = Attachment::find($id); - $this->attrs = data_get($data, 'attrs'); - $this->breakpoints = data_get($data, 'breakpoints', []); - $this->sizes = Setting::get('srcx'); - } - - public function cacheOutput() { - return Cache::rememberForever('responsive-image-'.$this->attachment->id, function() { - return $this->output(); - }).' '.$this->attrs; - } - - public function output() { - ksort($this->breakpoints); - - $files = collect(Storage::disk($this->disk)->files($this->attachment->path))->filter(function($file) { - return preg_match($this->attachment->versionRegex, $file); - })->map(function($file) { - $sizes = getimagesize(Storage::disk($this->disk)->path($file)); - - return collect([ - 'url' => Storage::disk($this->disk)->url($file), - 'width' => $sizes[0], - 'height' => $sizes[1] - ]); - })->sortBy('width'); - - if ($files->isEmpty()) { - return 'src="'.$this->attachment->url.'"'; - } - - $sizes = $files->map(function($file) { - return "(max-width: {$file->get('width')}px) {$file->get('width')}px"; - }); - - $srcset = $files->map(function($file) { - return "{$file->get('url')} {$file->get('width')}w"; - }); - - return $this->htmlAttributes(collect([ - 'width' => $files->last()->get('width'), - 'height' => $files->last()->get('height'), - 'sizes' => $sizes->implode(', '), - 'srcset' => $srcset->implode(', '), - 'alt' => $this->attachment->title - ])); - } - - private function htmlAttributes($attr) { - return $attr->map(function($value, $key) { - return "{$key}=\"{$value}\""; - })->implode(' '); - } -} diff --git a/composer.json b/composer.json index 7d0e834..6cc81b0 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,8 @@ "name": "aweos/oc-resizer-plugin", "type": "october-plugin", "require": { - "guzzlehttp/guzzle": "^6.3" + "guzzlehttp/guzzle": "^6.3", + "intervention/image": "^2.6" }, "authors": [ { diff --git a/composer.lock b/composer.lock index bf6fd2d..28c4e0c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "82331c61df9e7f95117f9b82b35b6ce2", + "content-hash": "d44b27fd707830b38897f254dc4f9040", "packages": [ { "name": "guzzlehttp/guzzle", @@ -193,6 +193,90 @@ ], "time": "2019-07-01T23:21:34+00:00" }, + { + "name": "intervention/image", + "version": "2.6.1", + "source": { + "type": "git", + "url": "https://github.com/Intervention/image.git", + "reference": "0925f10b259679b5d8ca58f3a2add9255ffcda45" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Intervention/image/zipball/0925f10b259679b5d8ca58f3a2add9255ffcda45", + "reference": "0925f10b259679b5d8ca58f3a2add9255ffcda45", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "guzzlehttp/psr7": "~1.1 || ^2.0", + "php": ">=5.4.0" + }, + "require-dev": { + "mockery/mockery": "~0.9.2", + "phpunit/phpunit": "^4.8 || ^5.7 || ^7.5.15" + }, + "suggest": { + "ext-gd": "to use GD library based image processing.", + "ext-imagick": "to use Imagick based image processing.", + "intervention/imagecache": "Caching extension for the Intervention Image library" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.4-dev" + }, + "laravel": { + "providers": [ + "Intervention\\Image\\ImageServiceProvider" + ], + "aliases": { + "Image": "Intervention\\Image\\Facades\\Image" + } + } + }, + "autoload": { + "psr-4": { + "Intervention\\Image\\": "src/Intervention/Image" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Oliver Vogel", + "email": "oliver@olivervogel.com", + "homepage": "http://olivervogel.com/" + } + ], + "description": "Image handling and manipulation library with support for Laravel integration", + "homepage": "http://image.intervention.io/", + "keywords": [ + "gd", + "image", + "imagick", + "laravel", + "thumbnail", + "watermark" + ], + "support": { + "issues": "https://github.com/Intervention/image/issues", + "source": "https://github.com/Intervention/image/tree/2.6.1" + }, + "funding": [ + { + "url": "https://www.paypal.me/interventionphp", + "type": "custom" + }, + { + "url": "https://github.com/Intervention", + "type": "github" + } + ], + "time": "2021-07-22T14:31:53+00:00" + }, { "name": "psr/http-message", "version": "1.0.1", @@ -291,5 +375,6 @@ "prefer-stable": false, "prefer-lowest": false, "platform": [], - "platform-dev": [] + "platform-dev": [], + "plugin-api-version": "2.0.0" } diff --git a/console/ResizeMake.php b/console/ResizeMake.php index bad1b1e..7413381 100644 --- a/console/ResizeMake.php +++ b/console/ResizeMake.php @@ -1,20 +1,21 @@ start($folder['folder'], $sizes, $callback); + public function resize(string $folder): void + { + foreach ($this->media->listFolderContents($folder) as $item) { + if ($item->type === 'folder') { + $this->resize($item->path); + } else { + Queue::push(ResizeJob::class, [$item->path]); + } } } @@ -43,117 +44,12 @@ class ResizeMake extends Command */ public function handle() { - $this->http = new Client(['base_uri' => 'https://api.tinify.com']); $this->media = MediaLibrary::instance(); ProgressBar::setFormatDefinition('custom', '%current%/%max% %bar% -- %message% (%size%)'); - $tinifyKey = Setting::get('tinify') ? Setting::get('tinypngkey') : null; + Storage::deleteDirectory('uploads/public/c'); - $i = 0; - $this->forAllResizables(function($file, $w) use (&$i) { - $i++; - }); - - $bar = $this->output->createProgressBar($i); - $bar->setFormat('custom'); - - /** - * @param File $file The current filename - * @param int $w Current width - * @param string $compressed compressed file - * @param string $uncompressed uncompressed file - */ - $this->forAllResizables(function($file, $w, $compressed, $uncompressed) use ($bar, $tinifyKey) { - $bar->setMessage($file->path); - $bar->setMessage($w, 'size'); - $bar->advance(); - - if (Storage::exists($compressed)) { - return; - } - - if (!Storage::exists($uncompressed)) { - $r = Resizer::open(Storage::disk('local')->path('media'.$file->path)); - $r->resize($w, 0); - $r->save(Storage::disk('local')->path($uncompressed)); - $this->info(Storage::url($uncompressed)); - } - - if (!is_null($tinifyKey)) { - $newUrl = url($this->media->getPathUrl(preg_replace('/^media/', '', $uncompressed))); - - try { - $response = $this->http->post('/shrink', [ - 'headers' => [ - 'Content-Type' => 'application/json', - 'Accept' => 'application/json', - 'Authorization' => 'Basic '.base64_encode('api:'.$tinifyKey) - ], - 'json' => [ - 'source' => ['url' => $newUrl] - ] - ]); - - $output = json_decode((string) $response->getBody()); - $url = $output->output->url; - - $response = $this->http->get($output->output->url, [ - 'headers' => [ - 'Authorization' => 'Basic '.base64_encode('api:'.$tinifyKey) - ] - ]); - $image = (string) $response->getBody(); - - Storage::put($compressed, $image); - Storage::delete($uncompressed); - } catch(ClientException $e) { - return; - } - } - - $bar->advance(); - }); - - $bar->finish(); - } - - public function getMediaOfType($f, $type) { - return array_filter($this->media->listFolderContents($f, 'title', 'image'), function($item) use ($f, $type) { - return $item->type == $type && $item->path != $f.'/c'; - }); - } - - public function start($f, $sizes, $callback) { - $f = '/'.ltrim(rtrim($f, '/'), '/'); - - $folders = $this->getMediaOfType($f, 'folder'); - - foreach ($folders as $folder) { - $this->start($folder->path, $sizes, $callback); - } - - $files = $this->getMediaOfType($f, 'file'); - - // Create C-Folder as current subfolder if it doesnt exist yet - if(count($files) && !$this->media->folderExists($f.'/c')) { - $this->media->makeFolder($f.'/c'); - } - - foreach ($files as $file) { - foreach ($sizes as $w) { - $imagesize = getimagesize(Storage::path('media'.$file->path)); - - $uncompressedFilename = pathinfo($file->path, PATHINFO_FILENAME).'-'.$w.'.'.pathinfo($file->path, PATHINFO_EXTENSION); - $compressedFilename = pathinfo($file->path, PATHINFO_FILENAME).'-'.$w.'t.'.pathinfo($file->path, PATHINFO_EXTENSION); - - $compressed = 'media'.dirname($file->path).'/c/'.$compressedFilename; - $uncompressed = 'media'.dirname($file->path).'/c/'.$uncompressedFilename; - - if ($imagesize[0] < $w) { - continue; - } - - call_user_func($callback, $file, $w, $compressed, $uncompressed); - } + foreach (Setting::get('folders') as $folder) { + $this->resize($folder['folder']); } } diff --git a/models/setting/fields.yaml b/models/setting/fields.yaml index 3303979..bdce25d 100644 --- a/models/setting/fields.yaml +++ b/models/setting/fields.yaml @@ -5,27 +5,29 @@ fields: folders: type: repeater + label: Überwachte Ordner form: fields: folder: type: mediafinder label: Folder - srcx: + breakpoints: type: taglist mode: array - label: Source X - srcy: - type: taglist - mode: array - label: Source Y + label: Breakpoints + comment: Von diesen Werten werden Bilder generiert mit entsprechender Breite. Bitte nur die Breite in Pixel angeben. Die Höhe bestimmt sich nach der Zielgröße + sizes: + type: repeater + label: Seitenverhältnisse + form: + fields: + name: + label: Name + span: left + comment: Bitte im Slug Format eingeben - z.B. "big-box" + aspect_ratio: + label: Seitenverhältnis + comment: Bitte im Format [x]x[y] eingeben - z.B. 1280x700 + span: right + comment: Ein Bild mit den selben Seitenverhältnissen des Originalbildes wird immer generiert. Gebe hier die Seitenverhältnisse in "x/y" an, die sonst noch generiert werden sollen, z.B. 16/9 - tinify: - type: checkbox - label: Tinyfy with tinypng - tinypngkey: - type: text - label: Tinypng API Key - trigger: - action: show - field: tinify - condition: checked diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..311802b --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,29 @@ + + + + + ./tests + + + + + + + + + + + + + + + diff --git a/tests/DeleteTest.php b/tests/DeleteTest.php new file mode 100644 index 0000000..7056064 --- /dev/null +++ b/tests/DeleteTest.php @@ -0,0 +1,60 @@ +image(100, 100)->storeAs('uploads/public/c/pages', 'test.jpg', 'local'); + Event::fire('media.file.delete', [null, '/pages/test.jpg', null]); + + $this->assertFileCount(0, 'pages'); + Storage::assertMissing('uploads/public/c/pages'); + } + + public function testItPreservesDirectoryWhenThereAreOtherFiles() + { + Setting::set('folders', ['pages']); + Setting::set('sizes', []); + Setting::set('breakpoints', []); + + UploadedFile::fake()->image(100, 100)->storeAs('uploads/public/c/pages', 'test.jpg', 'local'); + UploadedFile::fake()->image(100, 100)->storeAs('uploads/public/c/pages', 'test2.jpg', 'local'); + Event::fire('media.file.delete', [null, '/pages/test.jpg', null]); + + $this->assertHasFile('pages/test2.jpg'); + $this->assertDoesntHaveFile('pages/test.jpg'); + } + + public function testItDeletesFileVersions() + { + Setting::set('folders', ['pages']); + Setting::set('sizes', []); + Setting::set('breakpoints', []); + + UploadedFile::fake()->image(100, 100)->storeAs('uploads/public/c/pages', 'test.jpg', 'local'); + UploadedFile::fake()->image(300, 500)->storeAs('uploads/public/c/pages', 'test-300x500.jpg', 'local'); + Event::fire('media.file.delete', [null, '/pages/test.jpg', null]); + + $this->assertDoesntHaveFile('pages/test-300x500.jpg'); + } + +} diff --git a/tests/ImageTagTest.php b/tests/ImageTagTest.php new file mode 100644 index 0000000..4d16338 --- /dev/null +++ b/tests/ImageTagTest.php @@ -0,0 +1,88 @@ +media->put('/pages/test.jpg', UploadedFile::fake()->image('test.jpg', 500, 500)->get()); + UploadedFile::fake()->image('test.jpg', 500, 500)->storeAs('uploads/public/c/pages', 'test-500x500.jpg', 'local'); + UploadedFile::fake()->image('test.jpg', 200, 200)->storeAs('uploads/public/c/pages', 'test-200x200.jpg', 'local'); + UploadedFile::fake()->image('test.jpg', 100, 100)->storeAs('uploads/public/c/pages', 'test-100x100.jpg', 'local'); + + $this->assertEquals( + 'width="500" height="500" sizes="(max-width: 100px) 100px, (max-width: 200px) 200px, (max-width: 500px) 500px" srcset="/storage/uploads/public/c/pages/test-100x100.jpg 100w, /storage/uploads/public/c/pages/test-200x200.jpg 200w, /storage/uploads/public/c/pages/test-500x500.jpg 500w" src="/storage/uploads/public/c/pages/test-500x500.jpg"', + app(TagGenerator::class)->generate('pages/test.jpg'), + ); + } + + public function testItSkipsImagesWithWrongAspectRatio() + { + Setting::set('folders', ['pages']); + Setting::set('sizes', []); + Setting::set('breakpoints', [100,200]); + $this->media->put('/pages/test.jpg', UploadedFile::fake()->image('test.jpg', 500, 500)->get()); + UploadedFile::fake()->image('test.jpg', 100, 100)->storeAs('uploads/public/c/pages', 'test-100x100.jpg', 'local'); + UploadedFile::fake()->image('test.jpg', 150, 100)->storeAs('uploads/public/c/pages', 'test-150x100.jpg', 'local'); + + $this->assertFalse(str_contains( + app(TagGenerator::class)->generate('pages/test.jpg'), + '150x100', + )); + } + + public function testItCanGenerateAAspectRatio() + { + Setting::set('folders', ['pages']); + Setting::set('sizes', [['name' => 'size', 'aspect_ratio' => '1x2']]); + Setting::set('breakpoints', [100,200]); + $this->media->put('/pages/test.jpg', UploadedFile::fake()->image('test.jpg', 500, 500)->get()); + UploadedFile::fake()->image('test.jpg', 100, 100)->storeAs('uploads/public/c/pages', 'test-100x100.jpg', 'local'); + UploadedFile::fake()->image('test.jpg', 150, 100)->storeAs('uploads/public/c/pages', 'test-150x100.jpg', 'local'); + UploadedFile::fake()->image('test.jpg', 250, 500)->storeAs('uploads/public/c/pages', 'test-250x500.jpg', 'local'); + UploadedFile::fake()->image('test.jpg', 200, 400)->storeAs('uploads/public/c/pages', 'test-200x400.jpg', 'local'); + UploadedFile::fake()->image('test.jpg', 100, 200)->storeAs('uploads/public/c/pages', 'test-100x200.jpg', 'local'); + + $this->assertFalse(str_contains( + app(TagGenerator::class)->generate('pages/test.jpg', 'size'), + '100x100', + )); + $this->assertTrue(str_contains( + app(TagGenerator::class)->generate('pages/test.jpg', 'size'), + '200x400', + )); + } + + public function testItServesAspectRatiosThatCanBeOnePixelLarger() + { + Setting::set('folders', ['pages']); + Setting::set('sizes', [['name' => 'size', 'aspect_ratio' => '1200x280']]); + Setting::set('breakpoints', [640]); + $this->media->put('/pages/test.jpg', UploadedFile::fake()->image('test.jpg', 1920, 899)->get()); + UploadedFile::fake()->image('test.jpg', 640, 149)->storeAs('uploads/public/c/pages', 'test-640x149.jpg', 'local'); + + $this->assertTrue(str_contains( + app(TagGenerator::class)->generate('pages/test.jpg', 'size'), + '640x149', + )); + } + +} diff --git a/tests/MoveTest.php b/tests/MoveTest.php new file mode 100644 index 0000000..f2b3005 --- /dev/null +++ b/tests/MoveTest.php @@ -0,0 +1,69 @@ +media->put('/pages/alt/test.jpg', UploadedFile::fake()->image(100, 100)->get()); + UploadedFile::fake()->image(100, 100)->storeAs('uploads/public/c/pages/alt', 'test.jpg', 'local'); + UploadedFile::fake()->image(100, 100)->storeAs('uploads/public/c/pages/alt', 'test-200x300.jpg', 'local'); + + Event::fire('media.file.move', [null, '/pages/alt/test.jpg', '/pages/neu/test.jpg']); + + $this->assertFileCount(0, 'pages/alt'); + $this->assertHasFile('pages/neu/test.jpg'); + $this->assertHasFile('pages/neu/test-200x300.jpg'); + } + + public function testItMovesFilesOnRename() + { + Setting::set('folders', ['pages']); + Setting::set('sizes', []); + Setting::set('breakpoints', []); + $this->media->put('/pages/test.jpg', UploadedFile::fake()->image(100, 100)->get()); + UploadedFile::fake()->image(100, 100)->storeAs('uploads/public/c/pages', 'test.jpg', 'local'); + UploadedFile::fake()->image(100, 100)->storeAs('uploads/public/c/pages', 'test-200x300.jpg', 'local'); + + Event::fire('media.file.rename', [null, '/pages/test.jpg', '/pages/testneu.jpg']); + + $this->assertFileCount(2, 'pages'); + $this->assertHasFile('pages/testneu.jpg'); + $this->assertHasFile('pages/testneu-200x300.jpg'); + } + + public function testItDoesntMoveOtherFilesInTheSameDirectory() + { + Setting::set('folders', ['pages']); + Setting::set('sizes', []); + Setting::set('breakpoints', []); + $this->media->put('/pages/test.jpg', UploadedFile::fake()->image(100, 100)->get()); + UploadedFile::fake()->image(100, 100)->storeAs('uploads/public/c/pages', 'test.jpg', 'local'); + UploadedFile::fake()->image(100, 100)->storeAs('uploads/public/c/pages', 'test-200x300.jpg', 'local'); + UploadedFile::fake()->image(100, 100)->storeAs('uploads/public/c/pages', 'testother.jpg', 'local'); + + Event::fire('media.file.rename', [null, '/pages/test.jpg', '/pages/testneu.jpg']); + + $this->assertFileCount(3, 'pages'); + $this->assertHasFile('pages/testneu.jpg'); + $this->assertHasFile('pages/testneu-200x300.jpg'); + } + +} diff --git a/tests/ResizerTest.php b/tests/ResizerTest.php new file mode 100644 index 0000000..11793f4 --- /dev/null +++ b/tests/ResizerTest.php @@ -0,0 +1,158 @@ +image('test.jpg', 500, 600); + $media = MediaLibrary::instance(); + $media->put('/pages/test.jpg', $file); + Event::fire('media.file.upload', [null, '/pages/test.jpg', null]); + + $this->assertFileCount(0, ''); + } + + public function testCopyOriginalFileWithoutSizesWhenNoSizesAreSet(): void + { + Setting::set('folders', [['folder' => '/pages']]); + Setting::set('sizes', []); + Setting::set('breakpoints', []); + + $file = UploadedFile::fake()->image('test.jpg', 500, 600); + $media = MediaLibrary::instance(); + $media->put('/pages/test.jpg', $file->get()); + Event::fire('media.file.upload', [null, '/pages/test.jpg', null]); + + $this->assertFileCount(2, 'pages'); + $this->assertHasFile('pages/test.jpg'); + $this->assertHasFile('pages/test-500x600.jpg'); + } + + public function testCopyTwoDirectoriesDeepButNotAnotherDirectory(): void + { + Setting::set('folders', [['folder' => '/pages']]); + Setting::set('sizes', []); + Setting::set('breakpoints', []); + + $file = UploadedFile::fake()->image('test.jpg', 500, 600); + $media = MediaLibrary::instance(); + $media->put('/pages-neu/test.jpg', $file->get()); + Event::fire('media.file.upload', [null, '/pages-neu/test.jpg', null]); + + $this->assertFileCount(0, 'pages'); + $this->assertFileCount(0, 'pages-neu'); + } + + public function testCopySubdirectoriesOfSelectedPath(): void + { + Setting::set('folders', [['folder' => '/pages']]); + Setting::set('sizes', []); + Setting::set('breakpoints', []); + + $file = UploadedFile::fake()->image('test.jpg', 500, 600); + $media = MediaLibrary::instance(); + $media->put('/pages/neu/test.jpg', $file->get()); + Event::fire('media.file.upload', [null, '/pages/neu/test.jpg', null]); + + $this->assertFileCount(2, 'pages/neu'); + } + + public function testGenerateSizeIfSizeIsSmallerWithSameAspectRatio(): void + { + Setting::set('folders', [['folder' => '/pages']]); + Setting::set('sizes', []); + Setting::set('breakpoints', ['250']); + + $file = UploadedFile::fake()->image('test.jpg', 500, 600); + $media = MediaLibrary::instance(); + $media->put('/pages/neu/test.jpg', $file->get()); + Event::fire('media.file.upload', [null, '/pages/neu/test.jpg', null]); + + $this->assertFileCount(3, 'pages/neu'); + $this->assertHasFile('pages/neu/test-250x300.jpg'); + $this->assertHasFile('pages/neu/test-500x600.jpg'); + $this->assertHasFile('pages/neu/test.jpg'); + } + + public function testGenerateSizeIfSizeIsSmallerWithDifferentAspectRatio(): void + { + Setting::set('folders', [['folder' => '/pages']]); + Setting::set('sizes', []); + Setting::set('breakpoints', ['250']); + + $file = UploadedFile::fake()->image('test.jpg', 500, 100); + $media = MediaLibrary::instance(); + $media->put('/pages/neu/test.jpg', $file->get()); + Event::fire('media.file.upload', [null, '/pages/neu/test.jpg', null]); + + $this->assertHasFile('pages/neu/test-250x50.jpg'); + } + + public function testDontGenerateSizeIfOriginalFileIsSmaller(): void + { + Setting::set('folders', [['folder' => '/pages']]); + Setting::set('sizes', []); + Setting::set('breakpoints', ['250']); + + $file = UploadedFile::fake()->image('test.jpg', 250, 1000); + $media = MediaLibrary::instance(); + $media->put('/pages/neu/test.jpg', $file->get()); + Event::fire('media.file.upload', [null, '/pages/neu/test.jpg', null]); + + $this->assertFileCount(2, 'pages/neu'); + } + + public function testDontGenerateSizeIfImageWouldBeLarger(): void + { + Setting::set('folders', [['folder' => '/pages']]); + Setting::set('sizes', []); + Setting::set('breakpoints', ['250']); + + $file = UploadedFile::fake()->image('test.jpg', 249, 1000); + $media = MediaLibrary::instance(); + $media->put('/pages/neu/test.jpg', $file->get()); + Event::fire('media.file.upload', [null, '/pages/neu/test.jpg', null]); + + $this->assertFileCount(2, 'pages/neu'); + } + + public function testGenerateBreakpointImage(): void + { + Setting::set('folders', [['folder' => '/pages']]); + Setting::set('sizes', [['name' => 'testas', 'aspect_ratio' => '5x8']]); + Setting::set('breakpoints', ['100']); + + $file = UploadedFile::fake()->image('test.jpg', 500, 400); + $media = MediaLibrary::instance(); + $media->put('/pages/neu/test.jpg', $file->get()); + Event::fire('media.file.upload', [null, '/pages/neu/test.jpg', null]); + + $this->assertFileCount(5, 'pages/neu'); + $this->assertHasFile('pages/neu/test.jpg'); + $this->assertHasFile('pages/neu/test-500x400.jpg'); + $this->assertHasFile('pages/neu/test-250x400.jpg'); + $this->assertHasFile('pages/neu/test-100x160.jpg'); + $this->assertHasFile('pages/neu/test-100x80.jpg'); + } + +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..649a924 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,41 @@ +media = MediaLibrary::instance(); + } + + public function assertHasFile($file): void + { + Storage::disk('local')->assertExists("uploads/public/c/{$this->normalizeFilePath($file)}"); + } + + public function assertDoesntHaveFile($file): void + { + Storage::disk('local')->assertMissing("uploads/public/c/{$this->normalizeFilePath($file)}"); + } + + public function assertFileCount($count, $dir): void + { + $this->assertCount($count, Storage::disk('local')->files("uploads/public/c/{$this->normalizeFilePath($dir)}")); + } + + public function normalizeFilePath(string $path): string + { + return preg_replace('|^/*|', '', $path); + } +} diff --git a/tests/stub/img.jpg b/tests/stub/img.jpg new file mode 100644 index 0000000000000000000000000000000000000000..c9dd7a52ef23075831d4a0d42f25751318fe4d94 GIT binary patch literal 133339 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 literal 0 HcmV?d00001