From 7fe35d860debc5b2a0fd3f94963f1b81112c5c87 Mon Sep 17 00:00:00 2001 From: Nicolas Lemoine Date: Fri, 1 May 2026 11:09:09 +0200 Subject: [PATCH] Stop GD insert filling watermark bbox with black insertTransparent() copied the base region into an opaque-black scratch canvas, layered the watermark on top, and merged it back with imagecopymerge(). Two problems: the black fill stuck wherever the base was transparent, so the watermark's bbox came out black on alpha PNGs; and imagecopymerge ignores source alpha, so partial watermark alpha was lost on the way back. Build a faded copy of the watermark by scaling each pixel's alpha by the transparency factor, then composite with imagecopy(). GD does the blending instead of imagecopymerge, so transparent base pixels stay put and partial watermark alpha survives. Regression test inserts circle.png on a 50x50 transparent canvas at transparency=0.5 and asserts the watermark's transparent corner is still transparent after insert. Also drop the unused CanConvertRange trait and the trailing imagedestroy() call (no-op since PHP 8.0). --- src/Drivers/Gd/Modifiers/InsertModifier.php | 78 ++++++++----------- .../Gd/Modifiers/InsertModifierTest.php | 40 ++++++++++ 2 files changed, 74 insertions(+), 44 deletions(-) diff --git a/src/Drivers/Gd/Modifiers/InsertModifier.php b/src/Drivers/Gd/Modifiers/InsertModifier.php index fad79845..a0f4c6a2 100644 --- a/src/Drivers/Gd/Modifiers/InsertModifier.php +++ b/src/Drivers/Gd/Modifiers/InsertModifier.php @@ -5,19 +5,15 @@ namespace Intervention\Image\Drivers\Gd\Modifiers; use Intervention\Image\Exceptions\ModifierException; -use Intervention\Image\Exceptions\RuntimeException; use Intervention\Image\Exceptions\StateException; use Intervention\Image\Interfaces\FrameInterface; use Intervention\Image\Interfaces\ImageInterface; use Intervention\Image\Interfaces\PointInterface; use Intervention\Image\Interfaces\SpecializedInterface; use Intervention\Image\Modifiers\InsertModifier as GenericInsertModifier; -use Intervention\Image\Traits\CanConvertRange; class InsertModifier extends GenericInsertModifier implements SpecializedInterface { - use CanConvertRange; - /** * {@inheritdoc} * @@ -64,66 +60,60 @@ private function insertOpaque(FrameInterface $frame, ImageInterface $watermark, } /** - * Insert watermark transparent with current transparency + * Insert watermark with the given partial transparency. * - * Unfortunately, the original PHP function imagecopymerge does not work reliably. - * For example, any transparency of the image to be inserted is not applied correctly. - * For this reason, a new GDImage is created into which the original image is inserted - * in the first step and the watermark is inserted with 100% opacity in the second - * step. This combination is then transferred to the original image again with the - * respective opacity. + * The previous implementation copied the base region into an opaque black + * scratch canvas and then used imagecopymerge() to blend the watermark + * back. That worked for opaque base images but had two known failures: + * imagecopymerge() does not preserve source alpha, and any transparent + * pixel in the base region was overwritten with opaque black before the + * merge ran, so the watermark's bounding box ended up filled with black + * wherever the base used to be transparent. * - * Please note: Unfortunately, there is still an edge case, when a transparent image - * is inserted on a transparent background, the "double" transparent areas appear opaque! + * Instead, build a faded copy of the watermark by scaling each pixel's + * alpha by the requested transparency factor, then composite that copy + * with imagecopy() relying on the destination's alpha blending. The base + * image's transparent regions stay transparent and partial alpha in the + * watermark is preserved. * * @throws ModifierException */ private function insertTransparent(FrameInterface $frame, ImageInterface $watermark, PointInterface $position): void { - $cut = imagecreatetruecolor($watermark->width(), $watermark->height()); + $width = $watermark->width(); + $height = $watermark->height(); + + $faded = imagecreatetruecolor($width, $height); - if ($cut === false) { + if ($faded === false) { throw new ModifierException('Failed to insert image'); } - imagecopy( - $cut, - $frame->native(), - 0, - 0, - $position->x(), - $position->y(), - imagesx($cut), - imagesy($cut) - ); + imagealphablending($faded, false); + imagesavealpha($faded, true); - imagecopy( - $cut, - $watermark->core()->native(), - 0, - 0, - 0, - 0, - imagesx($cut), - imagesy($cut) - ); + $watermarkNative = $watermark->core()->native(); - try { - $transparency = (int) round(self::convertRange($this->transparency, 0, 1, 0, 100)); - } catch (RuntimeException $e) { - throw new ModifierException('Failed to convert transparency', previous: $e); + for ($y = 0; $y < $height; $y++) { + for ($x = 0; $x < $width; $x++) { + $color = imagecolorat($watermarkNative, $x, $y); + $alpha = ($color >> 24) & 0x7F; + // GD stores alpha as 0 (opaque) … 127 (transparent), so scale + // the opacity (127 - alpha) and flip back to GD's convention. + $newAlpha = 127 - (int) round((127 - $alpha) * $this->transparency); + imagesetpixel($faded, $x, $y, ($newAlpha << 24) | ($color & 0xFFFFFF)); + } } - imagecopymerge( + imagecopy( $frame->native(), - $cut, + $faded, $position->x(), $position->y(), 0, 0, - $watermark->width(), - $watermark->height(), - $transparency, + $width, + $height, ); } } diff --git a/tests/Unit/Drivers/Gd/Modifiers/InsertModifierTest.php b/tests/Unit/Drivers/Gd/Modifiers/InsertModifierTest.php index 83071a8e..b8777c00 100644 --- a/tests/Unit/Drivers/Gd/Modifiers/InsertModifierTest.php +++ b/tests/Unit/Drivers/Gd/Modifiers/InsertModifierTest.php @@ -5,6 +5,10 @@ namespace Intervention\Image\Tests\Unit\Drivers\Gd\Modifiers; use Intervention\Image\Alignment; +use Intervention\Image\Drivers\Gd\Core; +use Intervention\Image\Drivers\Gd\Driver; +use Intervention\Image\Drivers\Gd\Frame; +use Intervention\Image\Image; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\RequiresPhpExtension; use Intervention\Image\Modifiers\InsertModifier; @@ -41,4 +45,40 @@ public function testColorChangeTransparencyJpeg(): void $image->modify(new InsertModifier(Resource::create('exif.jpg')->path(), transparency: .5)); $this->assertColor(127, 83, 127, 255, $image->colorAt(10, 10), tolerance: 1); } + + public function testInsertWithTransparencyKeepsTransparentBaseTransparent(): void + { + $image = $this->createTransparentBase(50, 50); + $this->assertTransparency($image->colorAt(0, 0)); + + $image->modify(new InsertModifier( + Resource::create('circle.png')->path(), + 0, + 0, + Alignment::TOP_LEFT, + .5, + )); + + // circle.png's (0, 0) is fully transparent. The previous + // imagecreatetruecolor + imagecopymerge path filled the whole + // watermark bbox with opaque black wherever the base was + // transparent. The corner must still be transparent. + $this->assertTransparency($image->colorAt(0, 0)); + } + + private function createTransparentBase(int $width, int $height): Image + { + $gd = imagecreatetruecolor($width, $height); + imagealphablending($gd, false); + imagesavealpha($gd, true); + $transparent = imagecolorallocatealpha($gd, 0, 0, 0, 127); + imagefilledrectangle($gd, 0, 0, $width - 1, $height - 1, $transparent); + + return new Image( + new Driver(), + new Core([ + new Frame($gd), + ]), + ); + } }