Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 34 additions & 44 deletions src/Drivers/Gd/Modifiers/InsertModifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -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}
*
Expand Down Expand Up @@ -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,
);
}
}
40 changes: 40 additions & 0 deletions tests/Unit/Drivers/Gd/Modifiers/InsertModifierTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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),
]),
);
}
}