From ca4d21d7422aea9b304a51910d867b8b2bd6d203 Mon Sep 17 00:00:00 2001 From: Nicolas Lemoine Date: Tue, 7 Jul 2026 12:22:54 +0200 Subject: [PATCH] Preserve a transparent base when pasting with alpha on the GD driver Gd\Image::paste() routed 0 < alpha < 100 through imagecopymerge(), which ignores the source's per-pixel alpha and resolves every touched destination pixel as fully opaque. Pasting an overlay over a base that has transparency therefore washed the transparent regions into an opaque block (near-white for a typical PNG whose transparent pixels are stored white). Fade the overlay's own alpha channel by the opacity factor and paste it at full opacity through the alpha-aware imagecopy() path instead, so the base's transparency survives. Mirrors the Imagick change in #878. Add testPasteWithAlphaPreservesTransparentBase covering a transparent destination, companion to the existing opaque-base test. --- CHANGELOG.md | 2 ++ src/Gd/Image.php | 24 ++++++++++++- tests/tests/Image/AbstractImageTest.php | 46 +++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bbea396..09591e73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/src/Gd/Image.php b/src/Gd/Image.php index e9d69ffe..a42a2340 100644 --- a/src/Gd/Image.php +++ b/src/Gd/Image.php @@ -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'); } } diff --git a/tests/tests/Image/AbstractImageTest.php b/tests/tests/Image/AbstractImageTest.php index 3e7b0337..1409d727 100644 --- a/tests/tests/Image/AbstractImageTest.php +++ b/tests/tests/Image/AbstractImageTest.php @@ -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();