1
0
mirror of https://github.com/Intervention/image.git synced 2025-08-30 09:10:21 +02:00

Add text stroke outline effect

Adds the possibility to draw text with an outline stroke effect. The color and width of the outline can be specified.

During development, it was noticed that Imagick can handle the effect natively, but always draws it in the middle of the border. As there is currently no option to change this, the same trick was used that was already used for the implementation with the GD library.

This "compound" method first draws the outline several times with an offset before the actual text is placed over it.

However, this has the disadvantage that no transparent colors can be used if the stroke/outline effect is active as the would superimpose each other.

---------

Co-authored-by: MaximusLight <maximuslight7@gmail.com>
Co-authored-by: Oliver Vogel <oliver@olivervogel.com>
Co-authored-by: Amowogbaje Gideon <amowogbajegideon@gmail.com>
This commit is contained in:
Oliver Vogel
2024-03-02 12:37:29 +01:00
committed by GitHub
parent f1589875a9
commit a673763e14
7 changed files with 448 additions and 25 deletions

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Intervention\Image\Drivers;
use Intervention\Image\Geometry\Point;
use Intervention\Image\Interfaces\FontInterface;
abstract class AbstractTextModifier extends DriverSpecialized
{
/**
* Return array of offset points to draw text stroke effect below the actual text
*
* @param FontInterface $font
* @return array
*/
protected function strokeOffsets(FontInterface $font): array
{
$offsets = [];
if ($font->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;
}
}

View File

@@ -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

View File

@@ -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
*

View File

@@ -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
*

View File

@@ -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}
*

View File

@@ -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);

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Intervention\Image\Tests\Unit\Drivers;
use Intervention\Image\Drivers\AbstractTextModifier;
use Intervention\Image\Geometry\Point;
use PHPUnit\Framework\Attributes\CoversClass;
use Intervention\Image\Tests\BaseTestCase;
use Intervention\Image\Typography\Font;
use Mockery;
#[CoversClass(AbstractTextModifier::class)]
final class AbstractTextModifierTest extends BaseTestCase
{
public function testStrokeOffsets(): void
{
$modifier = Mockery::mock(AbstractTextModifier::class)->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)
));
}
}