diff --git a/composer.json b/composer.json index 4f5d40a..f975df8 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,7 @@ "require-dev": { "friendsofphp/php-cs-fixer": "^3.13", "sapientpro/image-comparator": "^1.0", - "pestphp/pest": "^2.17" + "pestphp/pest": "^2.29" }, "autoload": { "psr-4": { diff --git a/src/Alterations/AbstractText.php b/src/Alterations/AbstractText.php new file mode 100644 index 0000000..05d24d4 --- /dev/null +++ b/src/Alterations/AbstractText.php @@ -0,0 +1,351 @@ +parseColor($text->color); + $box = $text->getBox(); + + $opt = []; + $interlinePixels = 0; + if ($text->interline !== null) { + $opt['linespacing'] = $text->interline; + $interlinePixels = $text->interline * $text->getPointSize(); + $interlinePixels -= $text->getPointSize(); + } + + if ($text->background !== null) { + $bgY = $y; + $bgOffset = -4.25 * (1 / 50 * $text->size); + if ($text->interline !== null) { + $interlinePixels += $bgOffset; + $interlinePixels /= 0.65; + } + foreach ($text->getMultiLineBoxes() as $lineBox) { + $image->rectangle( + $x + $bgOffset, + $bgY - $bgOffset, + $x + $lineBox->upperRight->x - $bgOffset, + $bgY + $lineBox->upperRight->y + $bgOffset, + fn (Rectangle $r) => $r->background($text->background)->border(0) + ); + $bgY -= $box->upperRight->y - ($text->getPointSize() * 0.72) - $interlinePixels; + } + } + + if ($text->hasFont()) { + if ($text->angle !== 0 || $text->align !== Position::TOP_LEFT) { + switch ($text->align) { + case Position::TOP: + case Position::TOP_MIDDLE: + $x -= round(($box->upperLeft->x + $box->upperRight->x) / 2); + $y -= round(($box->upperLeft->y + $box->upperRight->y) / 2); + + break; + case Position::RIGHT: + case Position::TOP_RIGHT: + $x -= $box->upperRight->x; + $y -= $box->upperRight->y; + + break; + case Position::LEFT: + case Position::TOP_LEFT: + $x -= $box->upperLeft->x; + $y -= $box->upperLeft->y; + + break; + case Position::CENTER_MIDDLE: + case Position::CENTER: + $x -= round(($box->lowerLeft->x + $box->upperRight->x) / 2); + $y -= round(($box->lowerLeft->y + $box->upperRight->y) / 2); + + break; + case Position::CENTER_RIGHT: + $x -= round(($box->lowerRight->x + $box->upperRight->x) / 2); + $y -= round(($box->lowerRight->y + $box->upperRight->y) / 2); + + break; + + case Position::CENTER_LEFT: + $x -= round(($box->lowerLeft->x + $box->upperLeft->x) / 2); + $y -= round(($box->lowerLeft->y + $box->upperLeft->y) / 2); + + break; + case Position::BOTTOM: + case Position::BOTTOM_MIDDLE: + $x -= round(($box->lowerLeft->x + $box->lowerRight->x) / 2); + $y -= round(($box->lowerLeft->y + $box->lowerRight->y) / 2); + + break; + case Position::BOTTOM_RIGHT: + $x -= $box->lowerRight->x; + $y -= $box->lowerRight->y; + + break; + case Position::BOTTOM_LEFT: + $x -= $box->lowerLeft->x; + $y -= $box->lowerLeft->y; + + break; + } + } + + if ($text->stroke !== null) { + $strokeColor = $driver->parseColor($text->strokeColor); + $stroke = $text->stroke; + for ($sx = ($x - abs($stroke)); $sx <= ($x + abs($stroke)); $sx++) { + for ($sy = ($y - abs($stroke)); $sy <= ($y + abs($stroke)); $sy++) { + imagettftext( + $image->getCore(), + $text->getPointSize(), + $text->angle, + $sx, + $sy, + $strokeColor->getInt(), + $text->fontPath, + $text->parsedText(), + $opt + ); + } + } + } + + if ($text->hasShadow()) { + $shadowColor = $driver->parseColor($text->shadowColor); + imagettftext( + $image->getCore(), + $text->getPointSize(), + $text->angle, + $x + $text->shadowX, + $y + $text->shadowY, + $shadowColor->getInt(), + $text->fontPath, + $text->parsedText(), + $opt + ); + } + + // enable alphablending for imagettftext + imagealphablending($image->getCore(), true); + + // draw ttf text + imagettftext( + $image->getCore(), + $text->getPointSize(), + $text->angle, + $x, + $y, + $color->getInt(), + $text->fontPath, + $text->parsedText(), + $opt + ); + + return; + } + + // get box size + $size = $box->getSize(); + $width = $size->width; + $height = $size->height; + + // internal font specific position corrections + if ($text->getInternalFont() === 1) { + $topFix = 1; + $bottomFix = 2; + } elseif ($text->getInternalFont() === 3) { + $topFix = 2; + $bottomFix = 4; + } else { + $topFix = 3; + $bottomFix = 4; + } + + // x-position corrections for horizontal alignment + switch ($text->align) { + case Position::CENTER: + case Position::CENTER_MIDDLE: + case Position::TOP_MIDDLE: + case Position::BOTTOM_MIDDLE: + $x = ceil($x - ($width / 2)); + + break; + + case Position::RIGHT: + case Position::CENTER_RIGHT: + case Position::TOP_RIGHT: + case Position::BOTTOM_RIGHT: + $x = ceil($x - $width) + 1; + + break; + } + + // y-position corrections for vertical alignment + switch ($text->align) { + case Position::CENTER: + case Position::CENTER_MIDDLE: + case Position::CENTER_LEFT: + case Position::CENTER_RIGHT: + $y = ceil($y - ($height / 2)); + + break; + + case Position::TOP: + case Position::TOP_MIDDLE: + case Position::TOP_LEFT: + case Position::TOP_RIGHT: + $y = ceil($y - $topFix); + + break; + + default: + case Position::BOTTOM: + case Position::BOTTOM_MIDDLE: + case Position::BOTTOM_LEFT: + case Position::BOTTOM_RIGHT: + $y = round($y - $height + $bottomFix); + + break; + } + + if ($text->stroke !== null) { + $strokeColor = $driver->parseColor($text->strokeColor); + $stroke = $text->stroke; + for ($sx = ($x - abs($stroke)); $sx <= ($x + abs($stroke)); $sx++) { + for ($sy = ($y - abs($stroke)); $sy <= ($y + abs($stroke)); $sy++) { + imagestring( + $image->getCore(), + $text->getInternalFont(), + $sx, + $sy, + $text->text, + $strokeColor->getInt() + ); + } + } + } + + if ($text->hasShadow()) { + $shadowColor = $driver->parseColor($text->shadowColor); + imagestring( + $image->getCore(), + $text->getInternalFont(), + $x + $text->shadowX, + $y + $text->shadowY, + $text->text, + $shadowColor->getInt() + ); + } + + // draw text + imagestring($image->getCore(), $text->getInternalFont(), $x, $y, $text->text, $color->getInt()); + + } + + public function writeWithImagick(Image $image, ImagickText $text, Driver $driver, int $x, int $y): void + { + $color = $driver->parseColor($text->color); + + $draw = new \ImagickDraw(); + $draw->setTextAntialias(true); + $draw->setFont($text->fontPath); + $draw->setFontSize($text->size); + $draw->setFillColor($color->getPixel()); + $draw->setTextKerning($text->kerning); + if ($text->interline !== null) { + $interline = $text->interline * $text->getPointSize(); + $interline -= $text->getPointSize(); + $draw->setTextInterLineSpacing($interline); + } + + switch ($text->align) { + case Position::CENTER: + case Position::CENTER_MIDDLE: + case Position::TOP_MIDDLE: + case Position::BOTTOM_MIDDLE: + $align = \Imagick::ALIGN_CENTER; + + break; + + case Position::RIGHT: + case Position::CENTER_RIGHT: + case Position::TOP_RIGHT: + case Position::BOTTOM_RIGHT: + $align = \Imagick::ALIGN_RIGHT; + + break; + default: + case Position::LEFT: + case Position::CENTER_LEFT: + case Position::TOP_LEFT: + case Position::BOTTOM_LEFT: + $align = \Imagick::ALIGN_LEFT; + + break; + } + + $draw->setTextAlignment($align); + + switch ($text->align) { + case Position::CENTER: + case Position::CENTER_MIDDLE: + case Position::CENTER_LEFT: + case Position::CENTER_RIGHT: + case Position::TOP: + case Position::TOP_MIDDLE: + case Position::TOP_LEFT: + case Position::TOP_RIGHT: + $dimensions = $image->getCore()->queryFontMetrics($draw, $text->text); + $y = $y + $dimensions['textHeight'] * 0.65 / 2; + + break; + + default: + case Position::BOTTOM: + case Position::BOTTOM_MIDDLE: + case Position::BOTTOM_LEFT: + case Position::BOTTOM_RIGHT: + $dimensions = $image->getCore()->queryFontMetrics($draw, $text->text); + $y += $dimensions['characterHeight']; + + break; + } + + if ($text->background !== null) { + $draw->setTextUnderColor($driver->parseColor($text->background)->getPixel()); + } + + if ($text->stroke !== null) { + $draw->setStrokeColor($driver->parseColor($text->strokeColor)->getPixel()); + $draw->setStrokeWidth($text->stroke); + $draw->setStrokeAntialias(true); + } + + if ($text->hasShadow()) { + $draw->setFillColor($driver->parseColor($text->shadowColor)->getPixel()); + $image->getCore()->annotateImage( + $draw, + $x + $text->shadowX, + $y + $text->shadowY, + $text->angle * (-1), + $text->text + ); + $draw->setFillColor($color->getPixel()); + } + + $image->getCore()->annotateImage($draw, $x, $y, $text->angle * (-1), $text->text); + } +} diff --git a/src/Alterations/WriteText.php b/src/Alterations/WriteText.php index f9a7a6d..f1625a0 100644 --- a/src/Alterations/WriteText.php +++ b/src/Alterations/WriteText.php @@ -4,17 +4,13 @@ use Closure; use RuntimeException; -use SergiX44\ImageZen\Alteration; -use SergiX44\ImageZen\Draws\Position; use SergiX44\ImageZen\Drivers\Gd\Gd; -use SergiX44\ImageZen\Drivers\Gd\GdAlteration; use SergiX44\ImageZen\Drivers\Gd\GdText; use SergiX44\ImageZen\Drivers\Imagick\Imagick; -use SergiX44\ImageZen\Drivers\Imagick\ImagickAlteration; use SergiX44\ImageZen\Drivers\Imagick\ImagickText; use SergiX44\ImageZen\Image; -class WriteText extends Alteration implements GdAlteration, ImagickAlteration +class WriteText extends AbstractText { public static string $id = 'text'; @@ -41,140 +37,7 @@ public function applyWithGd(Image $image): null throw new RuntimeException('Invalid driver for this alteration'); } - $color = $driver->parseColor($text->color); - $box = $text->getBox(); - if ($text->hasFont()) { - if ($text->angle !== 0 || $text->align !== Position::TOP_LEFT) { - switch ($text->align) { - case Position::TOP: - case Position::TOP_MIDDLE: - $x -= round(($box->upperLeft->x + $box->upperRight->x) / 2); - $y -= round(($box->upperLeft->y + $box->upperRight->y) / 2); - - break; - case Position::RIGHT: - case Position::TOP_RIGHT: - $x -= $box->upperRight->x; - $y -= $box->upperRight->y; - - break; - case Position::LEFT: - case Position::TOP_LEFT: - $x -= $box->upperLeft->x; - $y -= $box->upperLeft->y; - - break; - case Position::CENTER_MIDDLE: - case Position::CENTER: - $x -= round(($box->lowerLeft->x + $box->upperRight->x) / 2); - $y -= round(($box->lowerLeft->y + $box->upperRight->y) / 2); - - break; - case Position::CENTER_RIGHT: - $x -= round(($box->lowerRight->x + $box->upperRight->x) / 2); - $y -= round(($box->lowerRight->y + $box->upperRight->y) / 2); - - break; - - case Position::CENTER_LEFT: - $x -= round(($box->lowerLeft->x + $box->upperLeft->x) / 2); - $y -= round(($box->lowerLeft->y + $box->upperLeft->y) / 2); - - break; - case Position::BOTTOM: - case Position::BOTTOM_MIDDLE: - $x -= round(($box->lowerLeft->x + $box->lowerRight->x) / 2); - $y -= round(($box->lowerLeft->y + $box->lowerRight->y) / 2); - - break; - case Position::BOTTOM_RIGHT: - $x -= $box->lowerRight->x; - $y -= $box->lowerRight->y; - - break; - case Position::BOTTOM_LEFT: - $x -= $box->lowerLeft->x; - $y -= $box->lowerLeft->y; - - break; - } - } - - // enable alphablending for imagettftext - imagealphablending($image->getCore(), true); - - // draw ttf text - imagettftext($image->getCore(), $text->getPointSize(), $text->angle, $x, $y, $color->getInt(), $text->fontPath, $text->parsedText()); - - return null; - } - - // get box size - $size = $box->getSize(); - $width = $size->width; - $height = $size->height; - - // internal font specific position corrections - if ($text->getInternalFont() === 1) { - $topFix = 1; - $bottomFix = 2; - } elseif ($text->getInternalFont() === 3) { - $topFix = 2; - $bottomFix = 4; - } else { - $topFix = 3; - $bottomFix = 4; - } - - // x-position corrections for horizontal alignment - switch ($text->align) { - case Position::CENTER: - case Position::CENTER_MIDDLE: - case Position::TOP_MIDDLE: - case Position::BOTTOM_MIDDLE: - $x = ceil($x - ($width / 2)); - - break; - - case Position::RIGHT: - case Position::CENTER_RIGHT: - case Position::TOP_RIGHT: - case Position::BOTTOM_RIGHT: - $x = ceil($x - $width) + 1; - - break; - } - - // y-position corrections for vertical alignment - switch ($text->align) { - case Position::CENTER: - case Position::CENTER_MIDDLE: - case Position::CENTER_LEFT: - case Position::CENTER_RIGHT: - $y = ceil($y - ($height / 2)); - - break; - - case Position::TOP: - case Position::TOP_MIDDLE: - case Position::TOP_LEFT: - case Position::TOP_RIGHT: - $y = ceil($y - $topFix); - - break; - - default: - case Position::BOTTOM: - case Position::BOTTOM_MIDDLE: - case Position::BOTTOM_LEFT: - case Position::BOTTOM_RIGHT: - $y = round($y - $height + $bottomFix); - - break; - } - - // draw text - imagestring($image->getCore(), $text->getInternalFont(), $x, $y, $this->text, $color->getInt()); + $this->writeWithGd($image, $text, $driver, $x, $y); return null; } @@ -194,70 +57,7 @@ public function applyWithImagick(Image $image): null throw new RuntimeException('Invalid driver for this alteration'); } - $color = $driver->parseColor($text->color); - - $draw = new \ImagickDraw(); - $draw->setStrokeAntialias(true); - $draw->setTextAntialias(true); - $draw->setFont($text->fontPath); - $draw->setFontSize($text->size); - $draw->setFillColor($color->getPixel()); - $draw->setTextKerning($text->kerning); - - switch ($text->align) { - case Position::CENTER: - case Position::CENTER_MIDDLE: - case Position::TOP_MIDDLE: - case Position::BOTTOM_MIDDLE: - $align = \Imagick::ALIGN_CENTER; - - break; - - case Position::RIGHT: - case Position::CENTER_RIGHT: - case Position::TOP_RIGHT: - case Position::BOTTOM_RIGHT: - $align = \Imagick::ALIGN_RIGHT; - - break; - default: - case Position::LEFT: - case Position::CENTER_LEFT: - case Position::TOP_LEFT: - case Position::BOTTOM_LEFT: - $align = \Imagick::ALIGN_LEFT; - - break; - } - - $draw->setTextAlignment($align); - - switch ($text->align) { - case Position::CENTER: - case Position::CENTER_MIDDLE: - case Position::CENTER_LEFT: - case Position::CENTER_RIGHT: - case Position::TOP: - case Position::TOP_MIDDLE: - case Position::TOP_LEFT: - case Position::TOP_RIGHT: - $dimensions = $image->getCore()->queryFontMetrics($draw, $this->text); - $y = $y + $dimensions['textHeight'] * 0.65 / 2; - - break; - - default: - case Position::BOTTOM: - case Position::BOTTOM_MIDDLE: - case Position::BOTTOM_LEFT: - case Position::BOTTOM_RIGHT: - $dimensions = $image->getCore()->queryFontMetrics($draw, $this->text, false); - $y += $dimensions['characterHeight']; - - break; - } - - $image->getCore()->annotateImage($draw, $x, $y, $text->angle * (-1), $this->text); + $this->writeWithImagick($image, $text, $driver, $x, $y); return null; } diff --git a/src/Alterations/WriteTextFit.php b/src/Alterations/WriteTextFit.php new file mode 100644 index 0000000..5cc9a15 --- /dev/null +++ b/src/Alterations/WriteTextFit.php @@ -0,0 +1,82 @@ +text); + if ($this->callback instanceof Closure) { + $this->callback->call($this, $text); + } + + $driver = $image->getDriver(); + if (!($driver instanceof Gd)) { + throw new RuntimeException('Invalid driver for this alteration'); + } + + $boxText = $text->getBox(); + while (!$boxText->getSize()->fitsInto($this->size)) { + $text->size($text->size - 0.2); + $boxText = $text->getBox(); + } + + $this->writeWithGd( + $image, + $text, + $driver, + $this->size->pivot->x + 1, + $this->size->pivot->y + $text->getPointSize() + 1, + ); + + return null; + } + + public function applyWithImagick(Image $image): mixed + { + $text = new ImagickText($this->text); + if ($this->callback instanceof Closure) { + $this->callback->call($this, $text); + } + + $driver = $image->getDriver(); + if (!($driver instanceof Imagick)) { + throw new RuntimeException('Invalid driver for this alteration'); + } + + $boxText = $text->getBox(); + while (!$boxText->getSize()->fitsInto($this->size)) { + $text->size($text->size - 0.2); + $boxText = $text->getBox(); + } + + $this->writeWithImagick( + $image, + $text, + $driver, + $this->size->pivot->x, + $this->size->pivot->y, + ); + + return null; + } +} diff --git a/src/DefaultAlterations.php b/src/DefaultAlterations.php index 270be0a..b228e37 100644 --- a/src/DefaultAlterations.php +++ b/src/DefaultAlterations.php @@ -6,6 +6,7 @@ use SergiX44\ImageZen\Draws\Color; use SergiX44\ImageZen\Draws\Flip; use SergiX44\ImageZen\Draws\Position; +use SergiX44\ImageZen\Draws\Size; use SergiX44\ImageZen\Draws\TrimFrom; trait DefaultAlterations @@ -53,6 +54,7 @@ protected function registerDefaultAlterations(): void Alterations\WriteText::class, Alterations\Trim::class, Alterations\Widen::class, + Alterations\WriteTextFit::class ); } @@ -560,7 +562,7 @@ public function sharpen(int $amount = 10): Image * @param string $text The text to write to the image * @param int $x The x-coordinate of the text * @param int $y The y-coordinate of the text - * @param Closure|null $callback A callback that is passed an instance of SergiX44\ImageZen\Fonts\Font + * @param Closure|null $callback A callback that is passed an instance of SergiX44\ImageZen\Draws\Text * @return Image */ public function text(string $text, int $x, int $y, ?Closure $callback = null): Image @@ -607,4 +609,19 @@ public function widen(int $width, ?Closure $callback = null): Image return $this; } + + /** + * Write text to the image and fit it into the given dimensions. + * + * @param string $text The text to write to the image + * @param Size $size The size to fit the text into + * @param Closure|null $callback A callback that is passed an instance of SergiX44\ImageZen\Draws\Text + * @return Image + */ + public function fitText(string $text, Size $size, ?Closure $callback = null): Image + { + $this->alterate(__FUNCTION__, $text, $size, $callback); + + return $this; + } } diff --git a/src/Draws/Position.php b/src/Draws/Position.php index 75ed09f..7b038f4 100644 --- a/src/Draws/Position.php +++ b/src/Draws/Position.php @@ -19,4 +19,23 @@ enum Position: string case LEFT = 'left'; case RIGHT = 'right'; + public function toImagickGravity(): int + { + return match ($this) { + self::TOP => \Imagick::GRAVITY_NORTH, + self::TOP_MIDDLE => \Imagick::GRAVITY_NORTH, + self::TOP_LEFT => \Imagick::GRAVITY_NORTHWEST, + self::TOP_RIGHT => \Imagick::GRAVITY_NORTHEAST, + self::CENTER => \Imagick::GRAVITY_CENTER, + self::CENTER_MIDDLE => \Imagick::GRAVITY_CENTER, + self::CENTER_LEFT => \Imagick::GRAVITY_WEST, + self::CENTER_RIGHT => \Imagick::GRAVITY_EAST, + self::BOTTOM => \Imagick::GRAVITY_SOUTH, + self::BOTTOM_MIDDLE => \Imagick::GRAVITY_SOUTH, + self::BOTTOM_LEFT => \Imagick::GRAVITY_SOUTHWEST, + self::BOTTOM_RIGHT => \Imagick::GRAVITY_SOUTHEAST, + self::LEFT => \Imagick::GRAVITY_WEST, + self::RIGHT => \Imagick::GRAVITY_EAST, + }; + } } diff --git a/src/Draws/Size.php b/src/Draws/Size.php index da8dbd1..2d87d18 100644 --- a/src/Draws/Size.php +++ b/src/Draws/Size.php @@ -12,9 +12,9 @@ class Size public ?Point $pivot; /** - * @param int|null $width - * @param int|null $height - * @param Point|null $pivot + * @param int|null $width + * @param int|null $height + * @param Point|null $pivot */ public function __construct(?int $width, ?int $height, ?Point $pivot = null) { @@ -26,8 +26,8 @@ public function __construct(?int $width, ?int $height, ?Point $pivot = null) /** * Set the width and height absolutely * - * @param int $width - * @param int $height + * @param int $width + * @param int $height */ public function set(int $width, int $height): void { @@ -38,7 +38,7 @@ public function set(int $width, int $height): void /** * Set current pivot point * - * @param Point $point + * @param Point $point */ public function setPivot(Point $point): void { @@ -52,7 +52,7 @@ public function setPivot(Point $point): void */ public function getWidth(): int { - return $this->width; + return abs($this->width); } /** @@ -62,7 +62,7 @@ public function getWidth(): int */ public function getHeight(): int { - return $this->height; + return abs($this->height); } /** @@ -78,9 +78,9 @@ public function getRatio(): float /** * Resize to desired width and/or height * - * @param int|null $width - * @param int|null $height - * @param Closure|null $callback + * @param int|null $width + * @param int|null $height + * @param Closure|null $callback * @return Size */ public function resize(?int $width, ?int $height, ?Closure $callback = null): self @@ -108,8 +108,8 @@ public function resize(?int $width, ?int $height, ?Closure $callback = null): se /** * Scale size according to given constraints * - * @param int|null $width - * @param Closure|null $callback + * @param int|null $width + * @param Closure|null $callback */ private function resizeWidth(?int $width, Closure $callback = null): void { @@ -133,7 +133,7 @@ private function resizeWidth(?int $width, Closure $callback = null): void } if ($constraint->isFixed(Constraint::ASPECT_RATIO)) { - $h = max(1, (int) round($this->width / $constraint->getSize()->getRatio())); + $h = max(1, (int)round($this->width / $constraint->getSize()->getRatio())); if ($constraint->isFixed(Constraint::UPSIZE)) { $this->height = ($h > $maxHeight) ? $maxHeight : $h; @@ -146,8 +146,8 @@ private function resizeWidth(?int $width, Closure $callback = null): void /** * Scale size according to given constraints * - * @param int|null $height - * @param Closure|null $callback + * @param int|null $height + * @param Closure|null $callback */ private function resizeHeight(?int $height, Closure $callback = null): void { @@ -171,7 +171,7 @@ private function resizeHeight(?int $height, Closure $callback = null): void } if ($constraint->isFixed(Constraint::ASPECT_RATIO)) { - $w = max(1, (int) round($this->height * $constraint->getSize()->getRatio())); + $w = max(1, (int)round($this->height * $constraint->getSize()->getRatio())); if ($constraint->isFixed(Constraint::UPSIZE)) { $this->width = ($w > $maxWidth) ? $maxWidth : $w; @@ -185,7 +185,7 @@ private function resizeHeight(?int $height, Closure $callback = null): void * Calculate the relative position to another Size * based on the pivot point settings of both sizes. * - * @param Size $size + * @param Size $size * @return Point */ public function relativePosition(Size $size): Point @@ -199,8 +199,8 @@ public function relativePosition(Size $size): Point /** * Resize given Size to best fitting size of current size. * - * @param Size $size - * @param Position $position + * @param Size $size + * @param Position $position * @return Size */ public function fit(Size $size, Position $position = Position::CENTER): self @@ -236,7 +236,7 @@ public function fit(Size $size, Position $position = Position::CENTER): self /** * Checks if given size fits into current size * - * @param Size $size + * @param Size $size * @return bool */ public function fitsInto(Size $size): bool @@ -248,9 +248,9 @@ public function fitsInto(Size $size): bool * Aligns current size's pivot point to given position * and moves point automatically by offset. * - * @param Position $position - * @param int|null $offsetX - * @param int|null $offsetY + * @param Position $position + * @param int|null $offsetX + * @param int|null $offsetY * @return Size */ public function align(Position $position, ?int $offsetX = 0, ?int $offsetY = 0): self @@ -258,10 +258,9 @@ public function align(Position $position, ?int $offsetX = 0, ?int $offsetY = 0): $offsetX ??= 0; $offsetY ??= 0; switch ($position) { - case Position::TOP: case Position::TOP_MIDDLE: - $x = (int) ($this->width / 2); + $x = (int)($this->width / 2); $y = $offsetY; break; @@ -275,14 +274,14 @@ public function align(Position $position, ?int $offsetX = 0, ?int $offsetY = 0): case Position::LEFT: case Position::CENTER_LEFT: $x = $offsetX; - $y = (int) ($this->height / 2); + $y = (int)($this->height / 2); break; case Position::RIGHT: case Position::CENTER_RIGHT: $x = $this->width - $offsetX; - $y = (int) ($this->height / 2); + $y = (int)($this->height / 2); break; @@ -294,7 +293,7 @@ public function align(Position $position, ?int $offsetX = 0, ?int $offsetY = 0): case Position::BOTTOM: case Position::BOTTOM_MIDDLE: - $x = (int) ($this->width / 2); + $x = (int)($this->width / 2); $y = $this->height - $offsetY; break; @@ -307,8 +306,8 @@ public function align(Position $position, ?int $offsetX = 0, ?int $offsetY = 0): case Position::CENTER: case Position::CENTER_MIDDLE: - $x = ((int) $this->width / 2) + $offsetX; - $y = ((int) $this->height / 2) + $offsetY; + $x = ((int)$this->width / 2) + $offsetX; + $y = ((int)$this->height / 2) + $offsetY; break; @@ -327,7 +326,7 @@ public function align(Position $position, ?int $offsetX = 0, ?int $offsetY = 0): /** * Runs constraints on current size * - * @param Closure|null $callback + * @param Closure|null $callback * @return Constraint */ private function getConstraint(Closure $callback = null): Constraint @@ -353,10 +352,10 @@ public function getBox(): Box } return new Box( - lowerLeft: new Point($this->pivot->x, $this->pivot->y + $this->height), - lowerRight: new Point($this->pivot->x + $this->width, $this->pivot->y + $this->height), - upperRight: new Point($this->pivot->x + $this->width, $this->pivot->y), - upperLeft: $this->pivot, + lowerLeft: $this->pivot, + lowerRight: new Point($this->pivot->x + $this->width, $this->pivot->y), + upperRight: new Point($this->pivot->x + $this->width, $this->pivot->y - $this->height), + upperLeft: new Point($this->pivot->x, $this->pivot->y - $this->height), ); } } diff --git a/src/Draws/Text.php b/src/Draws/Text.php index c571f23..e1e51bd 100644 --- a/src/Draws/Text.php +++ b/src/Draws/Text.php @@ -4,20 +4,27 @@ abstract class Text { - protected string $text; + public string $text; public ?string $fontPath; public int $size; public Color $color; public Position $align; public int $angle; + public ?float $stroke = null; + public Color $strokeColor; + public ?Color $background = null; + public int $shadowX = 0; + public int $shadowY = 0; + public ?Color $shadowColor = null; + public ?float $interline = null; /** - * @param string $text - * @param int $size - * @param Color|null $color - * @param string|null $fontPath - * @param Position $align - * @param int $angle + * @param string $text + * @param int $size + * @param Color|null $color + * @param string|null $fontPath + * @param Position $align + * @param int $angle */ public function __construct( string $text, @@ -28,9 +35,10 @@ public function __construct( int $angle = 0, ) { $this->text = $text; - $this->fontPath = $fontPath ?? __DIR__.'/../../assets/LiberationSans-Regular.ttf'; + $this->fontPath = $fontPath ?? __DIR__ . '/../../assets/LiberationSans-Regular.ttf'; $this->size = $size; $this->color = $color ?? Color::black(); + $this->strokeColor = $color ?? Color::white(); $this->align = $align; $this->angle = $angle; } @@ -72,8 +80,49 @@ public function angle(int $angle): self return $this; } + public function stroke(float $stroke, ?Color $color = null): self + { + $this->stroke = $stroke; + $this->strokeColor = $color ?? Color::white(); + + return $this; + } + + public function background(?Color $color = null): self + { + $this->background = $color ?? Color::white(); + + return $this; + } + + public function shadow(int $x, int $y, ?Color $color = null): self + { + $this->shadowX = $x; + $this->shadowY = $y; + $this->shadowColor = $color ?? Color::black(); + + return $this; + } + + public function interline(float $interline): self + { + $this->interline = $interline; + + return $this; + } + + public function hasShadow(): bool + { + return $this->shadowX !== 0 || $this->shadowY !== 0; + } + public function hasFont(): bool { return $this->fontPath !== null && file_exists($this->fontPath); } + + public function getPointSize(): int + { + return (int) ceil($this->size * 0.75); + } } diff --git a/src/Drivers/Gd/GdText.php b/src/Drivers/Gd/GdText.php index 0e235ac..a8f85cc 100644 --- a/src/Drivers/Gd/GdText.php +++ b/src/Drivers/Gd/GdText.php @@ -28,19 +28,18 @@ public function font(?string $font): self return parent::font($font); } - public function getPointSize(): int + public function getBox(): Box { - return (int) ceil($this->size * 0.75); + return $this->getBoxFor($this->parsedText()); } - public function getBox(): Box + public function getBoxFor(string $text): Box { - if (grapheme_strlen($this->text) === 0) { + if (grapheme_strlen($text) === 0) { return new Box(new Point(), new Point(), new Point(), new Point()); } if ($this->hasFont()) { - $text = $this->parsedText(); $box = imagettfbbox($this->getPointSize(), $this->angle, $this->fontPath, $text); return new Box(new Point($box[0], $box[1]), new Point($box[2], $box[3]), new Point($box[4], $box[5]), new Point($box[6], $box[7])); @@ -56,6 +55,18 @@ public function getBox(): Box return (new Size(strlen($this->text) * $width, $height, new Point()))->getBox(); } + public function getMultiLineBoxes(): array + { + $text = $this->parsedText(); + $lines = explode("\n", $text); + $boxes = []; + foreach ($lines as $line) { + $boxes[$line] = $this->getBoxFor($line); + } + + return $boxes; + } + public function parsedText(): string { // imagettfbbox() converts numeric entities to their respective diff --git a/src/Drivers/Imagick/ImagickText.php b/src/Drivers/Imagick/ImagickText.php index d5676b3..d7104bc 100644 --- a/src/Drivers/Imagick/ImagickText.php +++ b/src/Drivers/Imagick/ImagickText.php @@ -3,6 +3,7 @@ namespace SergiX44\ImageZen\Drivers\Imagick; use SergiX44\ImageZen\Draws\Box; +use SergiX44\ImageZen\Draws\Size; use SergiX44\ImageZen\Draws\Text; use SergiX44\ImageZen\Shapes\Point; @@ -23,8 +24,10 @@ public function getBox(): Box $draw->setFont($this->fontPath); $draw->setFontSize($this->size); $draw->setTextKerning($this->kerning); - $draw->setGravity($this->align); - $draw->setStrokeWidth(0); + $draw->setGravity($this->align->toImagickGravity()); + if ($this->stroke !== null) { + $draw->setStrokeWidth($this->stroke); + } $draw->setStrokeAntialias(false); $draw->setStrokeOpacity(1); $draw->setFillOpacity(1); @@ -35,11 +38,6 @@ public function getBox(): Box $metrics = $im->queryFontMetrics($draw, $this->text); $im->destroy(); - return new Box( - new Point($metrics['boundingBox']['x1'], $metrics['boundingBox']['y1']), - new Point($metrics['boundingBox']['x2'], $metrics['boundingBox']['y2']), - new Point($metrics['boundingBox']['x2'], $metrics['boundingBox']['y2']), - new Point($metrics['boundingBox']['x2'], $metrics['boundingBox']['y2']) - ); + return (new Size($metrics['textWidth'], $metrics['textHeight'], new Point()))->getBox(); } } diff --git a/tests/DriverTest.php b/tests/DriverTest.php index c1a1170..81a78b2 100644 --- a/tests/DriverTest.php +++ b/tests/DriverTest.php @@ -632,3 +632,134 @@ function prepare($instance, string $name, Backend $driver, string $ext = 'png'): $b64 = Image::make($file, $driver)->base64(\SergiX44\ImageZen\Format::JPG); expect($b64)->toStartWith('data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ'); })->with('drivers', 'tile'); + +it('can draw a text with a background', function ($driver, $file) { + [$out, $expected] = prepare($this, 'fruit_with_text_background', $driver); + + Image::make($file, $driver) + ->text("Hello World!", 100, 100, function (Text $text) { + $text->size(18) + ->background(Color::fuchsia()); + }) + ->save($out, quality: 100); + + expect($out) + ->toBeFile() + ->imageSimilarTo($expected); + unlink($out); +})->with('drivers', 'fruit'); + +it('can draw a text with a background with new lines', function ($driver, $file) { + [$out, $expected] = prepare($this, 'fruit_with_text_background_newlines', $driver); + + Image::make($file, $driver) + ->text("Hello\nWorld!\nMore strings!", 100, 100, function (Text $text) { + $text->size(80) + ->background(Color::fuchsia()); + }) + ->save($out, quality: 100); + + expect($out) + ->toBeFile() + ->imageSimilarTo($expected); + unlink($out); +})->with('drivers', 'fruit'); + +it('can draw a text with a background with new lines and interline', function ($driver, $file) { + [$out, $expected] = prepare($this, 'fruit_with_text_background_newlines_interline', $driver); + + Image::make($file, $driver) + ->text("Hello\nWorld!\nMore strings!", 100, 100, function (Text $text) { + $text->size(80) + ->interline(1.2) + ->background(Color::fuchsia()); + }) + ->save($out, quality: 100); + + expect($out) + ->toBeFile() + ->imageSimilarTo($expected); + unlink($out); +})->with('drivers', 'fruit'); + +it('can draw a text with a stroke', function ($driver, $file) { + [$out, $expected] = prepare($this, 'fruit_with_text_stroke', $driver); + + Image::make($file, $driver) + ->text("Hello World!", 100, 100, function (Text $text) { + $text->size(80) + ->color(Color::gold()) + ->background(Color::white()) + ->stroke(3, Color::fuchsia()); + }) + ->save($out, quality: 100); + + expect($out) + ->toBeFile() + ->imageSimilarTo($expected); + unlink($out); +})->with('drivers', 'fruit'); + +it('can draw a text with a shadow', function ($driver, $file) { + [$out, $expected] = prepare($this, 'fruit_with_text_shadow', $driver); + + Image::make($file, $driver) + ->text("Hello World!", 100, 100, function (Text $text) { + $text->size(80) + ->color(Color::gold()) + ->shadow(3, 3, Color::blue()); + }) + ->save($out, quality: 100); + + expect($out) + ->toBeFile() + ->imageSimilarTo($expected); + unlink($out); +})->with('drivers', 'fruit'); + +it('can fit a text in a box', function ($driver, $file) { + [$out, $expected] = prepare($this, 'fruit_with_text_fit', $driver); + + Image::make($file, $driver) + ->rectangle(100, 100, 200, 200, function ($draw) { + $draw->border(1, Color::fuchsia()); + }) + ->fitText( + "Hello World!", + new \SergiX44\ImageZen\Draws\Size(100, 100, new \SergiX44\ImageZen\Shapes\Point(100, 100)), + function (Text $text) { + $text->size(72) + ->color(Color::gold()); + } + ) + ->save($out, quality: 100); + + expect($out) + ->toBeFile() + ->imageSimilarTo($expected, 95); + unlink($out); +})->with('drivers', 'fruit'); + + +it('can fit a text in a box with multi lines', function ($driver, $file) { + [$out, $expected] = prepare($this, 'fruit_with_text_fit_multiline', $driver); + + Image::make($file, $driver) + ->rectangle(100, 100, 200, 200, function ($draw) { + $draw->border(1, Color::fuchsia()); + }) + ->fitText( + "Hello World!\nMore strings!\nAnd more longer and longer!\nmany\nmany\nmore and more\nlines", + new \SergiX44\ImageZen\Draws\Size(100, 100, new \SergiX44\ImageZen\Shapes\Point(100, 100)), + function (Text $text) { + $text->size(72) + ->color(Color::gold()); + } + ) + ->save($out, quality: 100); + + expect($out) + ->toBeFile() + ->imageSimilarTo($expected, 95); + unlink($out); +})->with('drivers', 'fruit'); diff --git a/tests/Images/Gd/fruit_with_text_background.png b/tests/Images/Gd/fruit_with_text_background.png new file mode 100644 index 0000000..7719df6 Binary files /dev/null and b/tests/Images/Gd/fruit_with_text_background.png differ diff --git a/tests/Images/Gd/fruit_with_text_background_newlines.png b/tests/Images/Gd/fruit_with_text_background_newlines.png new file mode 100644 index 0000000..0ca6d11 Binary files /dev/null and b/tests/Images/Gd/fruit_with_text_background_newlines.png differ diff --git a/tests/Images/Gd/fruit_with_text_background_newlines_interline.png b/tests/Images/Gd/fruit_with_text_background_newlines_interline.png new file mode 100644 index 0000000..d539080 Binary files /dev/null and b/tests/Images/Gd/fruit_with_text_background_newlines_interline.png differ diff --git a/tests/Images/Gd/fruit_with_text_fit.png b/tests/Images/Gd/fruit_with_text_fit.png new file mode 100644 index 0000000..83d1677 Binary files /dev/null and b/tests/Images/Gd/fruit_with_text_fit.png differ diff --git a/tests/Images/Gd/fruit_with_text_fit_multiline.png b/tests/Images/Gd/fruit_with_text_fit_multiline.png new file mode 100644 index 0000000..1c522c5 Binary files /dev/null and b/tests/Images/Gd/fruit_with_text_fit_multiline.png differ diff --git a/tests/Images/Gd/fruit_with_text_shadow.png b/tests/Images/Gd/fruit_with_text_shadow.png new file mode 100644 index 0000000..7823470 Binary files /dev/null and b/tests/Images/Gd/fruit_with_text_shadow.png differ diff --git a/tests/Images/Gd/fruit_with_text_stroke.png b/tests/Images/Gd/fruit_with_text_stroke.png new file mode 100644 index 0000000..9685627 Binary files /dev/null and b/tests/Images/Gd/fruit_with_text_stroke.png differ diff --git a/tests/Images/Imagick/fruit_with_text_background.png b/tests/Images/Imagick/fruit_with_text_background.png new file mode 100644 index 0000000..db08085 Binary files /dev/null and b/tests/Images/Imagick/fruit_with_text_background.png differ diff --git a/tests/Images/Imagick/fruit_with_text_background_newlines.png b/tests/Images/Imagick/fruit_with_text_background_newlines.png new file mode 100644 index 0000000..b8bbe9f Binary files /dev/null and b/tests/Images/Imagick/fruit_with_text_background_newlines.png differ diff --git a/tests/Images/Imagick/fruit_with_text_background_newlines_interline.png b/tests/Images/Imagick/fruit_with_text_background_newlines_interline.png new file mode 100644 index 0000000..399e4b9 Binary files /dev/null and b/tests/Images/Imagick/fruit_with_text_background_newlines_interline.png differ diff --git a/tests/Images/Imagick/fruit_with_text_fit.png b/tests/Images/Imagick/fruit_with_text_fit.png new file mode 100644 index 0000000..8f5552b Binary files /dev/null and b/tests/Images/Imagick/fruit_with_text_fit.png differ diff --git a/tests/Images/Imagick/fruit_with_text_fit_multiline.png b/tests/Images/Imagick/fruit_with_text_fit_multiline.png new file mode 100644 index 0000000..2be80c8 Binary files /dev/null and b/tests/Images/Imagick/fruit_with_text_fit_multiline.png differ diff --git a/tests/Images/Imagick/fruit_with_text_shadow.png b/tests/Images/Imagick/fruit_with_text_shadow.png new file mode 100644 index 0000000..e8c2e7c Binary files /dev/null and b/tests/Images/Imagick/fruit_with_text_shadow.png differ diff --git a/tests/Images/Imagick/fruit_with_text_stroke.png b/tests/Images/Imagick/fruit_with_text_stroke.png new file mode 100644 index 0000000..bc1546a Binary files /dev/null and b/tests/Images/Imagick/fruit_with_text_stroke.png differ diff --git a/tests/SizesTest.php b/tests/SizesTest.php new file mode 100644 index 0000000..91d1295 --- /dev/null +++ b/tests/SizesTest.php @@ -0,0 +1,21 @@ +getBox(); + $imbox = $im->getBox(); + + // sadly the box computation is not exactly the same across drivers and OSes + // so we have to allow a small margin of error + $marginError = 6; + expect($gdbox->lowerLeft->x)->toBeBetween($imbox->lowerLeft->x - $marginError, $imbox->lowerLeft->x + $marginError) + ->and($gdbox->lowerLeft->y)->toBeBetween($imbox->lowerLeft->y - $marginError, $imbox->lowerLeft->y + $marginError) + ->and($gdbox->lowerRight->x)->toBeBetween($imbox->lowerRight->x - $marginError, $imbox->lowerRight->x + $marginError) + ->and($gdbox->lowerRight->y)->toBeBetween($imbox->lowerRight->y - $marginError, $imbox->lowerRight->y + $marginError) + ->and($gdbox->upperLeft->x)->toBeBetween($imbox->upperLeft->x - $marginError, $imbox->upperLeft->x + $marginError) + ->and($gdbox->upperLeft->y)->toBeBetween($imbox->upperLeft->y - $marginError, $imbox->upperLeft->y + $marginError) + ->and($gdbox->upperRight->x)->toBeBetween($imbox->upperRight->x - $marginError, $imbox->upperRight->x + $marginError) + ->and($gdbox->upperRight->y)->toBeBetween($imbox->upperRight->y - $marginError, $imbox->upperRight->y + $marginError); +});