* 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. * * PHP version 8 * * @category AndrewGJohnson * @package AgjGd * @author Andrew G. Johnson * @copyright 2013–2026 Andrew G. Johnson * @license https://opensource.org/licenses/mit/ The MIT License * @link https://github.com/andrewgjohnson/AgjGd */ declare(strict_types=1); namespace AndrewGJohnson; use GdImage; use InvalidArgumentException; /** * AgjGd is a project that extends the functionality of PHP’s GD library started by Andrew G. Johnson. * * Examples: * * ``` * * @license https://opensource.org/licenses/mit/ The MIT License * @link https://github.com/andrewgjohnson/AgjGd */ class AgjGd { /** * The lowest possible GD alpha value, which is completely opaque. */ private const ALPHA_OPAQUE = 0; /** * The highest possible GD alpha value, which is completely transparent. */ private const ALPHA_TRANSPARENT = 127; /** * The highest possible value for a single 8-bit RGB color component. */ private const COLOR_COMPONENT_MAX = 255; /** * The CSS color keywords recognized by imagecolorallocatefromstring() and their RGB values. * * Source: https://www.w3.org/wiki/CSS/Properties/color/keywords * * @var array */ private const CSS_COLOR_KEYWORDS = [ 'aliceblue' => [0xF0, 0xF8, 0xFF], 'antiquewhite' => [0xFA, 0xEB, 0xD7], 'aqua' => [0x00, 0xFF, 0xFF], 'aquamarine' => [0x7F, 0xFF, 0xD4], 'azure' => [0xF0, 0xFF, 0xFF], 'beige' => [0xF5, 0xF5, 0xDC], 'bisque' => [0xFF, 0xE4, 0xC4], 'black' => [0x00, 0x00, 0x00], 'blanchedalmond' => [0xFF, 0xEB, 0xCD], 'blue' => [0x00, 0x00, 0xFF], 'blueviolet' => [0x8A, 0x2B, 0xE2], 'brown' => [0xA5, 0x2A, 0x2A], 'burlywood' => [0xDE, 0xB8, 0x87], 'cadetblue' => [0x5F, 0x9E, 0xA0], 'chartreuse' => [0x7F, 0xFF, 0x00], 'chocolate' => [0xD2, 0x69, 0x1E], 'coral' => [0xFF, 0x7F, 0x50], 'cornflowerblue' => [0x64, 0x95, 0xED], 'cornsilk' => [0xFF, 0xF8, 0xDC], 'crimson' => [0xDC, 0x14, 0x3C], 'cyan' => [0x00, 0xFF, 0xFF], 'darkblue' => [0x00, 0x00, 0x8B], 'darkcyan' => [0x00, 0x8B, 0x8B], 'darkgoldenrod' => [0xB8, 0x86, 0x0B], 'darkgray' => [0xA9, 0xA9, 0xA9], 'darkgreen' => [0x00, 0x64, 0x00], 'darkgrey' => [0xA9, 0xA9, 0xA9], 'darkkhaki' => [0xBD, 0xB7, 0x6B], 'darkmagenta' => [0x8B, 0x00, 0x8B], 'darkolivegreen' => [0x55, 0x6B, 0x2F], 'darkorange' => [0xFF, 0x8C, 0x00], 'darkorchid' => [0x99, 0x32, 0xCC], 'darkred' => [0x8B, 0x00, 0x00], 'darksalmon' => [0xE9, 0x96, 0x7A], 'darkseagreen' => [0x8F, 0xBC, 0x8F], 'darkslateblue' => [0x48, 0x3D, 0x8B], 'darkslategray' => [0x2F, 0x4F, 0x4F], 'darkslategrey' => [0x2F, 0x4F, 0x4F], 'darkturquoise' => [0x00, 0xCE, 0xD1], 'darkviolet' => [0x94, 0x00, 0xD3], 'deeppink' => [0xFF, 0x14, 0x93], 'deepskyblue' => [0x00, 0xBF, 0xFF], 'dimgray' => [0x69, 0x69, 0x69], 'dimgrey' => [0x69, 0x69, 0x69], 'dodgerblue' => [0x1E, 0x90, 0xFF], 'firebrick' => [0xB2, 0x22, 0x22], 'floralwhite' => [0xFF, 0xFA, 0xF0], 'forestgreen' => [0x22, 0x8B, 0x22], 'fuchsia' => [0xFF, 0x00, 0xFF], 'gainsboro' => [0xDC, 0xDC, 0xDC], 'ghostwhite' => [0xF8, 0xF8, 0xFF], 'gold' => [0xFF, 0xD7, 0x00], 'goldenrod' => [0xDA, 0xA5, 0x20], 'gray' => [0x80, 0x80, 0x80], 'green' => [0x00, 0x80, 0x00], 'greenyellow' => [0xAD, 0xFF, 0x2F], 'grey' => [0x80, 0x80, 0x80], 'honeydew' => [0xF0, 0xFF, 0xF0], 'hotpink' => [0xFF, 0x69, 0xB4], 'indianred' => [0xCD, 0x5C, 0x5C], 'indigo' => [0x4B, 0x00, 0x82], 'ivory' => [0xFF, 0xFF, 0xF0], 'khaki' => [0xF0, 0xE6, 0x8C], 'lavender' => [0xE6, 0xE6, 0xFA], 'lavenderblush' => [0xFF, 0xF0, 0xF5], 'lawngreen' => [0x7C, 0xFC, 0x00], 'lemonchiffon' => [0xFF, 0xFA, 0xCD], 'lightblue' => [0xAD, 0xD8, 0xE6], 'lightcoral' => [0xF0, 0x80, 0x80], 'lightcyan' => [0xE0, 0xFF, 0xFF], 'lightgoldenrodyellow' => [0xFA, 0xFA, 0xD2], 'lightgray' => [0xD3, 0xD3, 0xD3], 'lightgreen' => [0x90, 0xEE, 0x90], 'lightgrey' => [0xD3, 0xD3, 0xD3], 'lightpink' => [0xFF, 0xB6, 0xC1], 'lightsalmon' => [0xFF, 0xA0, 0x7A], 'lightseagreen' => [0x20, 0xB2, 0xAA], 'lightskyblue' => [0x87, 0xCE, 0xFA], 'lightslategray' => [0x77, 0x88, 0x99], 'lightslategrey' => [0x77, 0x88, 0x99], 'lightsteelblue' => [0xB0, 0xC4, 0xDE], 'lightyellow' => [0xFF, 0xFF, 0xE0], 'lime' => [0x00, 0xFF, 0x00], 'limegreen' => [0x32, 0xCD, 0x32], 'linen' => [0xFA, 0xF0, 0xE6], 'magenta' => [0xFF, 0x00, 0xFF], 'maroon' => [0x80, 0x00, 0x00], 'mediumaquamarine' => [0x66, 0xCD, 0xAA], 'mediumblue' => [0x00, 0x00, 0xCD], 'mediumorchid' => [0xBA, 0x55, 0xD3], 'mediumpurple' => [0x93, 0x70, 0xDB], 'mediumseagreen' => [0x3C, 0xB3, 0x71], 'mediumslateblue' => [0x7B, 0x68, 0xEE], 'mediumspringgreen' => [0x00, 0xFA, 0x9A], 'mediumturquoise' => [0x48, 0xD1, 0xCC], 'mediumvioletred' => [0xC7, 0x15, 0x85], 'midnightblue' => [0x19, 0x19, 0x70], 'mintcream' => [0xF5, 0xFF, 0xFA], 'mistyrose' => [0xFF, 0xE4, 0xE1], 'moccasin' => [0xFF, 0xE4, 0xB5], 'navajowhite' => [0xFF, 0xDE, 0xAD], 'navy' => [0x00, 0x00, 0x80], 'oldlace' => [0xFD, 0xF5, 0xE6], 'olive' => [0x80, 0x80, 0x00], 'olivedrab' => [0x6B, 0x8E, 0x23], 'orange' => [0xFF, 0xA5, 0x00], 'orangered' => [0xFF, 0x45, 0x00], 'orchid' => [0xDA, 0x70, 0xD6], 'palegoldenrod' => [0xEE, 0xE8, 0xAA], 'palegreen' => [0x98, 0xFB, 0x98], 'paleturquoise' => [0xAF, 0xEE, 0xEE], 'palevioletred' => [0xDB, 0x70, 0x93], 'papayawhip' => [0xFF, 0xEF, 0xD5], 'peachpuff' => [0xFF, 0xDA, 0xB9], 'peru' => [0xCD, 0x85, 0x3F], 'pink' => [0xFF, 0xC0, 0xCB], 'plum' => [0xDD, 0xA0, 0xDD], 'powderblue' => [0xB0, 0xE0, 0xE6], 'purple' => [0x80, 0x00, 0x80], 'red' => [0xFF, 0x00, 0x00], 'rosybrown' => [0xBC, 0x8F, 0x8F], 'royalblue' => [0x41, 0x69, 0xE1], 'saddlebrown' => [0x8B, 0x45, 0x13], 'salmon' => [0xFA, 0x80, 0x72], 'sandybrown' => [0xF4, 0xA4, 0x60], 'seagreen' => [0x2E, 0x8B, 0x57], 'seashell' => [0xFF, 0xF5, 0xEE], 'sienna' => [0xA0, 0x52, 0x2D], 'silver' => [0xC0, 0xC0, 0xC0], 'skyblue' => [0x87, 0xCE, 0xEB], 'slateblue' => [0x6A, 0x5A, 0xCD], 'slategray' => [0x70, 0x80, 0x90], 'slategrey' => [0x70, 0x80, 0x90], 'snow' => [0xFF, 0xFA, 0xFA], 'springgreen' => [0x00, 0xFF, 0x7F], 'steelblue' => [0x46, 0x82, 0xB4], 'tan' => [0xD2, 0xB4, 0x8C], 'teal' => [0x00, 0x80, 0x80], 'thistle' => [0xD8, 0xBF, 0xD8], 'tomato' => [0xFF, 0x63, 0x47], 'turquoise' => [0x40, 0xE0, 0xD0], 'violet' => [0xEE, 0x82, 0xEE], 'wheat' => [0xF5, 0xDE, 0xB3], 'white' => [0xFF, 0xFF, 0xFF], 'whitesmoke' => [0xF5, 0xF5, 0xF5], 'yellow' => [0xFF, 0xFF, 0x00], 'yellowgreen' => [0x9A, 0xCD, 0x32] ]; /** * Allocates a new blended color based on two existing allocated colors. * * Examples: * ``` * 1) { $opacityColor1 = 0.5; } $opacityColor2 = 1 - $opacityColor1; $componentsColor1 = self::colorComponents($image, $color1); $componentsColor2 = self::colorComponents($image, $color2); $red = (int)round( ($componentsColor1['red'] * $opacityColor1) + ($componentsColor2['red'] * $opacityColor2) ); $green = (int)round( ($componentsColor1['green'] * $opacityColor1) + ($componentsColor2['green'] * $opacityColor2) ); $blue = (int)round( ($componentsColor1['blue'] * $opacityColor1) + ($componentsColor2['blue'] * $opacityColor2) ); $alpha = (int)round( ($componentsColor1['alpha'] * $opacityColor1) + ($componentsColor2['alpha'] * $opacityColor2) ); return self::allocateColor($image, $red, $green, $blue, $alpha); } /** * Allocates a color based on a string. * * Examples: * ``` * 1) { throw new InvalidArgumentException( 'imagecolorallocatefromstring() received an invalid value for $string, input was: ' . $string ); } // Convert CSS opacity to GD alpha. CSS uses 1 as opaque and 0 as transparent while GD uses 0 as // opaque and 127 as transparent. $alpha = self::ALPHA_TRANSPARENT - (int)round(self::ALPHA_TRANSPARENT * $alphaValue); } } elseif (isset(self::CSS_COLOR_KEYWORDS[$string])) { [$red, $green, $blue] = self::CSS_COLOR_KEYWORDS[$string]; } else { throw new InvalidArgumentException( 'imagecolorallocatefromstring() received an invalid value for $string, input was: ' . $string ); } if ( $red > self::COLOR_COMPONENT_MAX || $green > self::COLOR_COMPONENT_MAX || $blue > self::COLOR_COMPONENT_MAX ) { throw new InvalidArgumentException( 'imagecolorallocatefromstring() received an invalid value for $string, input was: ' . $string ); } if ($alpha < self::ALPHA_OPAQUE || $alpha > self::ALPHA_TRANSPARENT) { throw new InvalidArgumentException( 'imagecolorallocatefromstring() received an invalid value for $alpha, input was: ' . $alpha ); } return self::allocateColor($image, $red, $green, $blue, $alpha, $hasAlpha); } /** * Draws a gradient filled rectangle. * * Examples: * ``` * |false Returns an array with 8 elements representing four points making the bounding box * of the text. The order of the points is lower left, lower right, upper right, upper left. The points are * relative to the text regardless of the angle, so "upper left" means in the top left-hand corner when you see the * text horizontally. Returns FALSE on error. */ public static function imagefttextfilter( GdImage $image, float $size, float $angle, int $x, int $y, int $color, string $fontFilename, string $text, array $options = [], int $filterIntensity = 0, int $filter = IMG_FILTER_GAUSSIAN_BLUR ): array|false { // Without a filter intensity there is nothing to apply, so fall back to imagettftext(). if ($filterIntensity <= 0) { return imagettftext($image, $size, $angle, $x, $y, $color, $fontFilename, $text, $options); } $renderedMask = self::renderTextMask($image, $size, $angle, $x, $y, $fontFilename, $text, $options); if ($renderedMask === false) { return false; } [$mask] = $renderedMask; // Apply the filter to the mask $filterIntensity times. for ($pass = 1; $pass <= $filterIntensity; $pass++) { imagefilter($mask, $filter); } $components = self::colorComponents($image, $color); // A fully transparent color scales every pixel’s visibility to zero, which leaves the image untouched and // makes plotTextMask() report the failure. $colorOpacity = (self::ALPHA_TRANSPARENT - $components['alpha']) / self::ALPHA_TRANSPARENT; return self::plotTextMask( $image, $mask, static function (int $maskX, int $maskY, float $visibility) use ($image, $components): int|false { // The filtered mask fades the glyphs out at their edges, so a pixel’s visibility becomes its opacity. return self::allocateColor( $image, $components['red'], $components['green'], $components['blue'], (int)round((1 - $visibility) * self::ALPHA_TRANSPARENT), true ); }, $colorOpacity ); } /** * An alias of imagefttextfilter(), using the more common “ttf” spelling of imagettftext(). * * @param GdImage $image A GdImage object, returned by one of the image creation * functions, such as imagecreatetruecolor(). * @param float $size The font size in points. * @param float $angle The angle in degrees. * @param int $x The x-ordinate of the basepoint of the first character. * @param int $y The y-ordinate of the font’s baseline. * @param int $color The color index. See imagecolorallocate(). * @param string $fontFilename The path to the TrueType font you wish to use. * @param string $text The text string in UTF-8 encoding. * @param array{linespacing?: float} $options The options passed through to imagettftext(). * @param int $filterIntensity The number of times you would like to apply the filter to your * text. Passing zero (the default) or a negative value applies no filter at all. * @param int $filter The filter you would like applied to your text. Defaults to a * Gaussian blur. See imagefilter() for the available filters. * * @alias imagefttextfilter() * * @return array|false Returns an array with 8 elements representing four points making the bounding box * of the text, or FALSE on error. */ public static function imagettftextfilter( GdImage $image, float $size, float $angle, int $x, int $y, int $color, string $fontFilename, string $text, array $options = [], int $filterIntensity = 0, int $filter = IMG_FILTER_GAUSSIAN_BLUR ): array|false { return self::imagefttextfilter( $image, $size, $angle, $x, $y, $color, $fontFilename, $text, $options, $filterIntensity, $filter ); } /** * An alias of imagefttextfilter(). The filter defaults to a Gaussian blur, so this is a convenient name for * blurring text and for the glow and shadow effects built on top of a blur. * * @param GdImage $image A GdImage object, returned by one of the image creation * functions, such as imagecreatetruecolor(). * @param float $size The font size in points. * @param float $angle The angle in degrees. * @param int $x The x-ordinate of the basepoint of the first character. * @param int $y The y-ordinate of the font’s baseline. * @param int $color The color index. See imagecolorallocate(). * @param string $fontFilename The path to the TrueType font you wish to use. * @param string $text The text string in UTF-8 encoding. * @param array{linespacing?: float} $options The options passed through to imagettftext(). * @param int $blurIntensity The number of times you would like to apply the blur to your * text. Passing zero (the default) or a negative value applies no blur at all. * @param int $blurFilter The filter you would like applied to your text. Defaults to a * Gaussian blur. See imagefilter() for the available filters. * * @alias imagefttextfilter() * * @return array|false Returns an array with 8 elements representing four points making the bounding box * of the text, or FALSE on error. */ public static function imagettftextblur( GdImage $image, float $size, float $angle, int $x, int $y, int $color, string $fontFilename, string $text, array $options = [], int $blurIntensity = 0, int $blurFilter = IMG_FILTER_GAUSSIAN_BLUR ): array|false { return self::imagefttextfilter( $image, $size, $angle, $x, $y, $color, $fontFilename, $text, $options, $blurIntensity, $blurFilter ); } /** * An alias of imagefttextfilter(), pairing the “ft” spelling with the blur-oriented name. The filter defaults to a * Gaussian blur, so this is a convenient name for blurring text and for the glow and shadow effects built on top of * a blur. * * @param GdImage $image A GdImage object, returned by one of the image creation * functions, such as imagecreatetruecolor(). * @param float $size The font size in points. * @param float $angle The angle in degrees. * @param int $x The x-ordinate of the basepoint of the first character. * @param int $y The y-ordinate of the font’s baseline. * @param int $color The color index. See imagecolorallocate(). * @param string $fontFilename The path to the TrueType font you wish to use. * @param string $text The text string in UTF-8 encoding. * @param array{linespacing?: float} $options The options passed through to imagettftext(). * @param int $blurIntensity The number of times you would like to apply the blur to your * text. Passing zero (the default) or a negative value applies no blur at all. * @param int $blurFilter The filter you would like applied to your text. Defaults to a * Gaussian blur. See imagefilter() for the available filters. * * @alias imagefttextfilter() * * @return array|false Returns an array with 8 elements representing four points making the bounding box * of the text, or FALSE on error. */ public static function imagefttextblur( GdImage $image, float $size, float $angle, int $x, int $y, int $color, string $fontFilename, string $text, array $options = [], int $blurIntensity = 0, int $blurFilter = IMG_FILTER_GAUSSIAN_BLUR ): array|false { return self::imagefttextfilter( $image, $size, $angle, $x, $y, $color, $fontFilename, $text, $options, $blurIntensity, $blurFilter ); } /** * A drop-in replacement for imagettftext() with added parameters to add gradient coloring effects. * * Examples: * ``` * |false Returns an array with 8 elements representing four points making the bounding box * of the text. The order of the points is lower left, lower right, upper right, upper left. The points are * relative to the text regardless of the angle, so "upper left" means in the top left-hand corner when you see the * text horizontally. Returns FALSE on error. */ public static function imagefttextgradient( GdImage $image, float $size, float $angle, int $x, int $y, int $color, string $fontFilename, string $text, array $options = [], ?int $gradientColor = null, bool $horizontalGradient = false ): array|false { // Without a gradient color there is no gradient to draw, so fall back to imagettftext(). if ($gradientColor === null) { return imagettftext($image, $size, $angle, $x, $y, $color, $fontFilename, $text, $options); } $renderedMask = self::renderTextMask($image, $size, $angle, $x, $y, $fontFilename, $text, $options); if ($renderedMask === false) { return false; } // The text’s own bounding box is what the gradient is measured against, so a pixel’s position within the text // — not within the image — decides its color. [$mask, $textBoundingBox] = $renderedMask; $textLeft = min($textBoundingBox[0], $textBoundingBox[6]); $textRight = max($textBoundingBox[2], $textBoundingBox[4]); $textTop = min($textBoundingBox[5], $textBoundingBox[7]); $textBottom = max($textBoundingBox[1], $textBoundingBox[3]); $textWidth = $textRight - $textLeft; $textHeight = $textBottom - $textTop; $components = self::colorComponents($image, $color); $gradientComponents = self::colorComponents($image, $gradientColor); return self::plotTextMask( $image, $mask, static function ( int $maskX, int $maskY, float $visibility ) use ( $image, $components, $gradientComponents, $horizontalGradient, $textLeft, $textTop, $textWidth, $textHeight ): int|false { // Calculate how far through the gradient this pixel sits, as a value between 0 and 1. Antialiased // pixels can spill just outside the text’s bounding box, so the position is clamped to keep the // interpolated color in range. if ($horizontalGradient) { $gradientPosition = $textWidth > 0 ? ($maskX - $textLeft) / $textWidth : 0.0; } else { $gradientPosition = $textHeight > 0 ? ($maskY - $textTop) / $textHeight : 0.0; } $gradientPosition = min(1.0, max(0.0, $gradientPosition)); $red = (int)round( ($components['red'] * (1 - $gradientPosition)) + ($gradientComponents['red'] * $gradientPosition) ); $green = (int)round( ($components['green'] * (1 - $gradientPosition)) + ($gradientComponents['green'] * $gradientPosition) ); $blue = (int)round( ($components['blue'] * (1 - $gradientPosition)) + ($gradientComponents['blue'] * $gradientPosition) ); $gradientAlpha = ($components['alpha'] * (1 - $gradientPosition)) + ($gradientComponents['alpha'] * $gradientPosition); return self::allocateColor( $image, $red, $green, $blue, (int)round( self::ALPHA_TRANSPARENT - (self::ALPHA_TRANSPARENT - $gradientAlpha) * $visibility ), true ); } ); } /** * An alias of imagefttextgradient(), using the more common “ttf” spelling of imagettftext(). * * @param GdImage $image A GdImage object, returned by one of the image creation * functions, such as imagecreatetruecolor(). * @param float $size The font size in points. * @param float $angle The angle in degrees. * @param int $x The x-ordinate of the basepoint of the first character. * @param int $y The y-ordinate of the font’s baseline. * @param int $color The start color. See imagecolorallocate(). * @param string $fontFilename The path to the TrueType font you wish to use. * @param string $text The text string in UTF-8 encoding. * @param array{linespacing?: float} $options The options passed through to imagettftext(). * @param ?int $gradientColor The finish color. Passing NULL, the default, draws the text * in a solid $color. * @param bool $horizontalGradient Whether or not to use a horizontal gradient versus a * vertical gradient. * * @alias imagefttextgradient() * * @return array|false Returns an array with 8 elements representing four points making the bounding box * of the text, or FALSE on error. */ public static function imagettftextgradient( GdImage $image, float $size, float $angle, int $x, int $y, int $color, string $fontFilename, string $text, array $options = [], ?int $gradientColor = null, bool $horizontalGradient = false ): array|false { return self::imagefttextgradient( $image, $size, $angle, $x, $y, $color, $fontFilename, $text, $options, $gradientColor, $horizontalGradient ); } /** * Automatically inserts line breaks into text intended for imagettftext(). * * Examples: * ``` * $word) { // Place the first word into $currentLine without further processing. If it is too wide, later logic can // only force-break it when another word causes the loop to enter the normal processing branch. if ($position === 0) { $currentLine = $word; continue; } $addedWord = false; // Check whether adding the new word to the current line still fits within the maximum width. if (self::ttfWidthFits($size, $angle, $fontFilename, $currentLine . ' ' . $word, $maximumWidth)) { $currentLine .= ' ' . $word; $addedWord = true; } // If the final word would appear alone on the last line, try moving the previous word down with it. if (!$addedWord && $preventWidows && $position === count($words) - 1) { $lastSpacePosition = strrpos($currentLine, ' '); if ($lastSpacePosition !== false) { $previousLine = substr($currentLine, 0, $lastSpacePosition); $lastWord = substr($currentLine, $lastSpacePosition + 1); $testLine = $lastWord . ' ' . $word; if (self::ttfWidthFits($size, $angle, $fontFilename, $testLine, $maximumWidth)) { $textWithLineBreaks .= $previousLine . $lineBreakCharacter; $currentLine = $testLine; $addedWord = true; } } } // Attempt to split the word on hyphens and fit as much of it as possible on the current line. if (!$addedWord && $attemptToBreakOnHyphens && str_contains($word, '-')) { $hyphenParts = explode('-', $word); $rebuiltWord = ''; foreach ($hyphenParts as $index => $part) { // Rebuild the word progressively, re-adding hyphens between parts. $candidate = ($rebuiltWord === '' ? $part : $rebuiltWord . '-' . $part); if ( self::ttfWidthFits( $size, $angle, $fontFilename, $currentLine . ' ' . $candidate, $maximumWidth ) ) { $rebuiltWord = $candidate; continue; } // If we have something that fits, commit it. if ($rebuiltWord !== '') { $currentLine .= ' ' . $rebuiltWord . '-'; $textWithLineBreaks .= $currentLine . $lineBreakCharacter; // Remaining parts become the next word. $word = implode('-', array_slice($hyphenParts, $index)); $currentLine = $word; $addedWord = true; } break; } } if (!$addedWord && $forceBreakOnSingleWords) { [$textWithLineBreaks, $currentLine, $addedWord] = self::forceBreakWord( $size, $angle, $fontFilename, $word, $maximumWidth, $lineBreakCharacter, $textWithLineBreaks, $currentLine ); } // If the word still has not been added, the text is too wide with the added word, so add a line break and // start a new line with only that word. if (!$addedWord) { $textWithLineBreaks .= $currentLine . $lineBreakCharacter; $currentLine = $word; } } // Append the final line to the processed text. return $textWithLineBreaks . $currentLine; } /** * An alias of linebreaksfortext(), using the original standalone function’s name. * * @param float $size The font size in points. * @param float $angle The angle in degrees. * @param string $fontFilename The path to the TrueType font you wish to use. * @param string $text The text string in UTF-8 encoding. * @param int $maximumWidth The maximum width (in pixels) a line should be before adding a line break. * @param string $lineBreakCharacter The character(s) to use when adding a line break. * @param bool $attemptToBreakOnHyphens Whether or not to attempt to break words on the hyphen(s) appearing * within. * @param bool $forceBreakOnSingleWords Whether or not to force breaks into single words that extend beyond a * single line. * @param bool $preventWidows Whether or not to try to prevent widows. * * @alias linebreaksfortext() * * @return string Returns $text with line breaks added. */ public static function linebreaks4imagettftext( float $size, float $angle, string $fontFilename, string $text, int $maximumWidth, string $lineBreakCharacter = PHP_EOL, bool $attemptToBreakOnHyphens = false, bool $forceBreakOnSingleWords = false, bool $preventWidows = false ): string { return self::linebreaksfortext( $size, $angle, $fontFilename, $text, $maximumWidth, $lineBreakCharacter, $attemptToBreakOnHyphens, $forceBreakOnSingleWords, $preventWidows ); } /** * An alias of linebreaksfortext(), pairing the “4image” naming with the “ft” spelling of imagefttext(). * * @param float $size The font size in points. * @param float $angle The angle in degrees. * @param string $fontFilename The path to the TrueType font you wish to use. * @param string $text The text string in UTF-8 encoding. * @param int $maximumWidth The maximum width (in pixels) a line should be before adding a line break. * @param string $lineBreakCharacter The character(s) to use when adding a line break. * @param bool $attemptToBreakOnHyphens Whether or not to attempt to break words on the hyphen(s) appearing * within. * @param bool $forceBreakOnSingleWords Whether or not to force breaks into single words that extend beyond a * single line. * @param bool $preventWidows Whether or not to try to prevent widows. * * @alias linebreaksfortext() * * @return string Returns $text with line breaks added. */ public static function linebreaks4imagefttext( float $size, float $angle, string $fontFilename, string $text, int $maximumWidth, string $lineBreakCharacter = PHP_EOL, bool $attemptToBreakOnHyphens = false, bool $forceBreakOnSingleWords = false, bool $preventWidows = false ): string { return self::linebreaksfortext( $size, $angle, $fontFilename, $text, $maximumWidth, $lineBreakCharacter, $attemptToBreakOnHyphens, $forceBreakOnSingleWords, $preventWidows ); } /** * An alias of linebreaksfortext(), using a shorter name for the same behaviour. * * @param float $size The font size in points. * @param float $angle The angle in degrees. * @param string $fontFilename The path to the TrueType font you wish to use. * @param string $text The text string in UTF-8 encoding. * @param int $maximumWidth The maximum width (in pixels) a line should be before adding a line break. * @param string $lineBreakCharacter The character(s) to use when adding a line break. * @param bool $attemptToBreakOnHyphens Whether or not to attempt to break words on the hyphen(s) appearing * within. * @param bool $forceBreakOnSingleWords Whether or not to force breaks into single words that extend beyond a * single line. * @param bool $preventWidows Whether or not to try to prevent widows. * * @alias linebreaksfortext() * * @return string Returns $text with line breaks added. */ public static function linebreaks4text( float $size, float $angle, string $fontFilename, string $text, int $maximumWidth, string $lineBreakCharacter = PHP_EOL, bool $attemptToBreakOnHyphens = false, bool $forceBreakOnSingleWords = false, bool $preventWidows = false ): string { return self::linebreaksfortext( $size, $angle, $fontFilename, $text, $maximumWidth, $lineBreakCharacter, $attemptToBreakOnHyphens, $forceBreakOnSingleWords, $preventWidows ); } /** * An alias of linebreaksfortext(), pairing the “for” naming with the “ft” spelling of imagefttext(). * * @param float $size The font size in points. * @param float $angle The angle in degrees. * @param string $fontFilename The path to the TrueType font you wish to use. * @param string $text The text string in UTF-8 encoding. * @param int $maximumWidth The maximum width (in pixels) a line should be before adding a line break. * @param string $lineBreakCharacter The character(s) to use when adding a line break. * @param bool $attemptToBreakOnHyphens Whether or not to attempt to break words on the hyphen(s) appearing * within. * @param bool $forceBreakOnSingleWords Whether or not to force breaks into single words that extend beyond a * single line. * @param bool $preventWidows Whether or not to try to prevent widows. * * @alias linebreaksfortext() * * @return string Returns $text with line breaks added. */ public static function linebreaksforimagefttext( float $size, float $angle, string $fontFilename, string $text, int $maximumWidth, string $lineBreakCharacter = PHP_EOL, bool $attemptToBreakOnHyphens = false, bool $forceBreakOnSingleWords = false, bool $preventWidows = false ): string { return self::linebreaksfortext( $size, $angle, $fontFilename, $text, $maximumWidth, $lineBreakCharacter, $attemptToBreakOnHyphens, $forceBreakOnSingleWords, $preventWidows ); } /** * An alias of linebreaksfortext(), pairing the “for” naming with the “ttf” spelling of imagettftext(). * * @param float $size The font size in points. * @param float $angle The angle in degrees. * @param string $fontFilename The path to the TrueType font you wish to use. * @param string $text The text string in UTF-8 encoding. * @param int $maximumWidth The maximum width (in pixels) a line should be before adding a line break. * @param string $lineBreakCharacter The character(s) to use when adding a line break. * @param bool $attemptToBreakOnHyphens Whether or not to attempt to break words on the hyphen(s) appearing * within. * @param bool $forceBreakOnSingleWords Whether or not to force breaks into single words that extend beyond a * single line. * @param bool $preventWidows Whether or not to try to prevent widows. * * @alias linebreaksfortext() * * @return string Returns $text with line breaks added. */ public static function linebreaksforimagettftext( float $size, float $angle, string $fontFilename, string $text, int $maximumWidth, string $lineBreakCharacter = PHP_EOL, bool $attemptToBreakOnHyphens = false, bool $forceBreakOnSingleWords = false, bool $preventWidows = false ): string { return self::linebreaksfortext( $size, $angle, $fontFilename, $text, $maximumWidth, $lineBreakCharacter, $attemptToBreakOnHyphens, $forceBreakOnSingleWords, $preventWidows ); } /** * Breaks a single word that is too wide for one line across as many lines as it needs, hyphenating each segment. * * @param float $size The font size in points. * @param float $angle The angle in degrees. * @param string $fontFilename The path to the TrueType font you wish to use. * @param string $word The word to force a break into. * @param int $maximumWidth The maximum width (in pixels) a line should be before adding a line break. * @param string $lineBreakCharacter The character(s) to use when adding a line break. * @param string $textWithLineBreaks The processed text so far. * @param string $currentLine The line currently being built. * * @return array{0: string, 1: string, 2: bool} The processed text, the current line and whether the word was * added. */ private static function forceBreakWord( float $size, float $angle, string $fontFilename, string $word, int $maximumWidth, string $lineBreakCharacter, string $textWithLineBreaks, string $currentLine ): array { // A word is only ever force-broken from the start of a line, so commit whatever the current line already holds // and work from an empty one. Every segment below is therefore measured on its own. if ($currentLine !== '') { $textWithLineBreaks .= $currentLine . $lineBreakCharacter; } $remainingCharacters = preg_split('//u', $word, -1, PREG_SPLIT_NO_EMPTY); if ($remainingCharacters === false) { // The word cannot be split (invalid UTF-8). The current line was already committed above, so leave the // word whole as the new current line and report it as handled — otherwise the caller's !$addedWord // fallback appends a second line break against an empty line, producing a spurious blank line. return [$textWithLineBreaks, $word, true]; } while (count($remainingCharacters) > 0) { // Take as many characters as will fit on a line, one at a time. $characterCount = 0; $candidateWord = ''; foreach ($remainingCharacters as $index => $character) { $testCandidateWord = $candidateWord . $character; // Every segment but the last one gets a trailing hyphen, so the hyphen has to fit on the line too. $hasRemainingCharacters = ($index < count($remainingCharacters) - 1); $testLine = $testCandidateWord . ($hasRemainingCharacters ? '-' : ''); if (!self::ttfWidthFits($size, $angle, $fontFilename, $testLine, $maximumWidth)) { break; } $candidateWord = $testCandidateWord; $characterCount++; } // If not even a single character and a hyphen fit on an empty line, no break is possible at all, so take // the whole remainder to avoid looping forever. if ($candidateWord === '') { return [$textWithLineBreaks, implode('', $remainingCharacters), true]; } $remainingCharacters = array_slice($remainingCharacters, $characterCount); // The final segment stays on the current line so that the words after it can still join it. if (count($remainingCharacters) === 0) { return [$textWithLineBreaks, $candidateWord, true]; } // More characters remain, so hyphenate this segment and commit it as its own line. $textWithLineBreaks .= $candidateWord . '-' . $lineBreakCharacter; } return [$textWithLineBreaks, '', true]; } /** * Allocates a color, using an RGBA color identifier when the color is translucent and an RGB color identifier * otherwise. * * @param GdImage $image A GdImage object. * @param int $red A value between 0 and 255. * @param int $green A value between 0 and 255. * @param int $blue A value between 0 and 255. * @param int $alpha A value between 0 and 127. 0 indicates completely opaque while 127 indicates * completely transparent. * @param bool $forceAlphaChannel Whether to allocate an RGBA color identifier even when $alpha is opaque. * * @return int|false Returns a color identifier or FALSE if the allocation failed. */ private static function allocateColor( GdImage $image, int $red, int $green, int $blue, int $alpha = self::ALPHA_OPAQUE, bool $forceAlphaChannel = false ): int|false { // GD raises a ValueError for a component outside its range, and the blending and gradient math rounds floats, // so every component is clamped here rather than trusted. $red = max(0, min(self::COLOR_COMPONENT_MAX, $red)); $green = max(0, min(self::COLOR_COMPONENT_MAX, $green)); $blue = max(0, min(self::COLOR_COMPONENT_MAX, $blue)); $alpha = max(self::ALPHA_OPAQUE, min(self::ALPHA_TRANSPARENT, $alpha)); if ($forceAlphaChannel || $alpha > self::ALPHA_OPAQUE) { return imagecolorallocatealpha($image, $red, $green, $blue, $alpha); } return imagecolorallocate($image, $red, $green, $blue); } /** * Resolves a color identifier into its red, green, blue and alpha components. * * A negative color index turns off antialiasing in imagettftext(), so the sign is dropped before the components * are read. * * @param GdImage $image A GdImage object. * @param int $color A color identifier created with imagecolorallocate(). * * @return array{red: int, green: int, blue: int, alpha: int} The color’s components. */ private static function colorComponents(GdImage $image, int $color): array { return imagecolorsforindex($image, abs($color)); } /** * Renders white text on a black background in an image the same size as $image. * * Both imagefttextfilter() and imagefttextgradient() work by rendering the text once into this grayscale mask and * then reading each pixel’s brightness back out as that pixel’s visibility, which is what lets them recolor the * antialiased edges of the glyphs. * * @param GdImage $image A GdImage object. * @param float $size The font size in points. * @param float $angle The angle in degrees. * @param int $x The x-ordinate of the basepoint of the first character. * @param int $y The y-ordinate of the font’s baseline. * @param string $fontFilename The path to the TrueType font you wish to use. * @param string $text The text string in UTF-8 encoding. * @param array{linespacing?: float} $options The options passed through to imagettftext(). * * @return array{0: GdImage, 1: array}|false Returns the mask and the text’s bounding box, as reported by * imagettftext(), or FALSE if the mask could not be rendered. */ private static function renderTextMask( GdImage $image, float $size, float $angle, int $x, int $y, string $fontFilename, string $text, array $options ): array|false { $mask = imagecreatetruecolor(imagesx($image), imagesy($image)); // The dimensions come from an existing image so are always >= 1; the only route to false is memory // exhaustion, which cannot be triggered deterministically in a test. // @codeCoverageIgnoreStart if ($mask === false) { return false; } // @codeCoverageIgnoreEnd $black = imagecolorallocate($mask, 0x00, 0x00, 0x00); $white = imagecolorallocate($mask, 0xFF, 0xFF, 0xFF); // imagecolorallocate() only returns false when a palette image runs out of slots, and $mask is a truecolor // image with no palette; the literal components cannot be out of range. // @codeCoverageIgnoreStart if ($black === false || $white === false) { return false; } // @codeCoverageIgnoreEnd imagefill($mask, 0, 0, $black); $textBoundingBox = imagettftext($mask, $size, $angle, $x, $y, $white, $fontFilename, $text, $options); if ($textBoundingBox === false) { return false; } return [$mask, $textBoundingBox]; } /** * Copies every visible pixel of a text mask onto an image, coloring each one with $allocatePixelColor. * * @param GdImage $image A GdImage object. * @param GdImage $mask A mask from renderTextMask(). * @param callable(int, int, float): (int|false) $allocatePixelColor Receives the pixel’s x-ordinate, y-ordinate * and visibility, and returns the color identifier to set that pixel to. * @param float $visibilityScale Scales every pixel’s visibility. A scale of * zero makes every pixel invisible, which leaves $image untouched and returns FALSE. * * @return array|false Returns the bounding box of the pixels that were set, or FALSE if no pixel was * visible. */ private static function plotTextMask( GdImage $image, GdImage $mask, callable $allocatePixelColor, float $visibilityScale = 1.0 ): array|false { // The bounding box starts inverted — every coordinate at the opposite extreme — so that the first visible // pixel replaces all eight values and each pixel after it can only widen the box. $emptyBoundingBox = [ imagesx($image), // Lower left (x coordinate) -1, // Lower left (y coordinate) -1, // Lower right (x coordinate) -1, // Lower right (y coordinate) -1, // Upper right (x coordinate) imagesy($image), // Upper right (y coordinate) imagesx($image), // Upper left (x coordinate) imagesy($image) // Upper left (y coordinate) ]; $boundingBox = $emptyBoundingBox; $width = imagesx($mask); $height = imagesy($mask); for ($maskX = 0; $maskX < $width; $maskX++) { for ($maskY = 0; $maskY < $height; $maskY++) { // The mask is grayscale, so any one channel is the pixel’s brightness, which is its visibility. $brightness = (int)imagecolorat($mask, $maskX, $maskY) & self::COLOR_COMPONENT_MAX; $visibility = $brightness / self::COLOR_COMPONENT_MAX * $visibilityScale; if ($visibility <= 0) { continue; } $boundingBox = [ min($boundingBox[0], $maskX), max($boundingBox[1], $maskY), max($boundingBox[2], $maskX), max($boundingBox[3], $maskY), max($boundingBox[4], $maskX), min($boundingBox[5], $maskY), min($boundingBox[6], $maskX), min($boundingBox[7], $maskY) ]; $pixelColor = $allocatePixelColor($maskX, $maskY, $visibility); if ($pixelColor !== false) { imagesetpixel($image, $maskX, $maskY, $pixelColor); } } } // An unchanged bounding box means no pixel was ever visible, which is a failure. if ($boundingBox === $emptyBoundingBox) { return false; } return $boundingBox; } /** * Calculates whether a string rendered by imagettftext() fits within a maximum width. * * A string whose width cannot be measured is treated as fitting, which leaves the text unbroken rather than * breaking it at every word. * * @param float $size The font size in points. * @param float $angle The angle in degrees. * @param string $fontFilename The path to the TrueType font you wish to use. * @param string $text The text string in UTF-8 encoding. * @param int $maximumWidth The maximum width in pixels. * * @return bool Returns TRUE if the text fits within $maximumWidth. */ private static function ttfWidthFits( float $size, float $angle, string $fontFilename, string $text, int $maximumWidth ): bool { $boundingBox = imagettfbbox($size, $angle, $fontFilename, $text); if ($boundingBox === false) { return true; } $left = min($boundingBox[0], $boundingBox[2], $boundingBox[4], $boundingBox[6]); $right = max($boundingBox[0], $boundingBox[2], $boundingBox[4], $boundingBox[6]); return ($right - $left) <= $maximumWidth; } }