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

Refactor font processing

This commit is contained in:
Oliver Vogel
2024-02-03 12:25:38 +01:00
parent 7b24205370
commit ac7389fa96
14 changed files with 534 additions and 276 deletions

View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace Intervention\Image\Drivers;
use Intervention\Image\Geometry\Point;
use Intervention\Image\Geometry\Rectangle;
use Intervention\Image\Interfaces\FontInterface;
use Intervention\Image\Interfaces\FontProcessorInterface;
use Intervention\Image\Interfaces\PointInterface;
use Intervention\Image\Typography\TextBlock;
abstract class AbstractFontProcessor implements FontProcessorInterface
{
/**
* {@inheritdoc}
*
* @see FontProcessorInterface::textBlock()
*/
public function textBlock(string $text, FontInterface $font, PointInterface $position): TextBlock
{
$lines = new TextBlock($text);
$pivot = $this->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();
}
}

View File

@@ -1,124 +0,0 @@
<?php
declare(strict_types=1);
namespace Intervention\Image\Drivers;
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;
use Intervention\Image\Typography\TextBlock;
/**
* @property FontInterface $font
*/
abstract class AbstractTextModifier extends DriverSpecialized implements ModifierInterface
{
/**
* Calculate size of bounding box of given text
*
* @return Polygon
*/
abstract protected function boxSize(string $text): Polygon;
/**
* Calculates typographical leanding
*
* @return int
*/
public function leadingInPixels(): int
{
return intval(round($this->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;
}
}

View File

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

View File

@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace Intervention\Image\Drivers\Gd;
use Intervention\Image\Drivers\AbstractFontProcessor;
use Intervention\Image\Geometry\Rectangle;
use Intervention\Image\Interfaces\FontInterface;
use Intervention\Image\Interfaces\SizeInterface;
class FontProcessor extends AbstractFontProcessor
{
/**
* {@inheritdoc}
*
* @see FontProcessorInterface::boxSize()
*/
public function boxSize(string $text, FontInterface $font): SizeInterface
{
// if the font has no ttf file the box size is calculated
// with gd's internal font system: integer values from 1-5
if (!$font->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,
};
}
}

View File

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

View File

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

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Intervention\Image\Drivers\Imagick;
use Imagick;
use ImagickDraw;
use ImagickDrawException;
use ImagickException;
use ImagickPixel;
use Intervention\Image\Drivers\AbstractFontProcessor;
use Intervention\Image\Exceptions\FontException;
use Intervention\Image\Geometry\Rectangle;
use Intervention\Image\Interfaces\FontInterface;
use Intervention\Image\Interfaces\SizeInterface;
class FontProcessor extends AbstractFontProcessor
{
/**
* {@inheritdoc}
*
* @see FontProcessorInterface::boxSize()
*/
public function boxSize(string $text, FontInterface $font): SizeInterface
{
// no text - no box size
if (mb_strlen($text) === 0) {
return (new Rectangle(0, 0));
}
$draw = $this->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;
}
}

View File

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

View File

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

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Intervention\Image\Interfaces;
use Intervention\Image\Typography\TextBlock;
interface FontProcessorInterface
{
/**
* Calculate size of bounding box of given text in conjuction with the given font
*
* @return SizeInterface
*/
public function boxSize(string $text, FontInterface $font): SizeInterface;
/**
* Build TextBlock object from text string and align every line
* according to text modifier's font object and position.
*
* @param string $text
* @param FontInterface $font
* @param PointInterface $position
* @return TextBlock
*/
public function textBlock(string $text, FontInterface $font, PointInterface $position): TextBlock;
/**
* Calculate the actual font size to pass at the driver level
*
* @return float
*/
public function nativeFontSize(FontInterface $font): float;
/**
* Calculate the typographical font size in pixels
*
* @return int
*/
public function typographicalSize(FontInterface $font): int;
/**
* Calculates typographical cap height
*
* @param FontInterface $font
* @return int
*/
public function capHeight(FontInterface $font): int;
/**
* Calculates typographical leanding
*
* @param FontInterface $font
* @return int
*/
public function leading(FontInterface $font): int;
}

View File

@@ -9,6 +9,11 @@ use Intervention\Image\Interfaces\PointInterface;
class Line
{
/**
* Segments (usually individual words) of the line
*/
protected array $segments = [];
/**
* Create new text line object with given text & position
*
@@ -17,9 +22,10 @@ class Line
* @return void
*/
public function __construct(
protected string $text,
string $text,
protected PointInterface $position = new Point()
) {
$this->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);
}
}

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace Intervention\Image\Tests\Drivers\Gd;
use Intervention\Image\Drivers\Gd\FontProcessor;
use Intervention\Image\Geometry\Point;
use Intervention\Image\Interfaces\SizeInterface;
use Intervention\Image\Tests\TestCase;
use Intervention\Image\Typography\Font;
use Intervention\Image\Typography\TextBlock;
class FontProcessorTest extends TestCase
{
public function testBoxSizeGdOne(): void
{
$processor = new FontProcessor();
$size = $processor->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);
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Intervention\Image\Tests\Drivers\Imagick;
use Intervention\Image\Drivers\Imagick\FontProcessor;
use Intervention\Image\Tests\TestCase;
use Intervention\Image\Typography\Font;
class FontProcessorTest extends TestCase
{
public function testNativeFontSize(): void
{
$processor = new FontProcessor();
$font = new Font();
$font->setSize(14.2);
$size = $processor->nativeFontSize($font);
$this->assertEquals(14.2, $size);
}
}

View File

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