From ac7389fa9620b634574558c64e0f6229fadf693e Mon Sep 17 00:00:00 2001 From: Oliver Vogel Date: Sat, 3 Feb 2024 12:25:38 +0100 Subject: [PATCH] Refactor font processing --- src/Drivers/AbstractFontProcessor.php | 103 +++++++++++++++ src/Drivers/AbstractTextModifier.php | 124 ------------------ src/Drivers/Gd/Driver.php | 11 ++ src/Drivers/Gd/FontProcessor.php | 88 +++++++++++++ src/Drivers/Gd/Modifiers/TextModifier.php | 106 ++------------- src/Drivers/Imagick/Driver.php | 11 ++ src/Drivers/Imagick/FontProcessor.php | 79 +++++++++++ .../Imagick/Modifiers/TextModifier.php | 78 +++-------- src/Interfaces/DriverInterface.php | 7 + src/Interfaces/FontProcessorInterface.php | 58 ++++++++ src/Typography/Line.php | 20 ++- tests/Drivers/Gd/FontProcessorTest.php | 95 ++++++++++++++ tests/Drivers/Imagick/FontProcessorTest.php | 21 +++ tests/Typography/LineTest.php | 9 ++ 14 files changed, 534 insertions(+), 276 deletions(-) create mode 100644 src/Drivers/AbstractFontProcessor.php delete mode 100644 src/Drivers/AbstractTextModifier.php create mode 100644 src/Drivers/Gd/FontProcessor.php create mode 100644 src/Drivers/Imagick/FontProcessor.php create mode 100644 src/Interfaces/FontProcessorInterface.php create mode 100644 tests/Drivers/Gd/FontProcessorTest.php create mode 100644 tests/Drivers/Imagick/FontProcessorTest.php diff --git a/src/Drivers/AbstractFontProcessor.php b/src/Drivers/AbstractFontProcessor.php new file mode 100644 index 00000000..ecef320d --- /dev/null +++ b/src/Drivers/AbstractFontProcessor.php @@ -0,0 +1,103 @@ +buildPivot($lines, $font, $position); + + $leading = $this->leading($font); + $blockWidth = $this->boxSize((string) $lines->longestLine(), $font)->width(); + + $x = $pivot->x(); + $y = $font->hasFilename() ? $pivot->y() + $this->capHeight($font) : $pivot->y(); + $x_adjustment = 0; + + foreach ($lines as $line) { + $line_width = $this->boxSize((string) $line, $font)->width(); + $x_adjustment = $font->alignment() == 'left' ? 0 : $blockWidth - $line_width; + $x_adjustment = $font->alignment() == 'right' ? intval(round($x_adjustment)) : $x_adjustment; + $x_adjustment = $font->alignment() == 'center' ? intval(round($x_adjustment / 2)) : $x_adjustment; + $position = new Point($x + $x_adjustment, $y); + $position->rotate($font->angle(), $pivot); + $line->setPosition($position); + $y += $leading; + } + + return $lines; + } + + /** + * {@inheritdoc} + * + * @see FontProcessorInterface::typographicalSize() + */ + public function typographicalSize(FontInterface $font): int + { + return $this->boxSize('Hy', $font)->height(); + } + + /** + * {@inheritdoc} + * + * @see FontProcessorInterface::capHeight() + */ + public function capHeight(FontInterface $font): int + { + return $this->boxSize('T', $font)->height(); + } + + /** + * {@inheritdoc} + * + * @see FontProcessorInterface::leading() + */ + public function leading(FontInterface $font): int + { + return intval(round($this->typographicalSize($font) * $font->lineHeight())); + } + + /** + * Build pivot point of textblock according to the font settings and based on given position + * + * @param TextBlock $block + * @param FontInterface $font + * @param PointInterface $position + * @return PointInterface + */ + protected function buildPivot(TextBlock $block, FontInterface $font, PointInterface $position): PointInterface + { + // bounding box + $box = (new Rectangle( + $this->boxSize((string) $block->longestLine(), $font)->width(), + $this->leading($font) * ($block->count() - 1) + $this->capHeight($font) + )); + + // set position + $box->setPivot($position); + + // alignment + $box->align($font->alignment()); + $box->valign($font->valignment()); + $box->rotate($font->angle()); + + return $box->last(); + } +} diff --git a/src/Drivers/AbstractTextModifier.php b/src/Drivers/AbstractTextModifier.php deleted file mode 100644 index 195d5c01..00000000 --- a/src/Drivers/AbstractTextModifier.php +++ /dev/null @@ -1,124 +0,0 @@ -fontSizeInPixels() * $this->font->lineHeight())); - } - - /** - * Calculates typographical cap height - * - * @return int - */ - public function capHeight(): int - { - return $this->boxSize('T')->height(); - } - - /** - * Calculates the font size in pixels - * - * @return int - */ - public function fontSizeInPixels(): int - { - return $this->boxSize('Hy')->height(); - } - - /** - * Build TextBlock object from text string and align every line - * according to text modifier's font object and position. - * - * @param Point $position - * @param string $text - * @return TextBlock - */ - public function alignedTextBlock(Point $position, string $text, ?int $width = null): TextBlock - { - $lines = new TextBlock($text); - - // wrap lines - $lines = $lines->wrap($width); - - $boundingBox = $this->boundingBox($lines, $position); - $pivot = $boundingBox->last(); - - $leading = $this->leadingInPixels(); - $blockWidth = $this->boxSize((string) $lines->longestLine())->width(); - - $x = $pivot->x(); - $y = $this->font->hasFilename() ? $pivot->y() + $this->capHeight() : $pivot->y(); - $x_adjustment = 0; - - foreach ($lines as $line) { - $line_width = $this->boxSize((string) $line)->width(); - $x_adjustment = $this->font->alignment() == 'left' ? 0 : $blockWidth - $line_width; - $x_adjustment = $this->font->alignment() == 'right' ? intval(round($x_adjustment)) : $x_adjustment; - $x_adjustment = $this->font->alignment() == 'center' ? intval(round($x_adjustment / 2)) : $x_adjustment; - $position = new Point($x + $x_adjustment, $y); - $position->rotate($this->font->angle(), $pivot); - $line->setPosition($position); - $y += $leading; - } - - return $lines; - } - - /** - * Returns bounding box of the given text block according to text modifier's - * font settings and given pivot point - * - * @param TextBlock $block - * @param Point|null $pivot - * @return Polygon - */ - public function boundingBox(TextBlock $block, ?Point $pivot = null): Polygon - { - $pivot = $pivot ? $pivot : new Point(); - - // bounding box - $box = (new Rectangle( - $this->boxSize((string) $block->longestLine())->width(), - $this->leadingInPixels() * ($block->count() - 1) + $this->capHeight() - )); - - // set pivot - $box->setPivot($pivot); - - // align - $box->align($this->font->alignment()); - $box->valign($this->font->valignment()); - - $box->rotate($this->font->angle()); - - return $box; - } -} diff --git a/src/Drivers/Gd/Driver.php b/src/Drivers/Gd/Driver.php index 2c80ee40..f454265e 100644 --- a/src/Drivers/Gd/Driver.php +++ b/src/Drivers/Gd/Driver.php @@ -11,6 +11,7 @@ use Intervention\Image\Interfaces\ColorInterface; use Intervention\Image\Interfaces\ColorProcessorInterface; use Intervention\Image\Interfaces\ColorspaceInterface; use Intervention\Image\Interfaces\DriverInterface; +use Intervention\Image\Interfaces\FontProcessorInterface; use Intervention\Image\Interfaces\ImageInterface; class Driver extends AbstractDriver @@ -120,4 +121,14 @@ class Driver extends AbstractDriver { return new ColorProcessor($colorspace); } + + /** + * {@inheritdoc} + * + * @see DriverInterface::fontProcessor() + */ + public function fontProcessor(): FontProcessorInterface + { + return new FontProcessor(); + } } diff --git a/src/Drivers/Gd/FontProcessor.php b/src/Drivers/Gd/FontProcessor.php new file mode 100644 index 00000000..b90b4666 --- /dev/null +++ b/src/Drivers/Gd/FontProcessor.php @@ -0,0 +1,88 @@ +hasFilename()) { + // calculate box size from gd font + $box = new Rectangle(0, 0); + $chars = mb_strlen($text); + if ($chars > 0) { + $box->setWidth( + $chars * $this->gdCharacterWidth((int) $font->filename()) + ); + $box->setHeight( + $this->gdCharacterHeight((int) $font->filename()) + ); + } + return $box; + } + + // calculate box size from ttf font file with angle 0 + $box = imageftbbox( + $this->nativeFontSize($font), + 0, + $font->filename(), + $text + ); + + // build size from points + return new Rectangle( + intval(abs($box[4] - $box[0])), + intval(abs($box[5] - $box[1])) + ); + } + + /** + * {@inheritdoc} + * + * @see FontProcessorInterface::nativeFontSize() + */ + public function nativeFontSize(FontInterface $font): float + { + return floatval(ceil($font->size() * .75)); + } + + /** + * Return width of a single character + * + * @param int $gdfont + * @return int + */ + protected function gdCharacterWidth(int $gdfont): int + { + return $gdfont + 4; + } + + /** + * Return height of a single character + * + * @param int $gdfont + * @return int + */ + protected function gdCharacterHeight(int $gdfont): int + { + return match ($gdfont) { + 2, 3 => 14, + 4, 5 => 16, + default => 8, + }; + } +} diff --git a/src/Drivers/Gd/Modifiers/TextModifier.php b/src/Drivers/Gd/Modifiers/TextModifier.php index d9b2237c..20da2173 100644 --- a/src/Drivers/Gd/Modifiers/TextModifier.php +++ b/src/Drivers/Gd/Modifiers/TextModifier.php @@ -4,24 +4,28 @@ declare(strict_types=1); namespace Intervention\Image\Drivers\Gd\Modifiers; -use Intervention\Image\Drivers\AbstractTextModifier; +use Intervention\Image\Drivers\DriverSpecialized; use Intervention\Image\Interfaces\ImageInterface; use Intervention\Image\Geometry\Point; -use Intervention\Image\Geometry\Polygon; -use Intervention\Image\Geometry\Rectangle; use Intervention\Image\Interfaces\FontInterface; +use Intervention\Image\Interfaces\ModifierInterface; /** * @property Point $position * @property string $text * @property FontInterface $font */ -class TextModifier extends AbstractTextModifier +class TextModifier extends DriverSpecialized implements ModifierInterface { + /** + * {@inheritdoc} + * + * @see ModifierInterface::apply() + */ public function apply(ImageInterface $image): ImageInterface { - $lines = $this->alignedTextBlock($this->position, $this->text); - + $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()) ); @@ -32,7 +36,7 @@ class TextModifier extends AbstractTextModifier imagealphablending($frame->native(), true); imagettftext( $frame->native(), - $this->adjustedFontSize(), + $fontProcessor->nativeFontSize($this->font), $this->font->angle() * -1, $line->position()->x(), $line->position()->y(), @@ -45,7 +49,7 @@ class TextModifier extends AbstractTextModifier foreach ($lines as $line) { imagestring( $frame->native(), - $this->getGdFont(), + $this->gdFont(), $line->position()->x(), $line->position()->y(), (string) $line, @@ -58,58 +62,12 @@ class TextModifier extends AbstractTextModifier return $image; } - /** - * {@inheritdoc} - * - * @see AbstractTextModifier::boxSize() - */ - protected function boxSize(string $text): Polygon - { - if (!$this->font->hasFilename()) { - // calculate box size from gd font - $box = new Rectangle(0, 0); - $chars = mb_strlen($text); - if ($chars > 0) { - $box->setWidth($chars * $this->getGdFontWidth()); - $box->setHeight($this->getGdFontHeight()); - } - return $box; - } - - // calculate box size from font file with angle 0 - $box = imageftbbox( - $this->adjustedFontSize(), - 0, - $this->font->filename(), - $text - ); - - // build polygon from points - $polygon = new Polygon(); - $polygon->addPoint(new Point($box[6], $box[7])); - $polygon->addPoint(new Point($box[4], $box[5])); - $polygon->addPoint(new Point($box[2], $box[3])); - $polygon->addPoint(new Point($box[0], $box[1])); - - return $polygon; - } - - /** - * Calculate font size for `imagettftext` from given font size - * - * @return float - */ - private function adjustedFontSize(): float - { - return floatval(ceil($this->font->size() * .75)); - } - - /** + /** * Return GD's internal font size (if no ttf file is set) * * @return int */ - private function getGdFont(): int + private function gdFont(): int { if (is_numeric($this->font->filename())) { return intval($this->font->filename()); @@ -117,40 +75,4 @@ class TextModifier extends AbstractTextModifier return 1; } - - /** - * Font width to calculate box size, only applicable when no ttf file is set - * - * @return int - */ - private function getGdFontWidth(): int - { - return $this->getGdFont() + 4; - } - - /** - * Font height to calculate box size, only applicable when no ttf file is set - * - * @return int - */ - private function getGdFontHeight(): int - { - switch ($this->getGdFont()) { - case 2: - return 14; - - case 3: - return 14; - - case 4: - return 16; - - case 5: - return 16; - - default: - case 1: - return 8; - } - } } diff --git a/src/Drivers/Imagick/Driver.php b/src/Drivers/Imagick/Driver.php index baa154fd..531bba39 100644 --- a/src/Drivers/Imagick/Driver.php +++ b/src/Drivers/Imagick/Driver.php @@ -13,6 +13,7 @@ use Intervention\Image\Interfaces\ColorInterface; use Intervention\Image\Interfaces\ColorProcessorInterface; use Intervention\Image\Interfaces\ColorspaceInterface; use Intervention\Image\Interfaces\DriverInterface; +use Intervention\Image\Interfaces\FontProcessorInterface; use Intervention\Image\Interfaces\ImageInterface; class Driver extends AbstractDriver @@ -123,4 +124,14 @@ class Driver extends AbstractDriver { return new ColorProcessor($colorspace); } + + /** + * {@inheritdoc} + * + * @see DriverInterface::fontProcessor() + */ + public function fontProcessor(): FontProcessorInterface + { + return new FontProcessor(); + } } diff --git a/src/Drivers/Imagick/FontProcessor.php b/src/Drivers/Imagick/FontProcessor.php new file mode 100644 index 00000000..1d24537c --- /dev/null +++ b/src/Drivers/Imagick/FontProcessor.php @@ -0,0 +1,79 @@ +toImagickDraw($font); + $draw->setStrokeAntialias(true); + $draw->setTextAntialias(true); + $dimensions = (new Imagick())->queryFontMetrics($draw, $text); + + return new Rectangle( + intval(round($dimensions['textWidth'])), + intval(round($dimensions['ascender'] + $dimensions['descender'])), + ); + } + + public function nativeFontSize(FontInterface $font): float + { + return $font->size(); + } + + /** + * Imagick::annotateImage() needs an ImagickDraw object - this method takes + * the font object as the base and adds an optional passed color to the new + * ImagickDraw object. + * + * @param FontInterface $font + * @param null|ImagickPixel $color + * @return ImagickDraw + * @throws FontException + * @throws ImagickDrawException + * @throws ImagickException + */ + public function toImagickDraw(FontInterface $font, ?ImagickPixel $color = null): ImagickDraw + { + if (!$font->hasFilename()) { + throw new FontException('No font file specified.'); + } + + $draw = new ImagickDraw(); + $draw->setStrokeAntialias(true); + $draw->setTextAntialias(true); + $draw->setFont($font->filename()); + $draw->setFontSize($this->nativeFontSize($font)); + $draw->setTextAlignment(Imagick::ALIGN_LEFT); + + if ($color) { + $draw->setFillColor($color); + } + + return $draw; + } +} diff --git a/src/Drivers/Imagick/Modifiers/TextModifier.php b/src/Drivers/Imagick/Modifiers/TextModifier.php index 97d5a40c..3f14813c 100644 --- a/src/Drivers/Imagick/Modifiers/TextModifier.php +++ b/src/Drivers/Imagick/Modifiers/TextModifier.php @@ -4,35 +4,35 @@ declare(strict_types=1); namespace Intervention\Image\Drivers\Imagick\Modifiers; -use Imagick; -use ImagickDraw; -use ImagickDrawException; -use ImagickException; -use ImagickPixel; -use Intervention\Image\Drivers\AbstractTextModifier; +use Intervention\Image\Drivers\DriverSpecialized; +use Intervention\Image\Drivers\Imagick\FontProcessor; use Intervention\Image\Exceptions\FontException; use Intervention\Image\Geometry\Point; -use Intervention\Image\Geometry\Polygon; -use Intervention\Image\Geometry\Rectangle; use Intervention\Image\Interfaces\FontInterface; use Intervention\Image\Interfaces\ImageInterface; +use Intervention\Image\Interfaces\ModifierInterface; /** * @property Point $position * @property string $text * @property FontInterface $font */ -class TextModifier extends AbstractTextModifier +class TextModifier extends DriverSpecialized implements ModifierInterface { + /** + * {@inheritdoc} + * + * @see ModifierInterface::apply() + */ public function apply(ImageInterface $image): ImageInterface { - $lines = $this->alignedTextBlock($this->position, $this->text); - + $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 = $this->toImagickDraw($color); + $draw = $fontProcessor->toImagickDraw($this->font, $color); foreach ($image as $frame) { foreach ($lines as $line) { @@ -50,56 +50,18 @@ class TextModifier extends AbstractTextModifier } /** - * {@inheritdoc} + * Return imagick font processor * - * @see AbstractTextModifier::boxSize() + * @return FontProcessor */ - protected function boxSize(string $text): Polygon + private function processor(): FontProcessor { - // no text - no box size - if (mb_strlen($text) === 0) { - return (new Rectangle(0, 0)); + $processor = $this->driver()->fontProcessor(); + + if (!($processor instanceof FontProcessor)) { + throw new FontException('Font processor does not match the driver.'); } - $draw = $this->toImagickDraw(); - $draw->setStrokeAntialias(true); - $draw->setTextAntialias(true); - $dimensions = (new Imagick())->queryFontMetrics($draw, $text); - - return (new Rectangle( - intval(round($dimensions['textWidth'])), - intval(round($dimensions['ascender'] + $dimensions['descender'])), - )); - } - - /** - * Imagick::annotateImage() needs an ImagickDraw object - this method takes - * the text color as the base and adds the text modifiers font settings - * to the new ImagickDraw object. - * - * @param null|ImagickPixel $color - * @return ImagickDraw - * @throws FontException - * @throws ImagickDrawException - * @throws ImagickException - */ - private function toImagickDraw(?ImagickPixel $color = null): ImagickDraw - { - if (!$this->font->hasFilename()) { - throw new FontException('No font file specified.'); - } - - $draw = new ImagickDraw(); - $draw->setStrokeAntialias(true); - $draw->setTextAntialias(true); - $draw->setFont($this->font->filename()); - $draw->setFontSize($this->font->size()); - $draw->setTextAlignment(Imagick::ALIGN_LEFT); - - if ($color) { - $draw->setFillColor($color); - } - - return $draw; + return $processor; } } diff --git a/src/Interfaces/DriverInterface.php b/src/Interfaces/DriverInterface.php index 9fb01b19..a3ac73a9 100644 --- a/src/Interfaces/DriverInterface.php +++ b/src/Interfaces/DriverInterface.php @@ -63,6 +63,13 @@ interface DriverInterface */ public function colorProcessor(ColorspaceInterface $colorspace): ColorProcessorInterface; + /** + * Return font processor of the current driver + * + * @return FontProcessorInterface + */ + public function fontProcessor(): FontProcessorInterface; + /** * Check whether all requirements for operating the driver are met and * throw exception if the check fails. diff --git a/src/Interfaces/FontProcessorInterface.php b/src/Interfaces/FontProcessorInterface.php new file mode 100644 index 00000000..9adc8ebd --- /dev/null +++ b/src/Interfaces/FontProcessorInterface.php @@ -0,0 +1,58 @@ +segments = explode(" ", $text); } /** @@ -45,6 +51,16 @@ class Line return $this; } + /** + * Count segments of line + * + * @return int + */ + public function count(): int + { + return count($this->segments); + } + /** * Cast line to string * @@ -52,6 +68,6 @@ class Line */ public function __toString(): string { - return $this->text; + return implode(" ", $this->segments); } } diff --git a/tests/Drivers/Gd/FontProcessorTest.php b/tests/Drivers/Gd/FontProcessorTest.php new file mode 100644 index 00000000..3159fbc3 --- /dev/null +++ b/tests/Drivers/Gd/FontProcessorTest.php @@ -0,0 +1,95 @@ +boxSize('test', new Font()); + $this->assertInstanceOf(SizeInterface::class, $size); + $this->assertEquals(16, $size->width()); + $this->assertEquals(8, $size->height()); + } + + public function testBoxSizeGdTwo(): void + { + $processor = new FontProcessor(); + $size = $processor->boxSize('test', new Font('2')); + $this->assertInstanceOf(SizeInterface::class, $size); + $this->assertEquals(24, $size->width()); + $this->assertEquals(14, $size->height()); + } + + public function testBoxSizeGdThree(): void + { + $processor = new FontProcessor(); + $size = $processor->boxSize('test', new Font('3')); + $this->assertInstanceOf(SizeInterface::class, $size); + $this->assertEquals(28, $size->width()); + $this->assertEquals(14, $size->height()); + } + + public function testBoxSizeGdFour(): void + { + $processor = new FontProcessor(); + $size = $processor->boxSize('test', new Font('4')); + $this->assertInstanceOf(SizeInterface::class, $size); + $this->assertEquals(32, $size->width()); + $this->assertEquals(16, $size->height()); + } + + public function testBoxSizeGdFive(): void + { + $processor = new FontProcessor(); + $size = $processor->boxSize('test', new Font('5')); + $this->assertInstanceOf(SizeInterface::class, $size); + $this->assertEquals(36, $size->width()); + $this->assertEquals(16, $size->height()); + } + + public function testNativeFontSize(): void + { + $processor = new FontProcessor(); + $size = $processor->nativeFontSize(new Font('5')); + $this->assertEquals(9.0, $size); + } + + public function testTextBlock(): void + { + $processor = new FontProcessor(); + $result = $processor->textBlock('test', new Font(), new Point(0, 0)); + $this->assertInstanceOf(TextBlock::class, $result); + } + + public function testTypographicalSize(): void + { + $processor = new FontProcessor(); + $result = $processor->typographicalSize(new Font()); + $this->assertEquals(8, $result); + } + + public function testCapHeight(): void + { + $processor = new FontProcessor(); + $result = $processor->capHeight(new Font()); + $this->assertEquals(8, $result); + } + + public function testLeading(): void + { + $processor = new FontProcessor(); + $result = $processor->leading(new Font()); + $this->assertEquals(10, $result); + } +} diff --git a/tests/Drivers/Imagick/FontProcessorTest.php b/tests/Drivers/Imagick/FontProcessorTest.php new file mode 100644 index 00000000..8f5627e2 --- /dev/null +++ b/tests/Drivers/Imagick/FontProcessorTest.php @@ -0,0 +1,21 @@ +setSize(14.2); + $size = $processor->nativeFontSize($font); + $this->assertEquals(14.2, $size); + } +} diff --git a/tests/Typography/LineTest.php b/tests/Typography/LineTest.php index 1c7d3432..b450435f 100644 --- a/tests/Typography/LineTest.php +++ b/tests/Typography/LineTest.php @@ -32,4 +32,13 @@ class LineTest extends TestCase $this->assertEquals(10, $line->position()->x()); $this->assertEquals(11, $line->position()->y()); } + + public function testCount(): void + { + $line = new Line("foo"); + $this->assertEquals(1, $line->count()); + + $line = new Line("foo bar"); + $this->assertEquals(2, $line->count()); + } }