From bee471e68a553295139c7a3b295609dbee0c39ed Mon Sep 17 00:00:00 2001 From: Oliver Vogel Date: Sun, 3 Aug 2025 08:01:23 +0200 Subject: [PATCH] Implement DataUri::class --- changelog.md | 2 + src/DataUri.php | 270 ++++++++++++++++++ src/Drivers/AbstractDecoder.php | 56 ---- .../Gd/Decoders/DataUriImageDecoder.php | 13 +- .../Imagick/Decoders/DataUriImageDecoder.php | 13 +- src/EncodedImage.php | 8 +- src/Interfaces/DataUriInterface.php | 92 ++++++ src/Interfaces/EncodedImageInterface.php | 2 +- tests/Providers/DataUriDataProvider.php | 103 +++++++ tests/Unit/DataUriTest.php | 135 +++++++++ tests/Unit/Drivers/AbstractDecoderTest.php | 43 --- 11 files changed, 613 insertions(+), 124 deletions(-) create mode 100644 src/DataUri.php create mode 100644 src/Interfaces/DataUriInterface.php create mode 100644 tests/Providers/DataUriDataProvider.php create mode 100644 tests/Unit/DataUriTest.php diff --git a/changelog.md b/changelog.md index 084ccb62..133b32e7 100644 --- a/changelog.md +++ b/changelog.md @@ -10,6 +10,7 @@ - Alignment::class - DriverInterface::handleImageInput() - DriverInterface::handleColorInput() +- DataUri::class ## API Changes @@ -24,3 +25,4 @@ - Changed default value for `background` to `null` in ImageInterface::crop() - Signature of ImageInterface::crop() changed `offset_x` is no `x` and `offset_y` is now `y` - Signature of ImageInterface::place() changed `offset_x` is no `x` and `offset_y` is now `y` +- EncodedImageInterface::toDataUri() now returns `DataUriInterface` instead of `string“ diff --git a/src/DataUri.php b/src/DataUri.php new file mode 100644 index 00000000..38c1bbdd --- /dev/null +++ b/src/DataUri.php @@ -0,0 +1,270 @@ +\w+\/[-+.\w]+)?" . + "(?P(;[-\w]+=[-\w]+)*)(?P;base64)?,(?P.*)/"; + + /** + * Media type of data uri output + */ + protected ?string $mediaType = null; + + /** + * Parameters of data uri output + * + * @var array + */ + protected array $parameters = []; + + /** + * Create new data uri instanceof + * + * @param array $parameters + */ + public function __construct( + protected string $data = '', + null|string|MediaType $mediaType = null, + array $parameters = [], + protected bool $isBase64Encoded = false + ) { + $this->setMediaType($mediaType); + $this->setParameters($parameters); + } + + /** + * {@inheritdoc} + * + * @see DataUriInterface::readFromString() + * + * @throws DecoderException + */ + public static function readFromString(string $dataUriScheme): self + { + $result = preg_match(self::PATTERN, $dataUriScheme, $matches); + + if ($result === false) { + throw new DecoderException('Unable to decode data uri scheme from string.'); + } + + $data = $matches['data'] ?? ''; + $isBase64Encoded = array_key_exists('base64', $matches) && $matches['base64'] !== ''; + + $datauri = new self( + data: $isBase64Encoded ? base64_decode($data) : rawurldecode($data), + mediaType: $matches['mediaType'] ?? '', + isBase64Encoded: $isBase64Encoded, + ); + + if (array_key_exists('parameters', $matches) && $matches['parameters'] !== '') { + $parameters = explode(';', $matches['parameters']); + $parameters = array_filter($parameters, fn(string $value): bool => $value !== ''); + $parameters = array_map(fn(string $value): array => explode('=', $value), $parameters); + foreach ($parameters as $parameter) { + $datauri->setParameter(...$parameter); + } + } + + return $datauri; + } + + /** + * {@inheritdoc} + * + * @see DataUriInterface::createBase64Encoded() + */ + public static function createBase64Encoded( + string $data, + null|string|MediaType $mediaType = null, + array $parameters = [], + ): self { + return new self( + data: base64_encode($data), + mediaType: $mediaType, + parameters: $parameters, + isBase64Encoded: true + ); + } + + /** + * {@inheritdoc} + * + * @see DataUriInterface::data() + */ + public function data(): string + { + return $this->data; + } + + /** + * {@inheritdoc} + * + * @see DataUriInterface::setData() + */ + public function setData(string $data): self + { + $this->data = $data; + + return $this; + } + + /** + * {@inheritdoc} + * + * @see DataUriInterface::mediaType() + */ + public function mediaType(): ?string + { + return $this->mediaType; + } + + /** + * {@inheritdoc} + * + * @see DataUriInterface::setMediaType() + */ + public function setMediaType(null|string|MediaType $mediaType): self + { + $this->mediaType = $mediaType instanceof MediaType ? $mediaType->value : $mediaType; + + return $this; + } + + /** + * {@inheritdoc} + * + * @see DataUriInterface::parameters() + */ + public function parameters(): array + { + return $this->parameters; + } + + /** + * {@inheritdoc} + * + * @see DataUriInterface::setParameters() + */ + public function setParameters(array $parameters): self + { + $this->parameters = $parameters; + + return $this; + } + + /** + * {@inheritdoc} + * + * @see DataUriInterface::appendParameters() + */ + public function appendParameters(array $parameters): self + { + foreach ($parameters as $key => $value) { + $this->setParameter((string) $key, (string) $value); + } + + return $this; + } + + /** + * {@inheritdoc} + * + * @see DataUriInterface::parameter() + */ + public function parameter(string $key): ?string + { + return $this->parameters[$key] ?? null; + } + + /** + * {@inheritdoc} + * + * @see DataUriInterface::setParameter() + */ + public function setParameter(string $key, string $value): self + { + $this->parameters[$key] = $value; + + return $this; + } + + /** + * {@inheritdoc} + * + * @see DataUriInterface::charset() + */ + public function charset(): ?string + { + return $this->parameter('charset'); + } + + /** + * {@inheritdoc} + * + * @see DataUriInterface::setCharset() + */ + public function setCharset(string $charset): self + { + $this->setParameter('charset', $charset); + + return $this; + } + + /** + * Prepare data for output + */ + private function encodedData(): string + { + return $this->isBase64Encoded ? $this->data : rawurlencode($this->data); + } + + /** + * Prepare all set parameters for output + */ + private function encodedParameters(): string + { + if (count($this->parameters) === 0 && $this->isBase64Encoded === false) { + return ''; + } + + $parameters = array_map(function (mixed $key, mixed $value) { + return $key . '=' . $value; + }, array_keys($this->parameters), $this->parameters); + + $parameterString = count($parameters) ? ';' . implode(';', $parameters) : ''; + + if ($this->isBase64Encoded) { + $parameterString .= ';base64'; + } + + return $parameterString; + } + + /** + * {@inheritdoc} + * + * @see DataUriInterface::toString() + */ + public function toString(): string + { + return 'data:' . $this->mediaType() . $this->encodedParameters() . ',' . $this->encodedData(); + } + + /** + * {@inheritdoc} + * + * @see Stringable::__toString() + */ + public function __toString(): string + { + return $this->toString(); + } +} diff --git a/src/Drivers/AbstractDecoder.php b/src/Drivers/AbstractDecoder.php index dd0ef0a8..5de6af67 100644 --- a/src/Drivers/AbstractDecoder.php +++ b/src/Drivers/AbstractDecoder.php @@ -86,62 +86,6 @@ abstract class AbstractDecoder implements DecoderInterface return $decoded; } - /** - * Parse data uri - */ - protected function parseDataUri(mixed $input): object - { - $pattern = "/^data:(?P\w+\/[-+.\w]+)?" . - "(?P(;[-\w]+=[-\w]+)*)(?P;base64)?,(?P.*)/"; - - $result = preg_match($pattern, (string) $input, $matches); - - return new class ($matches, $result) - { - /** - * @param array $matches - * @return void - */ - public function __construct(private array $matches, private int|false $result) - { - // - } - - public function isValid(): bool - { - return (bool) $this->result; - } - - public function mediaType(): ?string - { - if (isset($this->matches['mediatype']) && !empty($this->matches['mediatype'])) { - return $this->matches['mediatype']; - } - - return null; - } - - public function hasMediaType(): bool - { - return !empty($this->mediaType()); - } - - public function isBase64Encoded(): bool - { - return isset($this->matches['base64']) && $this->matches['base64'] === ';base64'; - } - - public function data(): ?string - { - if (isset($this->matches['data']) && !empty($this->matches['data'])) { - return $this->matches['data']; - } - - return null; - } - }; - } - /** * Parse and return a given file path or throw detailed exception if the path is invalid * diff --git a/src/Drivers/Gd/Decoders/DataUriImageDecoder.php b/src/Drivers/Gd/Decoders/DataUriImageDecoder.php index 81d7dd28..78909652 100644 --- a/src/Drivers/Gd/Decoders/DataUriImageDecoder.php +++ b/src/Drivers/Gd/Decoders/DataUriImageDecoder.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Intervention\Image\Drivers\Gd\Decoders; +use Intervention\Image\DataUri; use Intervention\Image\Exceptions\DecoderException; use Intervention\Image\Interfaces\ColorInterface; use Intervention\Image\Interfaces\DecoderInterface; @@ -22,16 +23,6 @@ class DataUriImageDecoder extends BinaryImageDecoder implements DecoderInterface throw new DecoderException('Data Uri must be of type string.'); } - $uri = $this->parseDataUri($input); - - if (!$uri->isValid()) { - throw new DecoderException('Input is no valid Data Uri Scheme.'); - } - - if ($uri->isBase64Encoded()) { - return parent::decode(base64_decode($uri->data())); - } - - return parent::decode(urldecode($uri->data())); + return parent::decode(DataUri::readFromString($input)->data()); } } diff --git a/src/Drivers/Imagick/Decoders/DataUriImageDecoder.php b/src/Drivers/Imagick/Decoders/DataUriImageDecoder.php index 6e65d7ee..e0ff5479 100644 --- a/src/Drivers/Imagick/Decoders/DataUriImageDecoder.php +++ b/src/Drivers/Imagick/Decoders/DataUriImageDecoder.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Intervention\Image\Drivers\Imagick\Decoders; +use Intervention\Image\DataUri; use Intervention\Image\Exceptions\DecoderException; use Intervention\Image\Interfaces\ColorInterface; use Intervention\Image\Interfaces\ImageInterface; @@ -21,16 +22,6 @@ class DataUriImageDecoder extends BinaryImageDecoder throw new DecoderException('Data Uri must be of type string.'); } - $uri = $this->parseDataUri($input); - - if (!$uri->isValid()) { - throw new DecoderException('Input is no valid Data Uri Scheme.'); - } - - if ($uri->isBase64Encoded()) { - return parent::decode(base64_decode($uri->data())); - } - - return parent::decode(urldecode($uri->data())); + return parent::decode(DataUri::readFromString($input)->data()); } } diff --git a/src/EncodedImage.php b/src/EncodedImage.php index 1d6bdb6c..57ebcd11 100644 --- a/src/EncodedImage.php +++ b/src/EncodedImage.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Intervention\Image; +use Intervention\Image\Interfaces\DataUriInterface; use Intervention\Image\Interfaces\EncodedImageInterface; use Throwable; @@ -46,9 +47,12 @@ class EncodedImage extends File implements EncodedImageInterface * * @see EncodedImageInterface::toDataUri() */ - public function toDataUri(): string + public function toDataUri(): DataUriInterface { - return sprintf('data:%s;base64,%s', $this->mediaType(), base64_encode((string) $this)); + return DataUri::createBase64Encoded( + data: (string) $this, + mediaType: $this->mediaType(), + ); } /** diff --git a/src/Interfaces/DataUriInterface.php b/src/Interfaces/DataUriInterface.php new file mode 100644 index 00000000..aaf53099 --- /dev/null +++ b/src/Interfaces/DataUriInterface.php @@ -0,0 +1,92 @@ + $parameters + */ + public static function createBase64Encoded( + string $data, + null|string|MediaType $mediaType = null, + array $parameters = [], + ): self; + + /** + * Return current data uri data + */ + public function data(): string; + + /** + * Set data of current data uri scheme + */ + public function setData(string $data): self; + + /** + * Get media type of current data uri output + */ + public function mediaType(): ?string; + + /** + * Set media type of current data uri output + */ + public function setMediaType(null|string|MediaType $mediaType): self; + + /** + * Get all parameters of current data uri output + * + * @return array + */ + public function parameters(): array; + + /** + * Set (overwrite) all parameters of current data uri output + * + * @param array $parameters + */ + public function setParameters(array $parameters): self; + + /** + * Append given parameters to current data uri output + * + * @param array $parameters + */ + public function appendParameters(array $parameters): self; + + /** + * Get value of given parameter, return null if parameter is not set + */ + public function parameter(string $key): ?string; + + /** + * Set (overwrite) parameter of given key to given value + */ + public function setParameter(string $key, string $value): self; + + /** + * Get charset of current data uri scheme, null if no charset is defined + */ + public function charset(): ?string; + + /** + * Define charset of current data uri scheme + */ + public function setCharset(string $charset): self; + + /** + * Transform current data uri scheme to string + */ + public function toString(): string; +} diff --git a/src/Interfaces/EncodedImageInterface.php b/src/Interfaces/EncodedImageInterface.php index d521d8e0..81965cf3 100644 --- a/src/Interfaces/EncodedImageInterface.php +++ b/src/Interfaces/EncodedImageInterface.php @@ -19,5 +19,5 @@ interface EncodedImageInterface extends FileInterface /** * Transform encoded image data into an data uri string */ - public function toDataUri(): string; + public function toDataUri(): DataUriInterface; } diff --git a/tests/Providers/DataUriDataProvider.php b/tests/Providers/DataUriDataProvider.php new file mode 100644 index 00000000..66edaaca --- /dev/null +++ b/tests/Providers/DataUriDataProvider.php @@ -0,0 +1,103 @@ +alert(\'hi\');', + '', + ]; + } + + public static function invalidDataUris(): Generator + { + yield [ + 'foo' + ]; + yield [ + 'bar' + ]; + yield [ + 'data:' + ]; + yield [ + 'data:;base64,foo' + ]; + yield [ + 'data:foo/plain,foobar' + ]; + yield [ + 'data:;base64,VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wcyBvdmVyIHRoZSBsYXp5IGRvZy=' + ]; + yield [ + 'data:image/jpeg;base64,VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wcyBvdmVyIHRoZSBsYXp5IGRvZy=' + ]; + yield [ + 'VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wcyBvdmVyIHRoZSBsYXp5IGRvZy4=' + ]; + yield [ + 'data:text;base64,SGVsbG8sIFdvcmxkIQ==' + ]; + } +} diff --git a/tests/Unit/DataUriTest.php b/tests/Unit/DataUriTest.php new file mode 100644 index 00000000..546c27c4 --- /dev/null +++ b/tests/Unit/DataUriTest.php @@ -0,0 +1,135 @@ +assertEquals('test', $datauri->data()); + $datauri->setData('foo'); + $this->assertEquals('foo', $datauri->data()); + } + + #[DataProvider('getSetMediaTypeDataProvider')] + public function testGetSetMediaType(mixed $inputMediaType, ?string $resultMediaType): void + { + $datauri = new DataUri(mediaType: $inputMediaType); + $this->assertEquals($resultMediaType, $datauri->mediaType()); + $datauri->setMediaType(null); + $this->assertNull($datauri->mediaType()); + } + + public static function getSetMediaTypeDataProvider(): Generator + { + yield [null, null]; + yield ['', null]; + yield ['image/jpeg', 'image/jpeg']; + yield ['image/gif', 'image/gif']; + yield [MediaType::IMAGE_AVIF, 'image/avif']; + } + + public function testSetGetParameters(): void + { + $datauri = new DataUri(); + $this->assertEquals([], $datauri->parameters()); + $datauri->setParameters(['foo' => 'bar']); + $this->assertEquals(['foo' => 'bar'], $datauri->parameters()); + $datauri->setParameters(['bar' => 'baz', 'test' => 123]); + $this->assertEquals(['bar' => 'baz', 'test' => '123'], $datauri->parameters()); + $datauri->setParameter('test', '456'); + $this->assertEquals(['bar' => 'baz', 'test' => '456'], $datauri->parameters()); + $datauri->appendParameters(['bar' => 'foobar', 'append' => 'ok']); + $this->assertEquals(['bar' => 'foobar', 'test' => '456', 'append' => 'ok'], $datauri->parameters()); + $this->assertEquals('foobar', $datauri->parameter('bar')); + $this->assertEquals('456', $datauri->parameter('test')); + $this->assertEquals('ok', $datauri->parameter('append')); + $this->assertEquals(null, $datauri->parameter('none')); + $datauri->setCharset('utf-8'); + $this->assertEquals('utf-8', $datauri->charset()); + $this->assertEquals([ + 'bar' => 'foobar', + 'test' => '456', + 'append' => 'ok', + 'charset' => 'utf-8', + ], $datauri->parameters()); + } + + /** + * @param array $parameters + */ + #[DataProvider('toStringDataProvider')] + public function testToString( + string $data, + null|string|MediaType $mediaType, + array $parameters, + bool $isBase64Encoded, + string $result, + ): void { + $datauri = new DataUri($data, $mediaType, $parameters, $isBase64Encoded); + $this->assertEquals($result, $datauri->toString()); + $this->assertEquals($result, (string) $datauri); + } + + public static function toStringDataProvider(): Generator + { + yield [ + '', + null, + [], + false, + 'data:,' + ]; + + yield [ + 'foo', + null, + [], + false, + 'data:,foo' + ]; + + yield [ + 'foo', + 'text/plain', + [], + false, + 'data:text/plain,foo' + ]; + + yield [ + 'foo', + 'text/plain', + ['charset' => 'utf-8'], + false, + 'data:text/plain;charset=utf-8,foo' + ]; + + yield [ + 'foo', + 'text/plain', + ['charset' => 'utf-8'], + true, + 'data:text/plain;charset=utf-8;base64,foo' + ]; + } + + #[DataProviderExternal(DataUriDataProvider::class, 'validDataUris')] + public function testCreateFromString(string $dataUriScheme, string $resultData): void + { + $datauri = DataUri::readFromString($dataUriScheme); + $this->assertInstanceOf(DataUri::class, $datauri); + $this->assertEquals($resultData, $datauri->data()); + } +} diff --git a/tests/Unit/Drivers/AbstractDecoderTest.php b/tests/Unit/Drivers/AbstractDecoderTest.php index ccb968fd..a965d2ec 100644 --- a/tests/Unit/Drivers/AbstractDecoderTest.php +++ b/tests/Unit/Drivers/AbstractDecoderTest.php @@ -47,49 +47,6 @@ final class AbstractDecoderTest extends BaseTestCase $this->assertEquals('Oliver Vogel', $result->get('IFD0.Artist')); } - public function testParseDataUri(): void - { - $decoder = new class () extends AbstractDecoder - { - public function parse(mixed $input): object - { - return parent::parseDataUri($input); - } - - public function decode(mixed $input): ImageInterface|ColorInterface - { - throw new Exception(''); - } - }; - - $result = $decoder->parse( - 'data:image/gif;foo=bar;base64,R0lGODdhAwADAKIAAAQyrKTy/ByS7AQytLT2/AAAAAAAAAAAACwAAAAAAwADAAADBhgU0gMgAQA7' - ); - - $this->assertTrue($result->isValid()); - $this->assertEquals('image/gif', $result->mediaType()); - $this->assertTrue($result->hasMediaType()); - $this->assertTrue($result->isBase64Encoded()); - $this->assertEquals( - 'R0lGODdhAwADAKIAAAQyrKTy/ByS7AQytLT2/AAAAAAAAAAAACwAAAAAAwADAAADBhgU0gMgAQA7', - $result->data() - ); - - $result = $decoder->parse('data:text/plain;charset=utf-8,test'); - $this->assertTrue($result->isValid()); - $this->assertEquals('text/plain', $result->mediaType()); - $this->assertTrue($result->hasMediaType()); - $this->assertFalse($result->isBase64Encoded()); - $this->assertEquals('test', $result->data()); - - $result = $decoder->parse('data:;charset=utf-8,'); - $this->assertTrue($result->isValid()); - $this->assertNull($result->mediaType()); - $this->assertFalse($result->hasMediaType()); - $this->assertFalse($result->isBase64Encoded()); - $this->assertNull($result->data()); - } - public function testIsValidBase64(): void { $decoder = new class () extends AbstractDecoder