PHPUnit Test Suite Source

There is one test suite per method.

ImageBlendedColorAllocateTest.php

<?php

/**
 * Copyright (c) 2018–2026 Andrew G. Johnson <andrew@andrewgjohnson.com>
 * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
 * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
 * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
 * permit persons to whom the Software is furnished to do so, subject to the following conditions:
 * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
 * Software.
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
 * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
 * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
 * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 */

declare(strict_types=1);

namespace AndrewGJohnson\AgjGd\Tests;

use AndrewGJohnson\AgjGd;
use PHPUnit\Framework\TestCase;

class ImageBlendedColorAllocateTest extends TestCase
{
    private \GdImage $image;

    protected function setUp(): void
    {
        $this->image = imagecreatetruecolor(100, 100);
    }

    public function testEvenBlend(): void
    {
        $red    = imagecolorallocate($this->image, 0xFF, 0x00, 0x00);
        $yellow = imagecolorallocate($this->image, 0xFF, 0xFF, 0x00);
        $result = AgjGd::imageblendedcolorallocate($this->image, $red, $yellow);

        $this->assertNotFalse($result);

        $components = imagecolorsforindex($this->image, $result);

        $this->assertSame(255, $components['red']);
        $this->assertSame(128, $components['green']); // round(127.5) = 128
        $this->assertSame(0, $components['blue']);
        $this->assertSame(0, $components['alpha']);
    }

    public function testCustomRatioBlend(): void
    {
        $blue   = imagecolorallocate($this->image, 0x00, 0x00, 0xFF);
        $cyan   = imagecolorallocate($this->image, 0x00, 0xFF, 0xFF);
        $result = AgjGd::imageblendedcolorallocate($this->image, $blue, $cyan, 0.25);

        $this->assertNotFalse($result);

        $components = imagecolorsforindex($this->image, $result);

        $this->assertSame(0, $components['red']);
        $this->assertSame(191, $components['green']); // round(255 * 0.75) = 191
        $this->assertSame(255, $components['blue']);
        $this->assertSame(0, $components['alpha']);
    }

    public function testFullColor1Blend(): void
    {
        $red    = imagecolorallocate($this->image, 0xFF, 0x00, 0x00);
        $blue   = imagecolorallocate($this->image, 0x00, 0x00, 0xFF);
        $result = AgjGd::imageblendedcolorallocate($this->image, $red, $blue, 1.0);

        $this->assertNotFalse($result);

        $components = imagecolorsforindex($this->image, $result);

        $this->assertSame(255, $components['red']);
        $this->assertSame(0, $components['green']);
        $this->assertSame(0, $components['blue']);
        $this->assertSame(0, $components['alpha']);
    }

    public function testFullColor2Blend(): void
    {
        $red    = imagecolorallocate($this->image, 0xFF, 0x00, 0x00);
        $blue   = imagecolorallocate($this->image, 0x00, 0x00, 0xFF);
        $result = AgjGd::imageblendedcolorallocate($this->image, $red, $blue, 0.0);

        $this->assertNotFalse($result);

        $components = imagecolorsforindex($this->image, $result);

        $this->assertSame(0, $components['red']);
        $this->assertSame(0, $components['green']);
        $this->assertSame(255, $components['blue']);
        $this->assertSame(0, $components['alpha']);
    }

    public function testOutOfRangeOpacityFallsBackToEvenBlend(): void
    {
        $red    = imagecolorallocate($this->image, 0xFF, 0x00, 0x00);
        $yellow = imagecolorallocate($this->image, 0xFF, 0xFF, 0x00);
        $result = AgjGd::imageblendedcolorallocate($this->image, $red, $yellow, 1.5);

        $this->assertNotFalse($result);

        $components = imagecolorsforindex($this->image, $result);

        $this->assertSame(255, $components['red']);
        $this->assertSame(128, $components['green']); // falls back to 50/50, same as testEvenBlend
        $this->assertSame(0, $components['blue']);
        $this->assertSame(0, $components['alpha']);
    }

    public function testNegativeOpacityFallsBackToEvenBlend(): void
    {
        $red    = imagecolorallocate($this->image, 0xFF, 0x00, 0x00);
        $yellow = imagecolorallocate($this->image, 0xFF, 0xFF, 0x00);
        $result = AgjGd::imageblendedcolorallocate($this->image, $red, $yellow, -0.1);

        $this->assertNotFalse($result);

        $components = imagecolorsforindex($this->image, $result);

        $this->assertSame(255, $components['red']);
        $this->assertSame(128, $components['green']); // falls back to 50/50, same as testEvenBlend
        $this->assertSame(0, $components['blue']);
        $this->assertSame(0, $components['alpha']);
    }

    public function testAlphaBlend(): void
    {
        $opaqueBlack      = imagecolorallocatealpha($this->image, 0x00, 0x00, 0x00, 0);
        $translucentBlack = imagecolorallocatealpha($this->image, 0x00, 0x00, 0x00, 63);
        $result           = AgjGd::imageblendedcolorallocate($this->image, $opaqueBlack, $translucentBlack);

        $this->assertNotFalse($result);

        $components = imagecolorsforindex($this->image, $result);

        $this->assertSame(0, $components['red']);
        $this->assertSame(0, $components['green']);
        $this->assertSame(0, $components['blue']);
        $this->assertSame(32, $components['alpha']); // round(63 * 0.5) = 32
    }

    public function testInvalidColor1ReturnsFalse(): void
    {
        $blue = imagecolorallocate($this->image, 0x00, 0x00, 0xFF);

        $this->assertFalse(AgjGd::imageblendedcolorallocate($this->image, false, $blue));
    }

    public function testInvalidColor2ReturnsFalse(): void
    {
        $red = imagecolorallocate($this->image, 0xFF, 0x00, 0x00);

        $this->assertFalse(AgjGd::imageblendedcolorallocate($this->image, $red, false));
    }
}

ImageColorAllocateFromStringTest.php

<?php

/**
 * Copyright (c) 2018–2026 Andrew G. Johnson <andrew@andrewgjohnson.com>
 * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
 * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
 * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
 * permit persons to whom the Software is furnished to do so, subject to the following conditions:
 * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
 * Software.
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
 * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
 * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
 * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 */

declare(strict_types=1);

namespace AndrewGJohnson\AgjGd\Tests;

use AndrewGJohnson\AgjGd;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

class ImageColorAllocateFromStringTest extends TestCase
{
    private \GdImage $image;

    protected function setUp(): void
    {
        $this->image = imagecreatetruecolor(100, 100);
    }

    public function testHexSixDigitUppercase(): void
    {
        $components = $this->componentsFor('#FF0000');

        $this->assertSame(255, $components['red']);
        $this->assertSame(0, $components['green']);
        $this->assertSame(0, $components['blue']);
        $this->assertSame(0, $components['alpha']);
    }

    public function testHexSixDigitLowercase(): void
    {
        $components = $this->componentsFor('#ff0000');

        $this->assertSame(255, $components['red']);
        $this->assertSame(0, $components['green']);
        $this->assertSame(0, $components['blue']);
        $this->assertSame(0, $components['alpha']);
    }

    public function testHexSixDigitWithoutHash(): void
    {
        $components = $this->componentsFor('ff0000');

        $this->assertSame(255, $components['red']);
        $this->assertSame(0, $components['green']);
        $this->assertSame(0, $components['blue']);
        $this->assertSame(0, $components['alpha']);
    }

    public function testHexThreeDigit(): void
    {
        $components = $this->componentsFor('#f00');

        $this->assertSame(255, $components['red']);
        $this->assertSame(0, $components['green']);
        $this->assertSame(0, $components['blue']);
        $this->assertSame(0, $components['alpha']);
    }

    public function testRgbWithCommas(): void
    {
        $components = $this->componentsFor('rgb(0, 255, 0)');

        $this->assertSame(0, $components['red']);
        $this->assertSame(255, $components['green']);
        $this->assertSame(0, $components['blue']);
        $this->assertSame(0, $components['alpha']);
    }

    public function testRgbWithSpaces(): void
    {
        $components = $this->componentsFor('rgb(0 255 0)');

        $this->assertSame(0, $components['red']);
        $this->assertSame(255, $components['green']);
        $this->assertSame(0, $components['blue']);
        $this->assertSame(0, $components['alpha']);
    }

    public function testRgbaOpaqueDecimalAlpha(): void
    {
        $components = $this->componentsFor('rgba(0, 0, 255, 1)');

        $this->assertSame(0, $components['red']);
        $this->assertSame(0, $components['green']);
        $this->assertSame(255, $components['blue']);
        $this->assertSame(0, $components['alpha']); // CSS alpha 1 (opaque) → GD alpha 0
    }

    public function testRgbaOpaqueDecimalAlphaWithTrailingZero(): void
    {
        // "1.0" is a valid CSS opacity; the alpha grammar must accept it, not just the bare "1".
        $components = $this->componentsFor('rgba(0, 0, 255, 1.0)');

        $this->assertSame(0, $components['alpha']); // 127 - round(127 * 1.0) = 0 (fully opaque)
    }

    public function testRgbaTransparentDecimalAlpha(): void
    {
        $components = $this->componentsFor('rgba(0, 0, 255, 0)');

        $this->assertSame(127, $components['alpha']); // CSS alpha 0 (transparent) → GD alpha 127
    }

    public function testRgbaHalfTransparentDecimalAlpha(): void
    {
        $components = $this->componentsFor('rgba(0, 0, 255, 0.5)');

        $this->assertSame(63, $components['alpha']); // 127 - round(127 * 0.5) = 127 - 64 = 63
    }

    public function testRgbaOpaquePercentageAlpha(): void
    {
        $components = $this->componentsFor('rgba(0 255 0 / 100%)');

        $this->assertSame(0, $components['red']);
        $this->assertSame(255, $components['green']);
        $this->assertSame(0, $components['blue']);
        $this->assertSame(0, $components['alpha']); // CSS alpha 100% (opaque) → GD alpha 0
    }

    public function testRgbaTransparentPercentageAlpha(): void
    {
        $components = $this->componentsFor('rgba(0 255 0 / 0%)');

        $this->assertSame(127, $components['alpha']); // CSS alpha 0% (transparent) → GD alpha 127
    }

    /**
     * @return array<string, array{0: string, 1: int, 2: int, 3: int}>
     */
    public static function cssKeywordProvider(): array
    {
        return [
            'red'   => ['red',   255, 0,   0  ],
            'green' => ['green', 0,   128, 0  ],
            'blue'  => ['blue',  0,   0,   255],
            'white' => ['white', 255, 255, 255],
            'black' => ['black', 0,   0,   0  ],
            'lime'  => ['lime',  0,   255, 0  ],
        ];
    }

    #[DataProvider('cssKeywordProvider')]
    public function testCssColorKeyword(string $keyword, int $red, int $green, int $blue): void
    {
        $components = $this->componentsFor($keyword);

        $this->assertSame($red, $components['red']);
        $this->assertSame($green, $components['green']);
        $this->assertSame($blue, $components['blue']);
    }

    public function testCssKeywordCaseInsensitive(): void
    {
        $components = $this->componentsFor('RED');

        $this->assertSame(255, $components['red']);
        $this->assertSame(0, $components['green']);
        $this->assertSame(0, $components['blue']);
    }

    public function testAlphaParameter(): void
    {
        $components = $this->componentsFor('#0000ff', 64);

        $this->assertSame(0, $components['red']);
        $this->assertSame(0, $components['green']);
        $this->assertSame(255, $components['blue']);
        $this->assertSame(64, $components['alpha']);
    }

    public function testLeadingAndTrailingWhitespaceTrimmed(): void
    {
        $components = $this->componentsFor('  #FF0000  ');

        $this->assertSame(255, $components['red']);
        $this->assertSame(0, $components['green']);
        $this->assertSame(0, $components['blue']);
    }

    public function testInvalidStringThrowsException(): void
    {
        $this->expectException(InvalidArgumentException::class);

        AgjGd::imagecolorallocatefromstring($this->image, 'notacolor');
    }

    public function testOutOfRangeRgbThrowsException(): void
    {
        $this->expectException(InvalidArgumentException::class);

        AgjGd::imagecolorallocatefromstring($this->image, 'rgb(999, 0, 0)');
    }

    public function testInvalidAlphaParameterThrowsException(): void
    {
        $this->expectException(InvalidArgumentException::class);

        AgjGd::imagecolorallocatefromstring($this->image, '#ff0000', 128);
    }

    public function testNegativeAlphaParameterThrowsException(): void
    {
        $this->expectException(InvalidArgumentException::class);

        AgjGd::imagecolorallocatefromstring($this->image, '#ff0000', -1);
    }

    public function testInvalidRgbaAlphaValueThrowsException(): void
    {
        $this->expectException(InvalidArgumentException::class);

        AgjGd::imagecolorallocatefromstring($this->image, 'rgba(255, 0, 0, 1.5)');
    }

    public function testOutOfRangePercentageAlphaThrows(): void
    {
        // 150% is valid rgba() syntax but converts to a 1.5 opacity, which is outside the 0-1 range.
        $this->expectException(InvalidArgumentException::class);

        AgjGd::imagecolorallocatefromstring($this->image, 'rgba(0 0 0 / 150%)');
    }

    /**
     * @return array{red: int, green: int, blue: int, alpha: int}
     */
    private function componentsFor(string $string, int $alpha = 0): array
    {
        $color = AgjGd::imagecolorallocatefromstring($this->image, $string, $alpha);

        $this->assertNotFalse($color);

        return imagecolorsforindex($this->image, $color);
    }
}

ImageFtTextFilterTest.php

<?php

/**
 * Copyright (c) 2013–2026 Andrew G. Johnson <andrew@andrewgjohnson.com>
 * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
 * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
 * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
 * permit persons to whom the Software is furnished to do so, subject to the following conditions:
 * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
 * Software.
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
 * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
 * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
 * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 */

declare(strict_types=1);

namespace AndrewGJohnson\AgjGd\Tests;

use AndrewGJohnson\AgjGd;
use PHPUnit\Framework\TestCase;

class ImageFtTextFilterTest extends TestCase
{
    private const FILTER_INTENSITY = 1;
    private const FONT_ANGLE       = 0;
    private const FONT_PATH        = __DIR__ . '/NotoSans-Regular.ttf';
    private const FONT_SIZE        = 12;
    private const FONT_X           = 10;
    private const FONT_Y           = 50;
    private const IMAGE_WIDTH      = 200;
    private const IMAGE_HEIGHT     = 100;

    public function testReturnsArrayWithFilter(): void
    {
        $image = $this->createImage();

        $result = AgjGd::imagefttextfilter(
            $image,
            self::FONT_SIZE,
            self::FONT_ANGLE,
            self::FONT_X,
            self::FONT_Y,
            (int)imagecolorallocate($image, 0, 0, 0),
            self::FONT_PATH,
            'Hello world!',
            [],
            self::FILTER_INTENSITY
        );

        $this->assertIsArray($result);
        $this->assertCount(8, $result);
    }

    public function testReturnsArrayWithoutFilter(): void
    {
        $image = $this->createImage();

        $result = AgjGd::imagefttextfilter(
            $image,
            self::FONT_SIZE,
            self::FONT_ANGLE,
            self::FONT_X,
            self::FONT_Y,
            (int)imagecolorallocate($image, 0, 0, 0),
            self::FONT_PATH,
            'Hello world!'
        );

        $this->assertIsArray($result);
        $this->assertCount(8, $result);
    }

    public function testBoundingBoxWithinImageBounds(): void
    {
        $image = $this->createImage();

        $result = AgjGd::imagefttextfilter(
            $image,
            self::FONT_SIZE,
            self::FONT_ANGLE,
            self::FONT_X,
            self::FONT_Y,
            (int)imagecolorallocate($image, 0, 0, 0),
            self::FONT_PATH,
            'Hello world!',
            [],
            self::FILTER_INTENSITY
        );

        $this->assertIsArray($result);

        foreach ([0, 2, 4, 6] as $index) {
            $this->assertGreaterThanOrEqual(0, $result[$index]);
            $this->assertLessThanOrEqual(self::IMAGE_WIDTH, $result[$index]);
        }

        foreach ([1, 3, 5, 7] as $index) {
            $this->assertGreaterThanOrEqual(0, $result[$index]);
            $this->assertLessThanOrEqual(self::IMAGE_HEIGHT, $result[$index]);
        }
    }

    public function testFullyTransparentColorReturnsFalse(): void
    {
        $image = $this->createImage();

        $result = AgjGd::imagefttextfilter(
            $image,
            self::FONT_SIZE,
            self::FONT_ANGLE,
            self::FONT_X,
            self::FONT_Y,
            (int)imagecolorallocatealpha($image, 0, 0, 0, 127), // 127 = completely transparent
            self::FONT_PATH,
            'Hello world!',
            [],
            self::FILTER_INTENSITY
        );

        $this->assertFalse($result);
    }

    public function testInvalidFontReturnsFalse(): void
    {
        $image = $this->createImage();

        // @ suppresses the GD warning emitted by imagettftext() when the font is missing
        $result = @AgjGd::imagefttextfilter(
            $image,
            self::FONT_SIZE,
            self::FONT_ANGLE,
            self::FONT_X,
            self::FONT_Y,
            (int)imagecolorallocate($image, 0, 0, 0),
            '/nonexistent/font.ttf',
            'Hello world!',
            [],
            self::FILTER_INTENSITY
        );

        $this->assertFalse($result);
    }

    public function testImageIsModifiedByUse(): void
    {
        $image = $this->createImage();

        $backgroundColor = (int)imagecolorallocate($image, 255, 255, 255); // RGB(255,255,255) = white
        imagefill($image, 0, 0, $backgroundColor);

        AgjGd::imagefttextfilter(
            $image,
            self::FONT_SIZE,
            self::FONT_ANGLE,
            self::FONT_X,
            self::FONT_Y,
            (int)imagecolorallocate($image, 0, 0, 0),
            self::FONT_PATH,
            'Hello world!',
            [],
            self::FILTER_INTENSITY
        );

        // At least one pixel somewhere in the image must differ from the background
        $modified = false;
        for ($x = 0; $x < self::IMAGE_WIDTH && !$modified; $x++) {
            for ($y = 0; $y < self::IMAGE_HEIGHT && !$modified; $y++) {
                if (imagecolorat($image, $x, $y) !== $backgroundColor) {
                    $modified = true;
                }
            }
        }

        $this->assertTrue($modified);
    }

    public function testFilterSpreadsTextBeyondTheUnfilteredBoundingBox(): void
    {
        $unfilteredImage = $this->createImage();
        $filteredImage   = $this->createImage();

        $unfiltered = AgjGd::imagefttextfilter(
            $unfilteredImage,
            self::FONT_SIZE,
            self::FONT_ANGLE,
            self::FONT_X,
            self::FONT_Y,
            (int)imagecolorallocate($unfilteredImage, 0, 0, 0),
            self::FONT_PATH,
            'Hello world!'
        );

        $filtered = AgjGd::imagefttextfilter(
            $filteredImage,
            self::FONT_SIZE,
            self::FONT_ANGLE,
            self::FONT_X,
            self::FONT_Y,
            (int)imagecolorallocate($filteredImage, 0, 0, 0),
            self::FONT_PATH,
            'Hello world!',
            [],
            10
        );

        $this->assertIsArray($unfiltered);
        $this->assertIsArray($filtered);

        // A heavy Gaussian blur bleeds the glyphs outwards, so the filtered bounding box must reach further left and
        // right than the bounding box imagettftext() reports for the same unfiltered text.
        $this->assertLessThan($unfiltered[0], $filtered[0], 'The filtered text should reach further left');
        $this->assertGreaterThan($unfiltered[2], $filtered[2], 'The filtered text should reach further right');
    }

    public function testImageTtfTextFilterAliasMatchesTheCanonicalMethod(): void
    {
        $canonical = $this->createImage();
        $alias     = $this->createImage();

        $canonicalBox = AgjGd::imagefttextfilter(
            $canonical,
            self::FONT_SIZE,
            self::FONT_ANGLE,
            self::FONT_X,
            self::FONT_Y,
            (int)imagecolorallocate($canonical, 0, 0, 0),
            self::FONT_PATH,
            'Hello world!',
            [],
            10
        );

        $aliasBox = AgjGd::imagettftextfilter(
            $alias,
            self::FONT_SIZE,
            self::FONT_ANGLE,
            self::FONT_X,
            self::FONT_Y,
            (int)imagecolorallocate($alias, 0, 0, 0),
            self::FONT_PATH,
            'Hello world!',
            [],
            10
        );

        $this->assertSame($canonicalBox, $aliasBox);
        $this->assertTrue($this->imagesAreIdentical($canonical, $alias));
    }

    public function testImageTtfTextBlurAliasMatchesTheCanonicalMethod(): void
    {
        $canonical = $this->createImage();
        $alias     = $this->createImage();

        $canonicalBox = AgjGd::imagefttextfilter(
            $canonical,
            self::FONT_SIZE,
            self::FONT_ANGLE,
            self::FONT_X,
            self::FONT_Y,
            (int)imagecolorallocate($canonical, 0, 0, 0),
            self::FONT_PATH,
            'Hello world!',
            [],
            10
        );

        $aliasBox = AgjGd::imagettftextblur(
            $alias,
            self::FONT_SIZE,
            self::FONT_ANGLE,
            self::FONT_X,
            self::FONT_Y,
            (int)imagecolorallocate($alias, 0, 0, 0),
            self::FONT_PATH,
            'Hello world!',
            [],
            10
        );

        $this->assertSame($canonicalBox, $aliasBox);
        $this->assertTrue($this->imagesAreIdentical($canonical, $alias));
    }

    public function testImageFtTextBlurAliasMatchesTheCanonicalMethod(): void
    {
        $canonical = $this->createImage();
        $alias     = $this->createImage();

        $canonicalBox = AgjGd::imagefttextfilter(
            $canonical,
            self::FONT_SIZE,
            self::FONT_ANGLE,
            self::FONT_X,
            self::FONT_Y,
            (int)imagecolorallocate($canonical, 0, 0, 0),
            self::FONT_PATH,
            'Hello world!',
            [],
            10
        );

        $aliasBox = AgjGd::imagefttextblur(
            $alias,
            self::FONT_SIZE,
            self::FONT_ANGLE,
            self::FONT_X,
            self::FONT_Y,
            (int)imagecolorallocate($alias, 0, 0, 0),
            self::FONT_PATH,
            'Hello world!',
            [],
            10
        );

        $this->assertSame($canonicalBox, $aliasBox);
        $this->assertTrue($this->imagesAreIdentical($canonical, $alias));
    }

    private function imagesAreIdentical(\GdImage $a, \GdImage $b): bool
    {
        for ($x = 0; $x < self::IMAGE_WIDTH; $x++) {
            for ($y = 0; $y < self::IMAGE_HEIGHT; $y++) {
                if (imagecolorat($a, $x, $y) !== imagecolorat($b, $x, $y)) {
                    return false;
                }
            }
        }

        return true;
    }

    private function createImage(): \GdImage
    {
        $image = imagecreatetruecolor(self::IMAGE_WIDTH, self::IMAGE_HEIGHT);

        $this->assertNotFalse($image);

        return $image;
    }
}

ImageFtTextGradientTest.php

<?php

/**
 * Copyright (c) 2017–2026 Andrew G. Johnson <andrew@andrewgjohnson.com>
 * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
 * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
 * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
 * permit persons to whom the Software is furnished to do so, subject to the following conditions:
 * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
 * Software.
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
 * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
 * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
 * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 */

declare(strict_types=1);

namespace AndrewGJohnson\AgjGd\Tests;

use AndrewGJohnson\AgjGd;
use PHPUnit\Framework\TestCase;

class ImageFtTextGradientTest extends TestCase
{
    private const FONT_ANGLE   = 0;
    private const FONT_PATH    = __DIR__ . '/NotoSans-Regular.ttf';
    private const FONT_SIZE    = 40;
    private const FONT_X       = 10;
    private const FONT_Y       = 120;
    private const IMAGE_WIDTH  = 400;
    private const IMAGE_HEIGHT = 200;

    public function testReturnsArrayWithEightElementsOnSuccess(): void
    {
        $image = $this->createImage();

        $result = AgjGd::imagefttextgradient(
            $image,
            self::FONT_SIZE,
            self::FONT_ANGLE,
            self::FONT_X,
            self::FONT_Y,
            (int)imagecolorallocate($image, 0xFF, 0x00, 0x00),
            self::FONT_PATH,
            'Hello',
            [],
            (int)imagecolorallocate($image, 0x00, 0x00, 0xFF)
        );

        $this->assertIsArray($result);
        $this->assertCount(8, $result);
    }

    public function testBoundingBoxCoordinatesAreWithinImageBounds(): void
    {
        $image = $this->createImage();

        $result = AgjGd::imagefttextgradient(
            $image,
            self::FONT_SIZE,
            self::FONT_ANGLE,
            self::FONT_X,
            self::FONT_Y,
            (int)imagecolorallocate($image, 0xFF, 0x00, 0x00),
            self::FONT_PATH,
            'Hello',
            [],
            (int)imagecolorallocate($image, 0x00, 0x00, 0xFF)
        );

        $this->assertIsArray($result);

        // Even indices are x coordinates, odd indices are y coordinates
        for ($index = 0; $index < 8; $index += 2) {
            $this->assertGreaterThanOrEqual(0, $result[$index]);
            $this->assertLessThan(self::IMAGE_WIDTH, $result[$index]);
        }

        for ($index = 1; $index < 8; $index += 2) {
            $this->assertGreaterThanOrEqual(0, $result[$index]);
            $this->assertLessThan(self::IMAGE_HEIGHT, $result[$index]);
        }
    }

    public function testReturnsFalseForInvalidFont(): void
    {
        $image = $this->createImage();

        // @ suppresses the GD warning emitted by imagettftext() when the font is missing
        $result = @AgjGd::imagefttextgradient(
            $image,
            self::FONT_SIZE,
            self::FONT_ANGLE,
            self::FONT_X,
            self::FONT_Y,
            (int)imagecolorallocate($image, 0xFF, 0x00, 0x00),
            '/nonexistent/font.ttf',
            'Hello',
            [],
            (int)imagecolorallocate($image, 0x00, 0x00, 0xFF)
        );

        $this->assertFalse($result);
    }

    public function testHorizontalGradientReturnsArray(): void
    {
        $image = $this->createImage();

        $result = AgjGd::imagefttextgradient(
            $image,
            self::FONT_SIZE,
            self::FONT_ANGLE,
            self::FONT_X,
            self::FONT_Y,
            (int)imagecolorallocate($image, 0xFF, 0x00, 0x00),
            self::FONT_PATH,
            'Hello',
            [],
            (int)imagecolorallocate($image, 0x00, 0x00, 0xFF),
            true
        );

        $this->assertIsArray($result);
        $this->assertCount(8, $result);
    }

    public function testVerticalGradientColorInterpolation(): void
    {
        $image = $this->createImage();

        $result = AgjGd::imagefttextgradient(
            $image,
            self::FONT_SIZE,
            self::FONT_ANGLE,
            self::FONT_X,
            self::FONT_Y,
            (int)imagecolorallocate($image, 0xFF, 0x00, 0x00),
            self::FONT_PATH,
            'HELLO',
            [],
            (int)imagecolorallocate($image, 0x00, 0x00, 0xFF)
        );

        $this->assertIsArray($result);

        $textTop    = min($result[5], $result[7]);
        $textBottom = max($result[1], $result[3]);
        $textLeft   = min($result[0], $result[6]);
        $textRight  = max($result[2], $result[4]);
        $band       = max(1, (int)(($textBottom - $textTop) * 0.15));

        [$topRedSum, $topBlueSum, $topCount] = $this->sumRgbInRegion(
            $image,
            $textLeft,
            $textTop,
            $textRight,
            $textTop + $band
        );

        [$bottomRedSum, $bottomBlueSum, $bottomCount] = $this->sumRgbInRegion(
            $image,
            $textLeft,
            $textBottom - $band,
            $textRight,
            $textBottom
        );

        $this->assertGreaterThan(0, $topCount, 'Expected text pixels near the top of the bounding box');
        $this->assertGreaterThan(0, $bottomCount, 'Expected text pixels near the bottom of the bounding box');
        $this->assertGreaterThan(
            $topBlueSum / $topCount,
            $topRedSum / $topCount,
            'Top pixels should have more red than blue'
        );
        $this->assertGreaterThan(
            $bottomRedSum / $bottomCount,
            $bottomBlueSum / $bottomCount,
            'Bottom pixels should have more blue than red'
        );
    }

    public function testHorizontalGradientColorInterpolation(): void
    {
        $image = $this->createImage();

        $result = AgjGd::imagefttextgradient(
            $image,
            self::FONT_SIZE,
            self::FONT_ANGLE,
            self::FONT_X,
            self::FONT_Y,
            (int)imagecolorallocate($image, 0xFF, 0x00, 0x00),
            self::FONT_PATH,
            'HELLO',
            [],
            (int)imagecolorallocate($image, 0x00, 0x00, 0xFF),
            true
        );

        $this->assertIsArray($result);

        $textTop    = min($result[5], $result[7]);
        $textBottom = max($result[1], $result[3]);
        $textLeft   = min($result[0], $result[6]);
        $textRight  = max($result[2], $result[4]);
        $band       = max(1, (int)(($textRight - $textLeft) * 0.15));

        [$leftRedSum, $leftBlueSum, $leftCount] = $this->sumRgbInRegion(
            $image,
            $textLeft,
            $textTop,
            $textLeft + $band,
            $textBottom
        );

        [$rightRedSum, $rightBlueSum, $rightCount] = $this->sumRgbInRegion(
            $image,
            $textRight - $band,
            $textTop,
            $textRight,
            $textBottom
        );

        $this->assertGreaterThan(0, $leftCount, 'Expected text pixels near the left edge of the bounding box');
        $this->assertGreaterThan(0, $rightCount, 'Expected text pixels near the right edge of the bounding box');
        $this->assertGreaterThan(
            $leftBlueSum / $leftCount,
            $leftRedSum / $leftCount,
            'Left pixels should have more red than blue'
        );
        $this->assertGreaterThan(
            $rightRedSum / $rightCount,
            $rightBlueSum / $rightCount,
            'Right pixels should have more blue than red'
        );
    }

    public function testAlphaColorsAreInterpolated(): void
    {
        $image = $this->createImage();

        $result = AgjGd::imagefttextgradient(
            $image,
            self::FONT_SIZE,
            self::FONT_ANGLE,
            self::FONT_X,
            self::FONT_Y,
            (int)imagecolorallocatealpha($image, 0xFF, 0x00, 0x00, 0),
            self::FONT_PATH,
            'Hello',
            [],
            (int)imagecolorallocatealpha($image, 0x00, 0x00, 0xFF, 64)
        );

        $this->assertIsArray($result);
        $this->assertCount(8, $result);
    }

    public function testCalledWithoutGradientColorRendersSolidText(): void
    {
        // Omitting the gradient color falls back to imagettftext(), which draws the text in a solid $color.
        $image = $this->createImage();

        $result = AgjGd::imagefttextgradient(
            $image,
            self::FONT_SIZE,
            self::FONT_ANGLE,
            self::FONT_X,
            self::FONT_Y,
            (int)imagecolorallocate($image, 0xFF, 0x00, 0x00),
            self::FONT_PATH,
            'Hello'
        );

        $this->assertIsArray($result);
        $this->assertCount(8, $result);

        $expected = imagettftext(
            $this->createImage(),
            self::FONT_SIZE,
            self::FONT_ANGLE,
            self::FONT_X,
            self::FONT_Y,
            0xFF0000,
            self::FONT_PATH,
            'Hello'
        );

        $this->assertSame($expected, $result);
    }

    public function testImageTtfTextGradientAliasMatchesTheCanonicalMethod(): void
    {
        // imagettftextgradient() is an alias of imagefttextgradient(). It must forward every argument, so rendering the
        // same gradient text through each one must produce byte-for-byte identical images and bounding boxes.
        $canonical = $this->createImage();
        $alias     = $this->createImage();

        $canonicalBox = AgjGd::imagefttextgradient(
            $canonical,
            self::FONT_SIZE,
            self::FONT_ANGLE,
            self::FONT_X,
            self::FONT_Y,
            (int)imagecolorallocate($canonical, 0xFF, 0x00, 0x00),
            self::FONT_PATH,
            'Hello',
            [],
            (int)imagecolorallocate($canonical, 0x00, 0x00, 0xFF),
            true
        );

        $aliasBox = AgjGd::imagettftextgradient(
            $alias,
            self::FONT_SIZE,
            self::FONT_ANGLE,
            self::FONT_X,
            self::FONT_Y,
            (int)imagecolorallocate($alias, 0xFF, 0x00, 0x00),
            self::FONT_PATH,
            'Hello',
            [],
            (int)imagecolorallocate($alias, 0x00, 0x00, 0xFF),
            true
        );

        $this->assertSame($canonicalBox, $aliasBox);
        $this->assertTrue($this->imagesAreIdentical($canonical, $alias));
    }

    private function imagesAreIdentical(\GdImage $a, \GdImage $b): bool
    {
        for ($x = 0; $x < self::IMAGE_WIDTH; $x++) {
            for ($y = 0; $y < self::IMAGE_HEIGHT; $y++) {
                if (imagecolorat($a, $x, $y) !== imagecolorat($b, $x, $y)) {
                    return false;
                }
            }
        }

        return true;
    }

    /**
     * Scans a rectangular region and sums the red and blue channel values of non-black pixels.
     *
     * @param \GdImage $image The image to scan.
     * @param int      $x1    The x-ordinate of the region’s first point.
     * @param int      $y1    The y-ordinate of the region’s first point.
     * @param int      $x2    The x-ordinate of the region’s second point.
     * @param int      $y2    The y-ordinate of the region’s second point.
     *
     * @return array{0: int, 1: int, 2: int} The summed red channel, the summed blue channel and the pixel count.
     */
    private function sumRgbInRegion(\GdImage $image, int $x1, int $y1, int $x2, int $y2): array
    {
        $redSum  = 0;
        $blueSum = 0;
        $count   = 0;

        for ($x = $x1; $x <= $x2; $x++) {
            for ($y = $y1; $y <= $y2; $y++) {
                $rgb   = (int)imagecolorat($image, $x, $y);
                $red   = ($rgb >> 16) & 0xFF;
                $blue  = $rgb & 0xFF;

                if ($red + $blue > 0) {
                    $redSum  += $red;
                    $blueSum += $blue;
                    $count++;
                }
            }
        }

        return [$redSum, $blueSum, $count];
    }

    private function createImage(): \GdImage
    {
        $image = imagecreatetruecolor(self::IMAGE_WIDTH, self::IMAGE_HEIGHT);

        $this->assertNotFalse($image);

        return $image;
    }
}

ImageGradientRectangleTest.php

<?php

/**
 * Copyright (c) 2018–2026 Andrew G. Johnson <andrew@andrewgjohnson.com>
 * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
 * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
 * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
 * permit persons to whom the Software is furnished to do so, subject to the following conditions:
 * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
 * Software.
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
 * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
 * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
 * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 */

declare(strict_types=1);

namespace AndrewGJohnson\AgjGd\Tests;

use AndrewGJohnson\AgjGd;
use PHPUnit\Framework\TestCase;

class ImageGradientRectangleTest extends TestCase
{
    private \GdImage $image;

    private int $red;

    private int $blue;

    protected function setUp(): void
    {
        $this->image = imagecreatetruecolor(100, 100);
        $this->red   = (int)imagecolorallocate($this->image, 0xFF, 0x00, 0x00);
        $this->blue  = (int)imagecolorallocate($this->image, 0x00, 0x00, 0xFF);
    }

    public function testVerticalGradientReturnsTrue(): void
    {
        $this->assertTrue(AgjGd::imagegradientrectangle($this->image, 10, 10, 90, 90, $this->red, $this->blue));
    }

    public function testHorizontalGradientReturnsTrue(): void
    {
        $this->assertTrue(AgjGd::imagegradientrectangle($this->image, 10, 10, 90, 90, $this->red, $this->blue, true));
    }

    public function testZeroHeightVerticalGradientReturnsFalse(): void
    {
        $this->assertFalse(AgjGd::imagegradientrectangle($this->image, 10, 50, 90, 50, $this->red, $this->blue));
    }

    public function testZeroWidthHorizontalGradientReturnsFalse(): void
    {
        $this->assertFalse(AgjGd::imagegradientrectangle($this->image, 50, 10, 50, 90, $this->red, $this->blue, true));
    }

    public function testSolidFillReturnsTrue(): void
    {
        $this->assertTrue(AgjGd::imagegradientrectangle($this->image, 10, 10, 90, 90, $this->red));
    }

    public function testVerticalGradientRunsFromColorToGradientColor(): void
    {
        AgjGd::imagegradientrectangle($this->image, 10, 10, 90, 90, $this->red, $this->blue);

        $top    = imagecolorsforindex($this->image, (int)imagecolorat($this->image, 50, 11));
        $bottom = imagecolorsforindex($this->image, (int)imagecolorat($this->image, 50, 89));

        $this->assertGreaterThan($top['blue'], $top['red'], 'The top of the rectangle should be mostly red');
        $this->assertGreaterThan($bottom['red'], $bottom['blue'], 'The bottom of the rectangle should be mostly blue');
    }

    public function testHorizontalGradientRunsFromColorToGradientColor(): void
    {
        AgjGd::imagegradientrectangle($this->image, 10, 10, 90, 90, $this->red, $this->blue, true);

        $left  = imagecolorsforindex($this->image, (int)imagecolorat($this->image, 11, 50));
        $right = imagecolorsforindex($this->image, (int)imagecolorat($this->image, 89, 50));

        $this->assertGreaterThan($left['blue'], $left['red'], 'The left of the rectangle should be mostly red');
        $this->assertGreaterThan($right['red'], $right['blue'], 'The right of the rectangle should be mostly blue');
    }

    public function testVerticalGradientReachesTheFinishColorAtTheFarEdge(): void
    {
        // Regression test for the off-by-one that stopped one pixel short of $y2 and never reached
        // ratio 0, so the far edge must now be painted with the pure finish color (blue).
        AgjGd::imagegradientrectangle($this->image, 10, 10, 90, 90, $this->red, $this->blue);

        $components = imagecolorsforindex($this->image, (int)imagecolorat($this->image, 50, 90));

        $this->assertSame(0, $components['red']);
        $this->assertSame(0, $components['green']);
        $this->assertSame(255, $components['blue']);
    }

    public function testHorizontalGradientReachesTheFinishColorAtTheFarEdge(): void
    {
        // Regression test for the off-by-one that stopped one pixel short of $x2 and never reached
        // ratio 0, so the far edge must now be painted with the pure finish color (blue).
        AgjGd::imagegradientrectangle($this->image, 10, 10, 90, 90, $this->red, $this->blue, true);

        $components = imagecolorsforindex($this->image, (int)imagecolorat($this->image, 90, 50));

        $this->assertSame(0, $components['red']);
        $this->assertSame(0, $components['green']);
        $this->assertSame(255, $components['blue']);
    }

    public function testSolidFillUsesTheColorExactly(): void
    {
        AgjGd::imagegradientrectangle($this->image, 10, 10, 90, 90, $this->red);

        $components = imagecolorsforindex($this->image, (int)imagecolorat($this->image, 50, 50));

        $this->assertSame(255, $components['red']);
        $this->assertSame(0, $components['green']);
        $this->assertSame(0, $components['blue']);
    }

    public function testReturnsFalseWhenTheVerticalGradientExhaustsThePalette(): void
    {
        // A palette image caps at 256 colors; a 400px vertical gradient needs more, so partway through the loop
        // imageblendedcolorallocate() can no longer allocate a color and the method reports the failure.
        $image = imagecreate(400, 400);
        $this->assertNotFalse($image);
        $red  = (int)imagecolorallocate($image, 0xFF, 0x00, 0x00);
        $blue = (int)imagecolorallocate($image, 0x00, 0x00, 0xFF);

        $this->assertFalse(AgjGd::imagegradientrectangle($image, 0, 0, 399, 399, $red, $blue));
    }

    public function testReturnsFalseWhenTheHorizontalGradientExhaustsThePalette(): void
    {
        $image = imagecreate(400, 400);
        $this->assertNotFalse($image);
        $red  = (int)imagecolorallocate($image, 0xFF, 0x00, 0x00);
        $blue = (int)imagecolorallocate($image, 0x00, 0x00, 0xFF);

        $this->assertFalse(AgjGd::imagegradientrectangle($image, 0, 0, 399, 399, $red, $blue, true));
    }
}

LineBreaksForTextTest.php

<?php

/**
 * Copyright (c) 2018–2026 Andrew G. Johnson <andrew@andrewgjohnson.com>
 * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
 * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
 * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
 * permit persons to whom the Software is furnished to do so, subject to the following conditions:
 * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
 * Software.
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
 * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
 * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
 * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 */

declare(strict_types=1);

namespace AndrewGJohnson\AgjGd\Tests;

use AndrewGJohnson\AgjGd;
use PHPUnit\Framework\TestCase;

class LineBreaksForTextTest extends TestCase
{
    private const FONT_ANGLE = 0;
    private const FONT_PATH  = __DIR__ . '/NotoSans-Regular.ttf';
    private const FONT_SIZE  = 10;

    public function testTextIsReturnedUnchangedWhenItAlreadyFits(): void
    {
        $this->assertSame('Hello world!', $this->lineBreaks('Hello world!', 10000));
    }

    public function testEmptyStringReturnsEmptyString(): void
    {
        $this->assertSame('', $this->lineBreaks('', 10000));
    }

    public function testSingleWordReturnedUnchanged(): void
    {
        // A single word should always be returned unchanged regardless of the maximum width.
        $this->assertSame('Hello', $this->lineBreaks('Hello', 1));
    }

    public function testTextFittingMaximumWidthHasNoLineBreaks(): void
    {
        $result = $this->lineBreaks('Hello world!', 10000);

        $this->assertStringNotContainsString("\n", $result);
        $this->assertStringNotContainsString("\r", $result);
    }

    public function testTextExceedingMaximumWidthGetsLineBreak(): void
    {
        $this->assertStringContainsString("\n", $this->lineBreaks('Hello world!', 1, "\n"));
    }

    public function testCustomLineBreakCharacter(): void
    {
        $result = $this->lineBreaks('Hello world!', 1, '<br>');

        $this->assertStringContainsString('<br>', $result);
        $this->assertStringNotContainsString(PHP_EOL, $result);
    }

    public function testAllWordsArePresentInOutput(): void
    {
        $words  = ['Hello', 'world', 'foo', 'bar'];
        $result = $this->lineBreaks(implode(' ', $words), 1, "\n");

        foreach ($words as $word) {
            $this->assertStringContainsString($word, $result);
        }
    }

    public function testForceBreakOnSingleWordsSplitsLongWord(): void
    {
        // With forceBreakOnSingleWords disabled a long word that does not fit should still appear intact on a line;
        // with the flag enabled the word should be split across lines.
        $longWord  = 'Pneumonoultramicroscopicsilicovolcanoconiosis';
        $halfWidth = (int)($this->textWidth($longWord) / 2);
        $text      = 'A ' . $longWord;

        $withoutForce = $this->lineBreaks($text, $halfWidth, "\n", false, false);
        $withForce    = $this->lineBreaks($text, $halfWidth, "\n", false, true);

        $foundIntact = false;
        foreach (explode("\n", $withoutForce) as $line) {
            if (str_contains($line, $longWord)) {
                $foundIntact = true;
                break;
            }
        }

        $this->assertTrue(
            $foundIntact,
            'Without forceBreakOnSingleWords the long word should appear intact on a single line'
        );

        $this->assertGreaterThan(
            count(explode("\n", $withoutForce)),
            count(explode("\n", $withForce)),
            'With forceBreakOnSingleWords there should be more lines because the word is split'
        );
    }

    public function testAttemptToBreakOnHyphensBreaksAtHyphen(): void
    {
        // maximumWidth is exactly the pixel width of 'A B-', so 'A B-C' overflows and the hyphen-break logic commits
        // 'A B-' and carries 'C' to the next line.
        $text         = 'A B-C';
        $maximumWidth = $this->textWidth('A B-');

        $withHyphens    = $this->lineBreaks($text, $maximumWidth, "\n", true);
        $withoutHyphens = $this->lineBreaks($text, $maximumWidth, "\n", false);

        $this->assertNotSame(
            $withoutHyphens,
            $withHyphens,
            'attemptToBreakOnHyphens should produce a different result when a break at a hyphen is possible'
        );

        $hasLineEndingWithHyphen = false;
        foreach (explode("\n", $withHyphens) as $line) {
            if (str_ends_with($line, '-')) {
                $hasLineEndingWithHyphen = true;
                break;
            }
        }

        $this->assertTrue(
            $hasLineEndingWithHyphen,
            'With attemptToBreakOnHyphens at least one line should end with a trailing hyphen'
        );
    }

    public function testPreventWidowsMovesPreviousWordToLastLine(): void
    {
        // When the last word would appear alone on the final line (a widow), preventWidows should pull the previous
        // word down so the two words share the last line.
        $text = 'Hello world x';

        // Width fits 'Hello world' exactly, so 'Hello world x' overflows and 'x' becomes a widow.
        $maximumWidth = $this->textWidth('Hello world');

        $withoutPrevent = $this->lineBreaks($text, $maximumWidth, "\n", false, false, false);
        $withPrevent    = $this->lineBreaks($text, $maximumWidth, "\n", false, false, true);

        $linesWithout = explode("\n", $withoutPrevent);
        $this->assertSame(
            'x',
            end($linesWithout),
            'Without preventWidows the last word should appear alone on the final line'
        );

        $linesWith = explode("\n", $withPrevent);
        $lastLine  = end($linesWith);

        $this->assertStringContainsString(
            'world',
            $lastLine,
            'With preventWidows the second-to-last word should be moved to the final line'
        );
        $this->assertStringContainsString(
            'x',
            $lastLine,
            'With preventWidows the last word should still appear on the final line'
        );
    }

    public function testEveryLineFitsWithinTheMaximumWidth(): void
    {
        $text         = 'It was the best of times, it was the worst of times, it was the age of wisdom.';
        $maximumWidth = 120;

        foreach (explode("\n", $this->lineBreaks($text, $maximumWidth, "\n")) as $line) {
            $this->assertLessThanOrEqual(
                $maximumWidth,
                $this->textWidth($line),
                'Every line should fit within the maximum width: ' . $line
            );
        }
    }

    public function testForceBreakReturnsTheWholeWordWhenNoSingleCharacterFits(): void
    {
        // With a maximum width smaller than any single glyph, the force-break loop cannot place even one character,
        // so it returns the whole remaining word rather than looping forever.
        $result = $this->lineBreaks('A verylongword', 1, "\n", false, true);

        $this->assertSame("A\nverylongword", $result);
    }

    public function testForceBreakHandlesEmptyWordsFromConsecutiveSpaces(): void
    {
        // A trailing space produces an empty final word; force-breaking an empty word must terminate cleanly.
        $result = $this->lineBreaks('A ', 1, "\n", false, true);

        $this->assertSame("A\n", $result);
    }

    public function testForceBreakGivesUpOnMalformedUtf8(): void
    {
        // Force-breaking splits a word into characters with preg_split() in UTF-8 mode, which fails on malformed
        // input. GD still measures the word, so it is seen as too wide rather than unmeasurable and the force-break
        // path is entered, but the split cannot happen and the word is left whole on its own line.
        $result = $this->lineBreaks("A verylongword\xFF", 1, "\n", false, true);

        $this->assertSame("A\nverylongword\xFF", $result);
    }

    public function testInvalidFontLeavesTextUnbroken(): void
    {
        // When imagettfbbox() cannot measure the text (here, a missing font) the width is treated as fitting, so no
        // line breaks are added. The @ suppresses the GD warning.
        $result = @AgjGd::linebreaksfortext(
            self::FONT_SIZE,
            self::FONT_ANGLE,
            '/nonexistent/font.ttf',
            'Hello world foo',
            100
        );

        $this->assertSame('Hello world foo', $result);
    }

    public function testAliasesMatchTheCanonicalMethod(): void
    {
        // linebreaks4imagettftext(), linebreaks4imagefttext(), linebreaks4text(), linebreaksforimagefttext() and
        // linebreaksforimagettftext() are all aliases of linebreaksfortext(). Each must forward every argument, so
        // wrapping the same text through each one must return an identical string.
        $text  = 'The quick brown fox jumps over the lazy dog';
        $width = 100;

        $canonical = AgjGd::linebreaksfortext(
            self::FONT_SIZE,
            self::FONT_ANGLE,
            self::FONT_PATH,
            $text,
            $width
        );

        // The input must actually wrap for the comparison to be meaningful.
        $this->assertStringContainsString(PHP_EOL, $canonical);

        $this->assertSame($canonical, AgjGd::linebreaks4imagettftext(
            self::FONT_SIZE,
            self::FONT_ANGLE,
            self::FONT_PATH,
            $text,
            $width
        ));

        $this->assertSame($canonical, AgjGd::linebreaks4imagefttext(
            self::FONT_SIZE,
            self::FONT_ANGLE,
            self::FONT_PATH,
            $text,
            $width
        ));

        $this->assertSame($canonical, AgjGd::linebreaks4text(
            self::FONT_SIZE,
            self::FONT_ANGLE,
            self::FONT_PATH,
            $text,
            $width
        ));

        $this->assertSame($canonical, AgjGd::linebreaksforimagefttext(
            self::FONT_SIZE,
            self::FONT_ANGLE,
            self::FONT_PATH,
            $text,
            $width
        ));

        $this->assertSame($canonical, AgjGd::linebreaksforimagettftext(
            self::FONT_SIZE,
            self::FONT_ANGLE,
            self::FONT_PATH,
            $text,
            $width
        ));
    }

    private function lineBreaks(
        string $text,
        int $maximumWidth,
        string $lineBreakCharacter = PHP_EOL,
        bool $attemptToBreakOnHyphens = false,
        bool $forceBreakOnSingleWords = false,
        bool $preventWidows = false
    ): string {
        return AgjGd::linebreaksfortext(
            self::FONT_SIZE,
            self::FONT_ANGLE,
            self::FONT_PATH,
            $text,
            $maximumWidth,
            $lineBreakCharacter,
            $attemptToBreakOnHyphens,
            $forceBreakOnSingleWords,
            $preventWidows
        );
    }

    private function textWidth(string $text): int
    {
        $boundingBox = imagettfbbox(self::FONT_SIZE, self::FONT_ANGLE, self::FONT_PATH, $text);

        $this->assertNotFalse($boundingBox);

        $left  = min($boundingBox[0], $boundingBox[2], $boundingBox[4], $boundingBox[6]);
        $right = max($boundingBox[0], $boundingBox[2], $boundingBox[4], $boundingBox[6]);

        return $right - $left;
    }
}