diff --git a/lib/GaletteObjectsLend/Entity/LendCategory.php b/lib/GaletteObjectsLend/Entity/LendCategory.php index ffa2661..ff19e17 100644 --- a/lib/GaletteObjectsLend/Entity/LendCategory.php +++ b/lib/GaletteObjectsLend/Entity/LendCategory.php @@ -154,6 +154,11 @@ public function delete(): void if ($need_transaction) { $this->zdb->beginTransaction(); } + //a picture file removed here comes back from the database on rollback + $picture = new CategoryPicture($this->category_id); + if ($picture->hasPicture() && !$picture->delete(false)) { + throw new \RuntimeException('Unable to remove picture'); + } $select = $this->zdb->select(LEND_PREFIX . LendObject::TABLE) ->where(['category_id' => $this->category_id]); $results = $this->zdb->execute($select); diff --git a/lib/GaletteObjectsLend/Entity/LendObject.php b/lib/GaletteObjectsLend/Entity/LendObject.php index dc60ad7..9f9b09c 100644 --- a/lib/GaletteObjectsLend/Entity/LendObject.php +++ b/lib/GaletteObjectsLend/Entity/LendObject.php @@ -200,6 +200,11 @@ public function delete(): void if ($need_transaction) { $this->zdb->beginTransaction(); } + //a picture file removed here comes back from the database on rollback + $picture = $this->getPicture(); + if ($picture->hasPicture() && !$picture->delete(false)) { + throw new \RuntimeException('Unable to remove picture'); + } //remove rents $update = $this->zdb->update(LEND_PREFIX . self::TABLE) ->set([LendRent::PK => null]) diff --git a/lib/GaletteObjectsLend/Entity/Picture.php b/lib/GaletteObjectsLend/Entity/Picture.php index 51fff85..d726f2f 100644 --- a/lib/GaletteObjectsLend/Entity/Picture.php +++ b/lib/GaletteObjectsLend/Entity/Picture.php @@ -12,10 +12,16 @@ use Analog\Analog; use GaletteObjectsLend\LendPreferences; -use Psr\Http\Message\UploadedFileInterface; use Slim\Psr7\Response; use Slim\Psr7\Stream; +use function Safe\file_get_contents; +use function Safe\fopen; +use function Safe\fwrite; +use function Safe\getimagesize; +use function Safe\rewind; +use function Safe\unlink; + /** * Picture handling * @@ -24,46 +30,13 @@ */ class Picture extends \Galette\Core\Picture { + protected string $tbl_prefix = LEND_PREFIX; protected int $max_width = 800; protected int $max_height = 800; - protected int $thumb_max_width; - protected int $thumb_max_height; - - protected int $thumb_optimal_height; - protected int $thumb_optimal_width; - - /** - * Default constructor. - * - * @param mixed|null $objectid Object id - */ - public function __construct(mixed $objectid = null) - { - $this->tbl_prefix = LEND_PREFIX; - - if (!file_exists($this->store_path)) { - if (!mkdir($this->store_path, 0o755, true)) { - Analog::log( - 'Unable to create photo dir `' . $this->store_path . '`.', - Analog::ERROR - ); - } else { - Analog::log( - 'New directory `' . $this->store_path . '` has been created', - Analog::INFO - ); - } - } elseif (!is_dir($this->store_path)) { - Analog::log( - 'Unable to store plugin images, since `' . $this->store_path - . '` is not a directory.', - Analog::WARNING - ); - } - - parent::__construct($objectid); - } + private string $thumb_path; + private int $thumb_optimal_height; + private int $thumb_optimal_width; /** * Set maximum size of an uploaded picture @@ -78,17 +51,13 @@ public function setMaxLength(int $maxlength): self /** * Gets the default picture to show, anyway - * - * @see Logo::getDefaultPicture() */ protected function getDefaultPicture(): void { - $this->file_path = (string)realpath( - __DIR__ . '/../../../webroot/images/1f5bc.png' - ); $this->format = 'png'; $this->mime = 'image/png'; $this->has_picture = false; + $this->setDefaultPath(__DIR__ . '/../../../webroot/images/1f5bc.png'); } /** @@ -107,115 +76,12 @@ public function displayThumb(Response $response, LendPreferences $prefs): Respon ->withHeader('Pragma', 'public'); $stream = fopen('php://memory', 'r+'); - fwrite($stream, file_get_contents($this->getThumbPath())); + fwrite($stream, file_get_contents($this->thumb_path)); rewind($stream); return $response->withBody(new Stream($stream)); } - /** - * Create thumbnail image - * @see \Galette\Core\Picture::resizeImage() - * - * @param string $source the source image - * @param string $ext file's extension - * @param ?string $dest the destination image. - * If null, we'll use the source image. Defaults to null - */ - private function createThumb(string $source, string $ext, ?string $dest = null): bool - { - $class = get_class($this); - - if (function_exists("gd_info")) { - $gdinfo = gd_info(); - $h = $this->thumb_max_height; - $w = $this->thumb_max_width; - if ($dest == null) { - $dest = $source; - } - - switch (strtolower($ext)) { - case 'jpg': - if (!$gdinfo['JPEG Support']) { - Analog::log( - '[' . $class . '] GD has no JPEG Support - ' - . 'pictures could not be resized!', - Analog::ERROR - ); - return false; - } - break; - case 'png': - if (!$gdinfo['PNG Support']) { - Analog::log( - '[' . $class . '] GD has no PNG Support - ' - . 'pictures could not be resized!', - Analog::ERROR - ); - return false; - } - break; - case 'gif': - if (!$gdinfo['GIF Create Support']) { - Analog::log( - '[' . $class . '] GD has no GIF Support - ' - . 'pictures could not be resized!', - Analog::ERROR - ); - return false; - } - break; - default: - return false; - } - - [$cur_width, $cur_height] = getimagesize($source); - - $ratio = $cur_width / $cur_height; - - // calculate image size according to ratio - if ($cur_width > $cur_height) { - $h = (int)($w / $ratio); - } else { - $w = (int)($h * $ratio); - } - - $thumb = imagecreatetruecolor($w, $h); - switch ($ext) { - case 'jpg': - $image = imagecreatefromjpeg($source); - imagecopyresampled($thumb, $image, 0, 0, 0, 0, $w, $h, $cur_width, $cur_height); - imagejpeg($thumb, $dest); - break; - case 'png': - $image = imagecreatefrompng($source); - // Turn off alpha blending and set alpha flag. That prevent alpha - // transparency to be saved as an arbitrary color (black in my tests) - imagealphablending($thumb, false); - imagealphablending($image, false); - imagesavealpha($thumb, true); - imagesavealpha($image, true); - imagecopyresampled($thumb, $image, 0, 0, 0, 0, $w, $h, $cur_width, $cur_height); - imagepng($thumb, $dest); - break; - case 'gif': - $image = imagecreatefromgif($source); - imagecopyresampled($thumb, $image, 0, 0, 0, 0, $w, $h, $cur_width, $cur_height); - imagegif($thumb, $dest); - break; - } - - return true; - } else { - Analog::log( - '[' . $class . '] GD is not present - ' - . 'pictures could not be resized!', - Analog::ERROR - ); - return false; - } - } - /** * Deletes a picture, from both database and filesystem * @@ -225,101 +91,88 @@ private function createThumb(string $source, string $ext, ?string $dest = null): */ public function delete(bool $transaction = true): bool { - //find and delete any thumb - $ext = pathinfo($this->file_path, PATHINFO_EXTENSION); - $filename = substr($this->file_path, 0, strlen($this->file_path) - strlen($ext) - 1); - - $thumb = $filename . '_th.' . $ext; - - if (file_exists($thumb)) { - unlink($thumb); + //default picture thumbnail is shared + if ($this->has_picture) { + $thumb = $this->getThumbPath(); + if (is_file($thumb)) { + unlink($thumb); + } } return parent::delete($transaction); } - /** - * Stores an image on the disk and in the database - * - * @param UploadedFileInterface $file The uploaded file - * @param ?array $cropping Cropping properties - * - * @return true|int - */ - public function storeFile(UploadedFileInterface $file, ?array $cropping = null): bool|int - { - $ext = pathinfo($this->file_path, PATHINFO_EXTENSION); - $filename = substr($this->file_path, 0, strlen($this->file_path) - strlen($ext) - 1); - $thumb = $filename . '_th.' . $ext; - - if (is_file($thumb)) { - unlink($thumb); - } - - return parent::storeFile($file, $cropping); - } - /** * Get thumbnail file path */ - public function getThumbPath(): string + private function getThumbPath(): string { - if ($this->has_picture) { - $ext = pathinfo($this->file_path, PATHINFO_EXTENSION); - $filename = substr($this->file_path, 0, strlen($this->file_path) - strlen($ext) - 1); - $filename .= '_th.' . $ext; - } else { - $this->getDefaultPicture(); - $infos = pathinfo($this->file_path); - $filename = $this->store_path . '/' . $infos['filename'] . '_th' . '.' . $infos['extension']; - } - return $filename; + $infos = pathinfo($this->getPath()); + return $this->store_path . $infos['filename'] . '_th.' . ($infos['extension'] ?? ''); } /** - * Set picture thumbnail sizes - * - * Should override Picture::setSize(), but this one is private :/ + * Set picture thumbnail sizes, (re)create thumbnail if needed * * @param LendPreferences $prefs Plugin preferences */ private function setThumbSizes(LendPreferences $prefs): void { + $source = $this->getPath(); $thumb = $this->getThumbPath(); - $this->thumb_max_width = $prefs->getThumbWidth(); - $this->thumb_max_height = $prefs->getThumbHeight(); + $max_width = $prefs->getThumbWidth(); + $max_height = $prefs->getThumbHeight(); - // Create if missing - if (!is_file($thumb)) { - $ext = pathinfo($this->file_path, PATHINFO_EXTENSION); - $this->createThumb($this->file_path, $ext, $thumb); + //same computation as resizeImage() + $ratio = $this->getWidth() / $this->getHeight(); + if ($this->getWidth() > $this->getHeight()) { + $expected = [$max_width, (int)round($max_width / $ratio)]; } else { - //resize if too small/large - if (function_exists("gd_info")) { - [$cur_width, $cur_height] = getimagesize($thumb); + $expected = [(int)round($max_height * $ratio), $max_height]; + } - if ( - $cur_height != $this->getOptimalHeight() - && $cur_height < $this->thumb_max_height - && $cur_width != $this->getOptimalWidth() - && $cur_width < $this->thumb_max_width - || $cur_width > $this->thumb_max_width - || $cur_height > $this->thumb_max_height - ) { - Analog::log( - 'Picture thumbnail must be generated again.', - Analog::INFO - ); - unlink($thumb); - $ext = pathinfo($this->file_path, PATHINFO_EXTENSION); - $this->createThumb($this->file_path, $ext, $thumb); - } + if (is_file($thumb)) { + [$width, $height] = getimagesize($thumb); + if ([$width, $height] !== $expected) { + Analog::log('Picture thumbnail must be generated again.', Analog::INFO); + unlink($thumb); } } + if ( + !is_file($thumb) + && ( + !$this->ensureStorePath() + || !$this->resizeImage( + source: $source, + ext: strtolower(pathinfo($source, PATHINFO_EXTENSION)), + dest: $thumb, + max_width: $max_width, + max_height: $max_height + ) + ) + ) { + Analog::log('Unable to create thumbnail for ' . $source . ', using picture itself.', Analog::WARNING); + $thumb = $source; + } + [$width, $height] = getimagesize($thumb); - $this->thumb_optimal_height = (int)$height; - $this->thumb_optimal_width = (int)$width; + $this->thumb_path = $thumb; + $this->thumb_optimal_width = $width; + $this->thumb_optimal_height = $height; + } + + /** + * Get thumbnail path, created if needed; picture itself if it cannot be created + * + * @param LendPreferences $prefs Plugin preferences + */ + public function getThumb(LendPreferences $prefs): string + { + if (!isset($this->thumb_path)) { + $this->setThumbSizes($prefs); + } + return $this->thumb_path; } /** @@ -334,7 +187,7 @@ public function getOptimalThumbHeight(LendPreferences $prefs): int if (!isset($this->thumb_optimal_height)) { $this->setThumbSizes($prefs); } - return (int)round($this->thumb_optimal_height, 1); + return $this->thumb_optimal_height; } /** @@ -349,14 +202,6 @@ public function getOptimalThumbWidth(LendPreferences $prefs): int if (!isset($this->thumb_optimal_width)) { $this->setThumbSizes($prefs); } - return (int)round($this->thumb_optimal_width, 1); - } - - /** - * Get storage directory - */ - public function getDir(): string - { - return $this->store_path; + return $this->thumb_optimal_width; } } diff --git a/lib/GaletteObjectsLend/IO/PdfObject.php b/lib/GaletteObjectsLend/IO/PdfObject.php index 11ed93c..e33cb55 100644 --- a/lib/GaletteObjectsLend/IO/PdfObject.php +++ b/lib/GaletteObjectsLend/IO/PdfObject.php @@ -116,7 +116,7 @@ public function drawCard(LendObject $object): void $hpic = (int)round($wpic / $ratio); } - $this->Image($pic->getThumbPath(), 10, 10, $wpic, $hpic); + $this->Image($pic->getThumb($this->lprefs), 10, 10, $wpic, $hpic); } $this->addCell(_T("Name", "objectslend"), $object->getName(), $wpic); diff --git a/lib/GaletteObjectsLend/Repository/Objects.php b/lib/GaletteObjectsLend/Repository/Objects.php index 7324e01..c7d8c7d 100644 --- a/lib/GaletteObjectsLend/Repository/Objects.php +++ b/lib/GaletteObjectsLend/Repository/Objects.php @@ -19,6 +19,7 @@ use GaletteObjectsLend\Entity\LendObject; use GaletteObjectsLend\Entity\LendRent; use GaletteObjectsLend\Entity\LendStatus; +use GaletteObjectsLend\Entity\ObjectPicture; use GaletteObjectsLend\LendPreferences; use GaletteObjectsLend\Filters\ObjectsList; use Laminas\Db\ResultSet\ResultSet; @@ -119,6 +120,14 @@ public function removeObjects(array $ids): void $this->zdb->beginTransaction(); } + //a picture file removed here comes back from the database on rollback + foreach ($ids as $id) { + $picture = new ObjectPicture($id); + if ($picture->hasPicture() && !$picture->delete(false)) { + throw new \RuntimeException('Unable to remove picture for object #' . $id); + } + } + $update = $this->zdb->update(LEND_PREFIX . self::TABLE); $update->set(['rent_id' => null]); $update->where->in(self::PK, $ids); diff --git a/tests/GaletteObjectsLend/Entity/tests/units/Picture.php b/tests/GaletteObjectsLend/Entity/tests/units/Picture.php new file mode 100644 index 0000000..7f94bf3 --- /dev/null +++ b/tests/GaletteObjectsLend/Entity/tests/units/Picture.php @@ -0,0 +1,220 @@ + + */ +class Picture extends GaletteTestCase +{ + protected int $seed = 20260924183512; + protected bool $load_plugins = true; + + /** @var string[] */ + private array $files = []; + + /** + * Cleanup after each test method + */ + public function tearDown(): void + { + $delete = $this->zdb->delete(LEND_PREFIX . LendObject::TABLE); + $this->zdb->execute($delete); + + $delete = $this->zdb->delete(LEND_PREFIX . LendCategory::TABLE); + $this->zdb->execute($delete); + + foreach ($this->files as $file) { + if (is_file($file)) { + unlink($file); + } + } + + parent::tearDown(); + } + + /** + * Create an object + */ + private function createObject(): LendObject + { + $object = new LendObject($this->zdb); + $object->setName('An object'); + $object->store(); + return $object; + } + + /** + * Store a picture, a 800x400 JPEG + * + * @param ObjectPicture|CategoryPicture $picture Picture + * @param int $id Object or category identifier + */ + private function storePicture(ObjectPicture|CategoryPicture $picture, int $id): void + { + $source = sys_get_temp_dir() . '/objectslend-upload-' . uniqid() . '.jpg'; + copy(GALETTE_ROOT . '../tests/fake_image.jpg', $source); + $this->files[] = $source; + + $uploaded_file = new \Slim\Psr7\UploadedFile( + fileNameOrStream: $source, + name: 'fake_image.jpg', + type: 'image/jpeg', + size: filesize($source), + error: UPLOAD_ERR_OK + ); + $this->assertTrue($picture->storeFile($uploaded_file)); + $this->expectLogEntry(\Analog\Analog::ERROR, 'Unable to remove picture database entry for ' . $id); + } + + /** + * Test thumbnail creation, and removal of files with the object + */ + public function testThumbnail(): void + { + $prefs = new LendPreferences($this->preferences); + $object = $this->createObject(); + $id = $object->getId(); + $this->storePicture(new ObjectPicture($id), $id); + + $store_path = GALETTE_PHOTOS_PATH . 'objectslend/objects/'; + $file = $store_path . $id . '.jpg'; + $thumb = $store_path . $id . '_th.jpg'; + $this->files[] = $file; + $this->files[] = $thumb; + + $picture = new ObjectPicture($id); + $this->assertTrue($picture->hasPicture()); + $this->assertFalse(is_file($thumb)); + $this->assertSame(128, $picture->getOptimalThumbWidth($prefs)); + $this->assertSame(64, $picture->getOptimalThumbHeight($prefs)); + $this->assertSame(realpath($thumb), realpath($picture->getThumb($prefs))); + [$width, $height] = getimagesize($thumb); + $this->assertSame([128, 64], [$width, $height]); + + //thumbnail is generated again when preferences change + $this->assertTrue($this->preferences->setValue(LendPreferences::THUMB_MAX_WIDTH, 64, $this->login)); + $picture = new ObjectPicture($id); + $this->assertSame(64, $picture->getOptimalThumbWidth($prefs)); + $this->assertSame(32, $picture->getOptimalThumbHeight($prefs)); + $this->expectLogEntry(\Analog\Analog::INFO, 'Picture thumbnail must be generated again.'); + [$width, $height] = getimagesize($thumb); + $this->assertSame([64, 32], [$width, $height]); + + $response = $picture->displayThumb(new \Slim\Psr7\Response(), $prefs); + $this->assertSame('image/jpeg', $response->getHeaderLine('Content-Type')); + $this->assertSame(\Safe\file_get_contents($thumb), (string)$response->getBody()); + + //files are removed with the object + $object->delete(); + $this->assertFalse(is_file($file)); + $this->assertFalse(is_file($thumb)); + $this->assertFalse((new ObjectPicture($id))->hasPicture()); + } + + /** + * Test thumbnail of the default picture + */ + public function testDefaultThumbnail(): void + { + $prefs = new LendPreferences($this->preferences); + $object = $this->createObject(); + + $picture = $object->getPicture(); + $this->assertFalse($picture->hasPicture()); + + //default picture is 512x512 + $this->assertSame(128, $picture->getOptimalThumbWidth($prefs)); + $this->assertSame(128, $picture->getOptimalThumbHeight($prefs)); + $this->assertSame( + realpath(GALETTE_PHOTOS_PATH . 'objectslend/objects/1f5bc_th.png'), + realpath($picture->getThumb($prefs)) + ); + + //default thumbnail is shared, it is kept + $object->delete(); + $this->assertTrue(is_file(GALETTE_PHOTOS_PATH . 'objectslend/objects/1f5bc_th.png')); + } + + /** + * Test files are removed with objects removed from the list + */ + public function testRemoveObjects(): void + { + $prefs = new LendPreferences($this->preferences); + $ids = []; + for ($i = 0; $i < 2; $i++) { + $id = $this->createObject()->getId(); + $this->storePicture(new ObjectPicture($id), $id); + (new ObjectPicture($id))->getThumb($prefs); + $ids[] = $id; + } + $id = $this->createObject()->getId(); + $ids[] = $id; + + $store_path = GALETTE_PHOTOS_PATH . 'objectslend/objects/'; + foreach ($ids as $id) { + $this->files[] = $store_path . $id . '.jpg'; + $this->files[] = $store_path . $id . '_th.jpg'; + } + $this->assertTrue(is_file($store_path . $ids[0] . '_th.jpg')); + + $objects = new Objects($this->zdb, $this->preferences, $this->login, $prefs); + $objects->removeObjects($ids); + + foreach ($ids as $id) { + $this->assertFalse(is_file($store_path . $id . '.jpg')); + $this->assertFalse(is_file($store_path . $id . '_th.jpg')); + } + } + + /** + * Test files are removed with the category + */ + public function testCategoryDelete(): void + { + $prefs = new LendPreferences($this->preferences); + $category = new LendCategory($this->zdb); + $category->setName('A category'); + $category->store(); + $id = $category->getId(); + $this->storePicture(new CategoryPicture($id), $id); + + $store_path = GALETTE_PHOTOS_PATH . 'objectslend/categories/'; + $file = $store_path . $id . '.jpg'; + $thumb = $store_path . $id . '_th.jpg'; + $this->files[] = $file; + $this->files[] = $thumb; + + (new CategoryPicture($id))->getThumb($prefs); + $this->assertTrue(is_file($thumb)); + + $category->delete(); + $this->assertFalse(is_file($file)); + $this->assertFalse(is_file($thumb)); + $this->assertFalse((new CategoryPicture($id))->hasPicture()); + } +}