diff --git a/src/Drivers/AbstractTextModifier.php b/src/Drivers/AbstractTextModifier.php new file mode 100644 index 00000000..f8565635 --- /dev/null +++ b/src/Drivers/AbstractTextModifier.php @@ -0,0 +1,34 @@ +strokeWidth() <= 0) { + return $offsets; + } + + for ($x = $font->strokeWidth() * -1; $x <= $font->strokeWidth(); $x++) { + for ($y = $font->strokeWidth() * -1; $y <= $font->strokeWidth(); $y++) { + $offsets[] = new Point($x, $y); + } + } + + return $offsets; + } +} diff --git a/src/Drivers/Gd/Modifiers/TextModifier.php b/src/Drivers/Gd/Modifiers/TextModifier.php index 20da2173..4d7a5cf6 100644 --- a/src/Drivers/Gd/Modifiers/TextModifier.php +++ b/src/Drivers/Gd/Modifiers/TextModifier.php @@ -4,7 +4,9 @@ declare(strict_types=1); namespace Intervention\Image\Drivers\Gd\Modifiers; -use Intervention\Image\Drivers\DriverSpecialized; +use Intervention\Image\Drivers\AbstractTextModifier; +use Intervention\Image\Exceptions\ColorException; +use Intervention\Image\Exceptions\RuntimeException; use Intervention\Image\Interfaces\ImageInterface; use Intervention\Image\Geometry\Point; use Intervention\Image\Interfaces\FontInterface; @@ -15,7 +17,7 @@ use Intervention\Image\Interfaces\ModifierInterface; * @property string $text * @property FontInterface $font */ -class TextModifier extends DriverSpecialized implements ModifierInterface +class TextModifier extends AbstractTextModifier implements ModifierInterface { /** * {@inheritdoc} @@ -26,34 +28,59 @@ class TextModifier extends DriverSpecialized implements ModifierInterface { $fontProcessor = $this->driver()->fontProcessor(); $lines = $fontProcessor->textBlock($this->text, $this->font, $this->position); - $color = $this->driver()->colorProcessor($image->colorspace())->colorToNative( - $this->driver()->handleInput($this->font->color()) - ); + + // decode text colors + $textColor = $this->textColor($image); + $strokeColor = $this->strokeColor($image); foreach ($image as $frame) { + imagealphablending($frame->native(), true); if ($this->font->hasFilename()) { foreach ($lines as $line) { - imagealphablending($frame->native(), true); + foreach ($this->strokeOffsets($this->font) as $offset) { + imagettftext( + $frame->native(), + $fontProcessor->nativeFontSize($this->font), + $this->font->angle() * -1, + $line->position()->x() + $offset->x(), + $line->position()->y() + $offset->y(), + $strokeColor, + $this->font->filename(), + (string) $line + ); + } + imagettftext( $frame->native(), $fontProcessor->nativeFontSize($this->font), $this->font->angle() * -1, $line->position()->x(), $line->position()->y(), - $color, + $textColor, $this->font->filename(), (string) $line ); } } else { foreach ($lines as $line) { + foreach ($this->strokeOffsets($this->font) as $offset) { + imagestring( + $frame->native(), + $this->gdFont(), + $line->position()->x() + $offset->x(), + $line->position()->y() + $offset->y(), + (string) $line, + $strokeColor + ); + } + imagestring( $frame->native(), $this->gdFont(), $line->position()->x(), $line->position()->y(), (string) $line, - $color + $textColor ); } } @@ -62,7 +89,58 @@ class TextModifier extends DriverSpecialized implements ModifierInterface return $image; } - /** + /** + * Decode text color + * + * The text outline effect is drawn with a trick by plotting additional text + * under the actual text with an offset in the color of the outline effect. + * For this reason, no colors with transparency can be used for the text + * color or the color of the stroke effect, as this would be superimposed. + * + * @param ImageInterface $image + * @throws RuntimeException + * @throws ColorException + * @return int + */ + protected function textColor(ImageInterface $image): int + { + $color = $this->driver()->handleInput($this->font->color()); + + if ($this->font->hasStrokeEffect() && $color->isTransparent()) { + throw new ColorException( + 'The text color must be fully opaque when using the stroke effect.' + ); + } + + return $this->driver()->colorProcessor($image->colorspace())->colorToNative($color); + } + + /** + * Decode outline stroke color + * + * @param ImageInterface $image + * @throws RuntimeException + * @throws ColorException + * @return int + */ + protected function strokeColor(ImageInterface $image): int + { + if (!$this->font->hasStrokeEffect()) { + return 0; + } + + $color = $this->driver()->handleInput($this->font->strokeColor()); + + if ($color->isTransparent()) { + throw new ColorException( + 'The stroke color must be fully opaque.' + ); + } + + return $this->driver()->colorProcessor($image->colorspace())->colorToNative($color); + } + + /** * Return GD's internal font size (if no ttf file is set) * * @return int diff --git a/src/Drivers/Imagick/Modifiers/TextModifier.php b/src/Drivers/Imagick/Modifiers/TextModifier.php index 88abe535..4baa9a13 100644 --- a/src/Drivers/Imagick/Modifiers/TextModifier.php +++ b/src/Drivers/Imagick/Modifiers/TextModifier.php @@ -4,20 +4,27 @@ declare(strict_types=1); namespace Intervention\Image\Drivers\Imagick\Modifiers; -use Intervention\Image\Drivers\DriverSpecialized; +use ImagickDraw; +use ImagickDrawException; +use ImagickException; +use Intervention\Image\Drivers\AbstractTextModifier; use Intervention\Image\Drivers\Imagick\FontProcessor; +use Intervention\Image\Drivers\Imagick\Frame; +use Intervention\Image\Exceptions\ColorException; use Intervention\Image\Exceptions\FontException; +use Intervention\Image\Exceptions\RuntimeException; use Intervention\Image\Geometry\Point; use Intervention\Image\Interfaces\FontInterface; use Intervention\Image\Interfaces\ImageInterface; use Intervention\Image\Interfaces\ModifierInterface; +use Intervention\Image\Typography\Line; /** * @property Point $position * @property string $text * @property FontInterface $font */ -class TextModifier extends DriverSpecialized implements ModifierInterface +class TextModifier extends AbstractTextModifier implements ModifierInterface { /** * {@inheritdoc} @@ -26,29 +33,108 @@ class TextModifier extends DriverSpecialized implements ModifierInterface */ public function apply(ImageInterface $image): ImageInterface { - $fontProcessor = $this->processor(); - $lines = $fontProcessor->textBlock($this->text, $this->font, $this->position); - $color = $this->driver()->colorProcessor($image->colorspace())->colorToNative( - $this->driver()->handleInput($this->font->color()) - ); - - $draw = $fontProcessor->toImagickDraw($this->font, $color); + $lines = $this->processor()->textBlock($this->text, $this->font, $this->position); + $drawText = $this->imagickDrawText($image, $this->font); + $drawStroke = $this->imagickDrawStroke($image, $this->font); foreach ($image as $frame) { foreach ($lines as $line) { - $frame->native()->annotateImage( - $draw, - $line->position()->x(), - $line->position()->y(), - $this->font->angle(), - (string) $line - ); + foreach ($this->strokeOffsets($this->font) as $offset) { + // Draw the stroke outline under the actual text + $this->maybeDrawText($frame, $line, $drawStroke, $offset); + } + + // Draw the actual text + $this->maybeDrawText($frame, $line, $drawText); } } return $image; } + /** + * Create an ImagickDraw object to draw text on the image + * + * @param ImageInterface $image + * @param FontInterface $font + * @throws RuntimeException + * @throws ColorException + * @throws FontException + * @throws ImagickDrawException + * @throws ImagickException + * @return ImagickDraw + */ + private function imagickDrawText(ImageInterface $image, FontInterface $font): ImagickDraw + { + $color = $this->driver()->handleInput($font->color()); + + if ($font->hasStrokeEffect() && $color->isTransparent()) { + throw new ColorException( + 'The text color must be fully opaque when using the stroke effect.' + ); + } + + $color = $this->driver()->colorProcessor($image->colorspace())->colorToNative($color); + + return $this->processor()->toImagickDraw($font, $color); + } + + /** + * Create a ImagickDraw object to draw the outline stroke effect on the Image + * + * @param ImageInterface $image + * @param FontInterface $font + * @throws RuntimeException + * @throws ColorException + * @throws FontException + * @throws ImagickDrawException + * @throws ImagickException + * @return null|ImagickDraw + */ + private function imagickDrawStroke(ImageInterface $image, FontInterface $font): ?ImagickDraw + { + if (!$font->hasStrokeEffect()) { + return null; + } + + $color = $this->driver()->handleInput($font->strokeColor()); + + if ($color->isTransparent()) { + throw new ColorException( + 'The stroke color must be fully opaque.' + ); + } + + $color = $this->driver()->colorProcessor($image->colorspace())->colorToNative($color); + + return $this->processor()->toImagickDraw($font, $color); + } + + /** + * Maybe draw given line of text on frame instance depending on given + * ImageDraw instance. Optionally move line position by given offset. + * + * @param Frame $frame + * @param Line $textline + * @param null|ImagickDraw $draw + * @param Point $offset + * @return void + */ + private function maybeDrawText( + Frame $frame, + Line $textline, + ?ImagickDraw $draw = null, + Point $offset = new Point(), + ): void { + $frame->native()->annotateImage( + $draw, + $textline->position()->x() + $offset->x(), + $textline->position()->y() + $offset->y(), + $this->font->angle(), + (string) $textline + ); + } + /** * Return imagick font processor * diff --git a/src/Interfaces/FontInterface.php b/src/Interfaces/FontInterface.php index 71be76f7..d410cdbd 100644 --- a/src/Interfaces/FontInterface.php +++ b/src/Interfaces/FontInterface.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace Intervention\Image\Interfaces; +use Intervention\Image\Exceptions\FontException; + interface FontInterface { /** @@ -21,6 +23,45 @@ interface FontInterface */ public function color(): mixed; + /** + * Set stroke color of font + * + * @param mixed $color + * @return FontInterface + */ + public function setStrokeColor(mixed $color): self; + + /** + * Get stroke color of font + * + * @return mixed + */ + public function strokeColor(): mixed; + + /** + /** + * Set stroke width of font + * + * @param int $width + * @throws FontException + * @return FontInterface + */ + public function setStrokeWidth(int $width): self; + + /** + * Get stroke width of font + * + * @return int + */ + public function strokeWidth(): int; + + /** + * Determine if the font is drawn with outline stroke effect + * + * @return bool + */ + public function hasStrokeEffect(): bool; + /** * Set font size * diff --git a/src/Typography/Font.php b/src/Typography/Font.php index 33cf0271..e8a098e4 100644 --- a/src/Typography/Font.php +++ b/src/Typography/Font.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Intervention\Image\Typography; +use Intervention\Image\Exceptions\FontException; use Intervention\Image\Interfaces\FontInterface; class Font implements FontInterface @@ -11,6 +12,8 @@ class Font implements FontInterface protected float $size = 12; protected float $angle = 0; protected mixed $color = '000000'; + protected mixed $strokeColor = 'ffffff'; + protected int $strokeWidth = 0; protected ?string $filename = null; protected string $alignment = 'left'; protected string $valignment = 'bottom'; @@ -120,6 +123,66 @@ class Font implements FontInterface return $this->color; } + /** + * {@inheritdoc} + * + * @see FontInterface::setStrokeColor() + */ + public function setStrokeColor(mixed $color): FontInterface + { + $this->strokeColor = $color; + + return $this; + } + + /** + * {@inheritdoc} + * + * @see FontInterface::strokeColor() + */ + public function strokeColor(): mixed + { + return $this->strokeColor; + } + + /** + * {@inheritdoc} + * + * @see FontInterface::setStrokeWidth() + */ + public function setStrokeWidth(int $width): FontInterface + { + if (!in_array($width, range(0, 10))) { + throw new FontException( + 'The stroke width must be in the range from 0 to 10.' + ); + } + + $this->strokeWidth = $width; + + return $this; + } + + /** + * {@inheritdoc} + * + * @see FontInterface::strokeWidth() + */ + public function strokeWidth(): int + { + return $this->strokeWidth; + } + + /** + * {@inheritdoc} + * + * @see FontInterface::hasStrokeEffect() + */ + public function hasStrokeEffect(): bool + { + return $this->strokeWidth > 0; + } + /** * {@inheritdoc} * diff --git a/src/Typography/FontFactory.php b/src/Typography/FontFactory.php index e19f124c..d6738f41 100644 --- a/src/Typography/FontFactory.php +++ b/src/Typography/FontFactory.php @@ -4,12 +4,20 @@ declare(strict_types=1); namespace Intervention\Image\Typography; +use Intervention\Image\Exceptions\FontException; use Intervention\Image\Interfaces\FontInterface; class FontFactory { protected FontInterface $font; + /** + * Create new instance + * + * @param callable|FontInterface $init + * @throws FontException + * @return void + */ public function __construct(callable|FontInterface $init) { $this->font = is_a($init, FontInterface::class) ? $init : new Font(); @@ -19,11 +27,22 @@ class FontFactory } } + /** + * Build font + * + * @return FontInterface + */ public function __invoke(): FontInterface { return $this->font; } + /** + * Set the filename of the font to be built + * + * @param string $value + * @return FontFactory + */ public function filename(string $value): self { $this->font->setFilename($value); @@ -31,11 +50,38 @@ class FontFactory return $this; } + /** + * {@inheritdoc} + * + * @see self::filename() + */ public function file(string $value): self { return $this->filename($value); } + /** + * Set outline stroke effect for the font to be built + * + * @param mixed $color + * @param int $width + * @throws FontException + * @return FontFactory + */ + public function stroke(mixed $color, int $width = 1): self + { + $this->font->setStrokeWidth($width); + $this->font->setStrokeColor($color); + + return $this; + } + + /** + * Set color for the font to be built + * + * @param mixed $value + * @return FontFactory + */ public function color(mixed $value): self { $this->font->setColor($value); @@ -43,6 +89,12 @@ class FontFactory return $this; } + /** + * Set the size for the font to be built + * + * @param float $value + * @return FontFactory + */ public function size(float $value): self { $this->font->setSize($value); @@ -50,6 +102,12 @@ class FontFactory return $this; } + /** + * Set the horizontal alignment of the font to be built + * + * @param string $value + * @return FontFactory + */ public function align(string $value): self { $this->font->setAlignment($value); @@ -57,6 +115,12 @@ class FontFactory return $this; } + /** + * Set the vertical alignment of the font to be built + * + * @param string $value + * @return FontFactory + */ public function valign(string $value): self { $this->font->setValignment($value); @@ -64,6 +128,12 @@ class FontFactory return $this; } + /** + * Set the line height of the font to be built + * + * @param float $value + * @return FontFactory + */ public function lineHeight(float $value): self { $this->font->setLineHeight($value); @@ -71,6 +141,12 @@ class FontFactory return $this; } + /** + * Set the rotation angle of the font to be built + * + * @param float $value + * @return FontFactory + */ public function angle(float $value): self { $this->font->setAngle($value); @@ -78,6 +154,12 @@ class FontFactory return $this; } + /** + * Set the maximum width of the text block to be built + * + * @param int $width + * @return FontFactory + */ public function wrap(int $width): self { $this->font->setWrapWidth($width); diff --git a/tests/Unit/Drivers/AbstractTextModifierTest.php b/tests/Unit/Drivers/AbstractTextModifierTest.php new file mode 100644 index 00000000..2b3c761c --- /dev/null +++ b/tests/Unit/Drivers/AbstractTextModifierTest.php @@ -0,0 +1,39 @@ +makePartial(); + $this->assertEquals([ + ], $modifier->strokeOffsets( + new Font() + )); + + $this->assertEquals([ + new Point(-1, -1), + new Point(-1, 0), + new Point(-1, 1), + new Point(0, -1), + new Point(0, 0), + new Point(0, 1), + new Point(1, -1), + new Point(1, 0), + new Point(1, 1), + ], $modifier->strokeOffsets( + (new Font())->setStrokeWidth(1) + )); + } +}