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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# CHANGELOG

- Fix the GD driver washing a transparent destination into an opaque block when pasting an image at an alpha lower than 100; the opacity now scales the overlay's per-pixel alpha and composites alpha-aware instead of routing through `imagecopymerge()` (#XXX, @nlemoine)

### 1.5.4 (2026-06-04)
- Fix imagick stale layers (#879, @nlemoine)
- Fix the Imagick driver deactivating the alpha channel in `effects()->grayscale()` (transparent pixels were encoded as opaque gray); it now uses the alpha-preserving grayscale image type, like `usePalette()` (#880, @nlemoine)
Expand Down
24 changes: 23 additions & 1 deletion src/Gd/Image.php
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,29 @@ final public function paste(ImageInterface $image, PointInterface $start, $alpha
throw new RuntimeException('Image paste operation failed');
}
} elseif ($alpha > 0) {
if (imagecopymerge(/*dst_im*/$this->resource, /*src_im*/ $image->resource, /*dst_x*/ $start->getX(), /*dst_y*/ $start->getY(), /*src_x*/ 0, /*src_y*/ 0, /*src_w*/ $size->getWidth(), /*src_h*/ $size->getHeight(), /*pct*/ $alpha) === false) {
// Fade the overlay's own alpha channel by the opacity factor, then
// paste it at full opacity through the alpha-aware imagecopy() path.
// Routing partial alpha through imagecopymerge() instead ignores the
// source's per-pixel alpha and resolves every touched destination
// pixel as fully opaque, which washes a transparent base into an
// opaque block. Mirrors Imagick\Image::paste().
$overlay = clone $image;

imagealphablending($overlay->resource, false);
for ($x = 0, $width = $size->getWidth(); $x < $width; $x++) {
for ($y = 0, $height = $size->getHeight(); $y < $height; $y++) {
$rgba = imagecolorat($overlay->resource, $x, $y);
$currentAlpha = ($rgba >> 24) & 0x7F;
$fadedAlpha = 127 - (int) round((127 - $currentAlpha) * $alpha / 100);
imagesetpixel($overlay->resource, $x, $y, ($rgba & 0xFFFFFF) | ($fadedAlpha << 24));
}
}

imagealphablending($this->resource, true);
$success = imagecopy($this->resource, $overlay->resource, $start->getX(), $start->getY(), 0, 0, $size->getWidth(), $size->getHeight());
imagealphablending($this->resource, false);

if ($success === false) {
throw new RuntimeException('Image paste operation failed');
}
}
Expand Down
46 changes: 46 additions & 0 deletions tests/tests/Image/AbstractImageTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1125,6 +1125,52 @@ public function testPasteWithAlphaPreservesTransparency()
$this->assertColorSimilar($palette->color(array(128, 0, 128)), $centre, 'opaque area was not blended', 2);
}

/**
* Companion to testPasteWithAlphaPreservesTransparency for the case where
* the *destination* has transparency. Pasting an overlay at an alpha lower
* than 100 over a transparent base must keep the base transparent: opacity
* has to scale the overlay's per-pixel alpha and composite alpha-aware. The
* GD driver used to route this through imagecopymerge(), which ignores the
* source alpha and forces every touched destination pixel fully opaque,
* washing a transparent base into an opaque block.
*/
public function testPasteWithAlphaPreservesTransparentBase()
{
try {
$this->getDriverInfo()->requireFeature(Info::FEATURE_TRANSPARENCY);
} catch (NotSupportedException $x) {
$this->markTestSkipped($x->getMessage());
}
$palette = new RGB();
$imagine = $this->getImagine();

// Destination: a fully-transparent canvas.
$destination = $imagine->create(new Box(20, 20), $palette->color(array(255, 255, 255), 0));

// Overlay: an opaque green square pasted over the top-left corner.
$overlay = $imagine->create(new Box(10, 10), $palette->color(array(0, 128, 0)));

try {
$destination->paste($overlay, new Point(0, 0), 40);
} catch (NotSupportedException $x) {
// e.g. Gmagick, which does not support pasting with alpha.
$this->markTestSkipped($x->getMessage());
}

// The area the overlay does not cover stays fully transparent.
$gap = $destination->getColorAt(new Point(15, 15));
$this->assertSame(0, $gap->getAlpha(), 'area outside the overlay must stay transparent');

// Under the overlay the base takes the overlay colour at ~40% opacity,
// NOT a fully-opaque block. The bug produced alpha=100 here (and washed
// the colour towards the base's stored RGB).
$covered = $destination->getColorAt(new Point(5, 5));
$this->assertColorSimilar($palette->color(array(0, 128, 0)), $covered, 'overlay colour was not preserved under the paste', 2, false);
$coveredAlpha = $covered->getAlpha();
$this->assertLessThan(100, $coveredAlpha, 'the transparent base was forced fully opaque (imagecopymerge ignores the source alpha)');
$this->assertTrue(abs($coveredAlpha - 40) <= 2, 'overlay opacity should scale the alpha to ~40%, got ' . $coveredAlpha);
}

public function testPasteOutOfBoundaries()
{
$imagine = $this->getImagine();
Expand Down
Loading