]> BookStack Code Mirror - bookstack/commitdiff
Merge pull request #5625 from BookStackApp/avif_images
authorDan Brown <redacted>
Fri, 23 May 2025 16:30:24 +0000 (17:30 +0100)
committerGitHub <redacted>
Fri, 23 May 2025 16:30:24 +0000 (17:30 +0100)
AVIF image support

app/Http/Controller.php
app/Uploads/ImageResizer.php
app/Uploads/ImageService.php
tests/Uploads/ImageTest.php
tests/test-data/animated.avif [new file with mode: 0644]

index 090cf523ad28051751f0cca3b325890cf99e7332..652e2ccf3bd47ad7f9926f7181af8cf673654860 100644 (file)
@@ -163,7 +163,7 @@ abstract class Controller extends BaseController
      */
     protected function getImageValidationRules(): array
     {
-        return ['image_extension', 'mimes:jpeg,png,gif,webp', 'max:' . (config('app.upload_limit') * 1000)];
+        return ['image_extension', 'mimes:jpeg,png,gif,webp,avif', 'max:' . (config('app.upload_limit') * 1000)];
     }
 
     /**
index 5f095658f3fc49412245c4fa826d35cb8a86865b..8d7571c82d6742d9b00b6a8fdddc5fe5ede161cc 100644 (file)
@@ -6,6 +6,7 @@ use BookStack\Exceptions\ImageUploadException;
 use Exception;
 use GuzzleHttp\Psr7\Utils;
 use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Log;
 use Intervention\Image\Decoders\BinaryImageDecoder;
 use Intervention\Image\Drivers\Gd\Decoders\NativeObjectDecoder;
 use Intervention\Image\Drivers\Gd\Driver;
@@ -93,8 +94,8 @@ class ImageResizer
 
         $imageData = $disk->get($imagePath);
 
-        // Do not resize apng images where we're not cropping
-        if ($keepRatio && $this->isApngData($image, $imageData)) {
+        // Do not resize animated images where we're not cropping
+        if ($keepRatio && $this->isAnimated($image, $imageData)) {
             Cache::put($thumbCacheKey, $image->path, static::THUMBNAIL_CACHE_TIME);
 
             return $this->storage->getPublicUrl($image->path);
@@ -240,15 +241,50 @@ class ImageResizer
     /**
      * Check if the given image and image data is apng.
      */
-    protected function isApngData(Image $image, string &$imageData): bool
+    protected function isApngData(string &$imageData): bool
     {
-        $isPng = strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'png';
-        if (!$isPng) {
+        $initialHeader = substr($imageData, 0, strpos($imageData, 'IDAT'));
+
+        return str_contains($initialHeader, 'acTL');
+    }
+
+    /**
+     * Check if the given avif image data represents an animated image.
+     * This is based up the answer here: https://stackoverflow.com/a/79457313
+     */
+    protected function isAnimatedAvifData(string &$imageData): bool
+    {
+        $stszPos = strpos($imageData, 'stsz');
+        if ($stszPos === false) {
             return false;
         }
 
-        $initialHeader = substr($imageData, 0, strpos($imageData, 'IDAT'));
+        // Look 12 bytes after the start of 'stsz'
+        $start = $stszPos + 12;
+        $end = $start + 4;
+        if ($end > strlen($imageData) - 1) {
+            return false;
+        }
 
-        return str_contains($initialHeader, 'acTL');
+        $data = substr($imageData, $start, 4);
+        $count = unpack('Nvalue', $data)['value'];
+        return $count > 1;
+    }
+
+    /**
+     * Check if the given image is animated.
+     */
+    protected function isAnimated(Image $image, string &$imageData): bool
+    {
+        $extension = strtolower(pathinfo($image->path, PATHINFO_EXTENSION));
+        if ($extension === 'png') {
+            return $this->isApngData($imageData);
+        }
+
+        if ($extension === 'avif') {
+            return $this->isAnimatedAvifData($imageData);
+        }
+
+        return false;
     }
 }
index 4d6d4919742546165dd347ccb98806469bfd3dd0..a8f1445173765d7384aa774d3b80654068741d0a 100644 (file)
@@ -13,7 +13,7 @@ use Symfony\Component\HttpFoundation\StreamedResponse;
 
 class ImageService
 {
-    protected static array $supportedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
+    protected static array $supportedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif'];
 
     public function __construct(
         protected ImageStorage $storage,
index 2c36f5f356ed575d546ea640cab9e00b0e78749a..a2f03df34d4f62d12d163ea23378811418f6e58d 100644 (file)
@@ -68,7 +68,20 @@ class ImageTest extends TestCase
         $this->files->deleteAtRelativePath($imgDetails['path']);
 
         $this->assertStringContainsString('thumbs-', $imgDetails['response']->thumbs->gallery);
-        $this->assertStringNotContainsString('thumbs-', $imgDetails['response']->thumbs->display);
+        $this->assertStringNotContainsString('scaled-', $imgDetails['response']->thumbs->display);
+    }
+
+    public function test_image_display_thumbnail_generation_for_animated_avif_images_uses_original_file()
+    {
+        $page = $this->entities->page();
+        $admin = $this->users->admin();
+        $this->actingAs($admin);
+
+        $imgDetails = $this->files->uploadGalleryImageToPage($this, $page, 'animated.avif');
+        $this->files->deleteAtRelativePath($imgDetails['path']);
+
+        $this->assertStringContainsString('thumbs-', $imgDetails['response']->thumbs->gallery);
+        $this->assertStringNotContainsString('scaled-', $imgDetails['response']->thumbs->display);
     }
 
     public function test_image_edit()
diff --git a/tests/test-data/animated.avif b/tests/test-data/animated.avif
new file mode 100644 (file)
index 0000000..92f7145
Binary files /dev/null and b/tests/test-data/animated.avif differ