Adds apng sniffing when generating thumbnails with retained ratios to
serve the original image files, as we do for GIF images, to prevent
the image being resized to a static version.
Is more tricky than GIF since apng file mimes and extensions
are the same as png, we have to detect part of the file header
to sniff the type. Means we have to sniff at a later stage
than GIF since we have to load the image file data.
Made some changes to the image thubmnail caching while doing
this work to fit in with this handling.
Added test to cover.
For #3136.
namespace BookStack\Uploads;
use BookStack\Exceptions\ImageUploadException;
namespace BookStack\Uploads;
use BookStack\Exceptions\ImageUploadException;
+use BookStack\Util\WebSafeMimeSniffer;
use ErrorException;
use Exception;
use Illuminate\Contracts\Cache\Repository as Cache;
use ErrorException;
use Exception;
use Illuminate\Contracts\Cache\Repository as Cache;
return strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'gif';
}
return strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'gif';
}
+ /**
+ * Check if the given image and image data is apng.
+ */
+ protected function isApngData(Image $image, string &$imageData): bool
+ {
+ $isPng = strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'png';
+ if (!$isPng) {
+ return false;
+ }
+
+ $initialHeader = substr($imageData, 0, strpos($imageData, 'IDAT'));
+ return strpos($initialHeader, 'acTL') !== false;
+ }
+
/**
* Get the thumbnail for an image.
* If $keepRatio is true only the width will be used.
/**
* Get the thumbnail for an image.
* If $keepRatio is true only the width will be used.
*/
public function getThumbnail(Image $image, ?int $width, ?int $height, bool $keepRatio = false): string
{
*/
public function getThumbnail(Image $image, ?int $width, ?int $height, bool $keepRatio = false): string
{
+ // Do not resize GIF images where we're not cropping
if ($keepRatio && $this->isGif($image)) {
return $this->getPublicUrl($image->path);
}
if ($keepRatio && $this->isGif($image)) {
return $this->getPublicUrl($image->path);
}
$imagePath = $image->path;
$thumbFilePath = dirname($imagePath) . $thumbDirName . basename($imagePath);
$imagePath = $image->path;
$thumbFilePath = dirname($imagePath) . $thumbDirName . basename($imagePath);
- if ($this->cache->has('images-' . $image->id . '-' . $thumbFilePath) && $this->cache->get('images-' . $thumbFilePath)) {
- return $this->getPublicUrl($thumbFilePath);
+ $thumbCacheKey = 'images::' . $image->id . '::' . $thumbFilePath;
+
+ // Return path if in cache
+ $cachedThumbPath = $this->cache->get($thumbCacheKey);
+ if ($cachedThumbPath) {
+ return $this->getPublicUrl($cachedThumbPath);
+ // If thumbnail has already been generated, serve that and cache path
$storage = $this->getStorageDisk($image->type);
if ($storage->exists($this->adjustPathForStorageDisk($thumbFilePath, $image->type))) {
$storage = $this->getStorageDisk($image->type);
if ($storage->exists($this->adjustPathForStorageDisk($thumbFilePath, $image->type))) {
+ $this->cache->put($thumbCacheKey, $thumbFilePath, 60 * 60 * 72);
return $this->getPublicUrl($thumbFilePath);
}
return $this->getPublicUrl($thumbFilePath);
}
- $thumbData = $this->resizeImage($storage->get($this->adjustPathForStorageDisk($imagePath, $image->type)), $width, $height, $keepRatio);
+ $imageData = $storage->get($this->adjustPathForStorageDisk($imagePath, $image->type));
+
+ // Do not resize apng images where we're not cropping
+ if ($keepRatio && $this->isApngData($image, $imageData)) {
+ $this->cache->put($thumbCacheKey, $image->path, 60 * 60 * 72);
+ return $this->getPublicUrl($image->path);
+ }
+ // If not in cache and thumbnail does not exist, generate thumb and cache path
+ $thumbData = $this->resizeImage($imageData, $width, $height, $keepRatio);
$this->saveImageDataInPublicSpace($storage, $this->adjustPathForStorageDisk($thumbFilePath, $image->type), $thumbData);
$this->saveImageDataInPublicSpace($storage, $this->adjustPathForStorageDisk($thumbFilePath, $image->type), $thumbData);
- $this->cache->put('images-' . $image->id . '-' . $thumbFilePath, $thumbFilePath, 60 * 60 * 72);
+ $this->cache->put($thumbCacheKey, $thumbFilePath, 60 * 60 * 72);
return $this->getPublicUrl($thumbFilePath);
}
return $this->getPublicUrl($thumbFilePath);
}
'application/json',
'application/octet-stream',
'application/pdf',
'application/json',
'application/octet-stream',
'application/pdf',
'image/bmp',
'image/jpeg',
'image/png',
'image/bmp',
'image/jpeg',
'image/png',
$this->assertEquals($originalFileSize, $displayFileSize, 'Display thumbnail generation should not increase image size');
}
$this->assertEquals($originalFileSize, $displayFileSize, 'Display thumbnail generation should not increase image size');
}
+ public function test_image_display_thumbnail_generation_for_apng_images_uses_original_file()
+ {
+ $page = Page::query()->first();
+ $admin = $this->getAdmin();
+ $this->actingAs($admin);
+
+ $imgDetails = $this->uploadGalleryImage($page, 'animated.png');
+ $this->deleteImage($imgDetails['path']);
+
+ $this->assertStringContainsString('thumbs-', $imgDetails['response']->thumbs->gallery);
+ $this->assertStringNotContainsString('thumbs-', $imgDetails['response']->thumbs->display);
+ }
+
public function test_image_edit()
{
$editor = $this->getEditor();
public function test_image_edit()
{
$editor = $this->getEditor();
use BookStack\Entities\Models\Page;
use Illuminate\Http\UploadedFile;
use BookStack\Entities\Models\Page;
use Illuminate\Http\UploadedFile;
*
* @param Page|null $page
*
*
* @param Page|null $page
*
+ * @return array{name: string, path: string, page: Page, response: stdClass}
*/
protected function uploadGalleryImage(Page $page = null, ?string $testDataFileName = null)
{
*/
protected function uploadGalleryImage(Page $page = null, ?string $testDataFileName = null)
{