From 31b55e0eb0a9b98f6774f2a3934ecc949d43c825 Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Sat, 31 Oct 2026 10:22:03 +0100 Subject: [PATCH 01/10] Allow zero dimensions in ImageInfo Handlers report a width and a height of 0 when the dimensions cannot be read from the image, so the documented range of the values was wrong. Co-Authored-By: Claude Sonnet 5.5 --- formwork/src/Images/ImageInfo.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/formwork/src/Images/ImageInfo.php b/formwork/src/Images/ImageInfo.php index 896fad402..40bcff9ae 100644 --- a/formwork/src/Images/ImageInfo.php +++ b/formwork/src/Images/ImageInfo.php @@ -11,12 +11,12 @@ class ImageInfo implements Arrayable protected string $mimeType; /** - * @var int<1, max> + * @var int<0, max> */ protected int $width; /** - * @var int<1, max> + * @var int<0, max> */ protected int $height; @@ -59,7 +59,7 @@ public function mimeType(): string /** * Get image width * - * @return int<1, max> + * @return int<0, max> */ public function width(): int { @@ -69,7 +69,7 @@ public function width(): int /** * Get image height * - * @return int<1, max> + * @return int<0, max> */ public function height(): int { From 1480be8981afcda3ffc2e9ad5dd280dfcdc27b84 Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Sat, 31 Oct 2026 10:22:03 +0100 Subject: [PATCH 02/10] Add helpers to read and pack binary data Every decoder and handler unpacked integers on its own, with a private copy of the same wrapper, and truncated data produced PHP warnings or silently wrong values instead of an error. Add ByteReader, which reads bytes, integers and strings in sequence and throws on any read beyond the data, and Bytes, which packs and unpacks integers at an offset in either byte order and applies several replacements to a string at once using the offsets of the original data. Co-Authored-By: Claude Sonnet 5.5 --- formwork/src/Images/Binary/ByteReader.php | 129 +++++++++++++ formwork/src/Images/Binary/Bytes.php | 83 +++++++++ formwork/src/Images/Binary/Endianness.php | 12 ++ tests/Unit/Images/Binary/ByteReaderTest.php | 195 ++++++++++++++++++++ tests/Unit/Images/Binary/BytesTest.php | 94 ++++++++++ 5 files changed, 513 insertions(+) create mode 100644 formwork/src/Images/Binary/ByteReader.php create mode 100644 formwork/src/Images/Binary/Bytes.php create mode 100644 formwork/src/Images/Binary/Endianness.php create mode 100644 tests/Unit/Images/Binary/ByteReaderTest.php create mode 100644 tests/Unit/Images/Binary/BytesTest.php diff --git a/formwork/src/Images/Binary/ByteReader.php b/formwork/src/Images/Binary/ByteReader.php new file mode 100644 index 000000000..6a5758362 --- /dev/null +++ b/formwork/src/Images/Binary/ByteReader.php @@ -0,0 +1,129 @@ +seek($position); + } + + /** + * Get the current read position + */ + public function position(): int + { + return $this->position; + } + + /** + * Move the read position to an absolute offset + * + * @throws UnexpectedValueException If the offset is outside the data + */ + public function seek(int $position): void + { + if ($position < 0 || $position > strlen($this->data)) { + throw new UnexpectedValueException('Unexpected end of data'); + } + + $this->position = $position; + } + + /** + * Get the number of unread bytes + */ + public function remaining(): int + { + return strlen($this->data) - $this->position; + } + + /** + * Read a given number of bytes + * + * @throws UnexpectedValueException If there are not enough bytes left + */ + public function bytes(int $length): string + { + if ($length < 0 || $length > $this->remaining()) { + throw new UnexpectedValueException('Unexpected end of data'); + } + + $bytes = substr($this->data, $this->position, $length); + $this->position += $length; + + return $bytes; + } + + /** + * Get a given number of bytes without moving the read position + * + * @throws UnexpectedValueException If there are not enough bytes left + */ + public function peek(int $length): string + { + $position = $this->position; + $bytes = $this->bytes($length); + $this->position = $position; + + return $bytes; + } + + /** + * Read all the remaining bytes + */ + public function rest(): string + { + return $this->bytes($this->remaining()); + } + + /** + * Skip a given number of bytes + */ + public function skip(int $length): void + { + $this->bytes($length); + } + + /** + * Read an unsigned integer of the given size (a size of 0 always reads 0) + * + * @throws UnexpectedValueException If there are not enough bytes left + */ + public function uint(int $size, Endianness $endianness = Endianness::Big): int + { + return Bytes::unpack($this->bytes($size), 0, $size, $endianness); + } + + /** + * Read a null-terminated string, consuming the terminator + * + * @throws UnexpectedValueException If the string is not terminated + */ + public function cString(): string + { + $end = strpos($this->data, "\x00", $this->position); + + if ($end === false) { + throw new UnexpectedValueException('Unexpected end of data'); + } + + $string = $this->bytes($end - $this->position); + $this->position++; + + return $string; + } +} diff --git a/formwork/src/Images/Binary/Bytes.php b/formwork/src/Images/Binary/Bytes.php new file mode 100644 index 000000000..e631371b2 --- /dev/null +++ b/formwork/src/Images/Binary/Bytes.php @@ -0,0 +1,83 @@ + 8 || ($size < 8 && $value >> (8 * $size) !== 0)) { + throw new UnexpectedValueException(sprintf('Value %d does not fit in %d bytes', $value, $size)); + } + + $bytes = substr(pack('J', $value), 8 - $size); + + return $endianness === Endianness::Little ? strrev($bytes) : $bytes; + } + + /** + * Unpack an unsigned integer of the given size at the given offset + * + * @throws UnexpectedValueException If the data is too short or the integer does not fit in a PHP integer + */ + public static function unpack(string $data, int $offset, int $size, Endianness $endianness = Endianness::Big): int + { + if ($offset < 0 || $size < 0 || $offset + $size > strlen($data)) { + throw new UnexpectedValueException('Unexpected end of data'); + } + + $bytes = substr($data, $offset, $size); + + if ($endianness === Endianness::Little) { + $bytes = strrev($bytes); + } + + $value = 0; + + for ($i = 0; $i < $size; $i++) { + if ($value >> 55 !== 0) { + throw new UnexpectedValueException('Integer is too large'); + } + $value = ($value << 8) | ord($bytes[$i]); + } + + return $value; + } + + /** + * Apply a list of replacements to the given data + * + * Every replacement is a triple of offset, length of the replaced range and replacement. + * Offsets always refer to the original data, so that replacements do not shift each other. + * + * @param list $replacements + * + * @throws UnexpectedValueException If a replacement is outside the data or overlaps another one + */ + public static function replace(string $data, array $replacements): string + { + usort($replacements, static fn(array $a, array $b): int => $b[0] <=> $a[0]); + + $limit = strlen($data); + + foreach ($replacements as [$offset, $length, $replacement]) { + if ($offset < 0 || $length < 0 || $offset + $length > $limit) { + throw new UnexpectedValueException('Invalid replacement range'); + } + $data = substr_replace($data, $replacement, $offset, $length); + $limit = $offset; + } + + return $data; + } +} diff --git a/formwork/src/Images/Binary/Endianness.php b/formwork/src/Images/Binary/Endianness.php new file mode 100644 index 000000000..ddc6c9cfd --- /dev/null +++ b/formwork/src/Images/Binary/Endianness.php @@ -0,0 +1,12 @@ +assertSame('ab', $reader->bytes(2)); + $this->assertSame('cd', $reader->bytes(2)); + $this->assertSame(2, $reader->remaining()); + } + + public function testRemainingBytesAreRead(): void + { + $reader = new ByteReader('abcdef'); + $reader->skip(4); + + $this->assertSame('ef', $reader->rest()); + $this->assertSame(0, $reader->remaining()); + $this->assertSame('', $reader->rest()); + } + + #[DataProvider('unsignedIntegerProvider')] + public function testUnsignedIntegersAreReadInBigEndianOrder(string $data, int $size, int $expected): void + { + $this->assertSame($expected, (new ByteReader($data))->uint($size)); + } + + /** + * @return iterable + */ + public static function unsignedIntegerProvider(): iterable + { + yield 'no bytes' => ['', 0, 0]; + yield 'one byte' => ["\xFF", 1, 255]; + yield 'two bytes' => ["\x01\x02", 2, 258]; + yield 'three bytes' => ["\x01\x00\x00", 3, 65536]; + yield 'four bytes' => ["\xFF\xFF\xFF\xFF", 4, 4294967295]; + yield 'eight bytes' => ["\x00\x00\x00\x01\x00\x00\x00\x00", 8, 4294967296]; + } + + #[DataProvider('invalidLengthProvider')] + public function testReadingBeyondTheDataIsNotAllowed(int $length): void + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Unexpected end of data'); + + (new ByteReader('abc'))->bytes($length); + } + + /** + * @return iterable + */ + public static function invalidLengthProvider(): iterable + { + yield 'more than available' => [4]; + yield 'negative' => [-1]; + } + + #[DataProvider('packedIntegerProvider')] + public function testUnsignedIntegersArePackedInBigEndianOrder(int $value, int $size, string $expected): void + { + $this->assertSame($expected, Bytes::pack($value, $size)); + } + + /** + * @return iterable + */ + public static function packedIntegerProvider(): iterable + { + yield 'no bytes' => [0, 0, '']; + yield 'one byte' => [255, 1, "\xFF"]; + yield 'two bytes' => [258, 2, "\x01\x02"]; + yield 'four bytes' => [4294967295, 4, "\xFF\xFF\xFF\xFF"]; + yield 'eight bytes' => [4294967296, 8, "\x00\x00\x00\x01\x00\x00\x00\x00"]; + } + + public function testPackingAndReadingAreSymmetrical(): void + { + $this->assertSame(123456, (new ByteReader(Bytes::pack(123456, 4)))->uint(4)); + } + + #[DataProvider('unpackableIntegerProvider')] + public function testIntegersNotFittingTheGivenSizeAreNotPacked(int $value, int $size): void + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage(sprintf('Value %d does not fit in %d bytes', $value, $size)); + + Bytes::pack($value, $size); + } + + /** + * @return iterable + */ + public static function unpackableIntegerProvider(): iterable + { + yield 'too large for one byte' => [256, 1]; + yield 'too large for two bytes' => [65536, 2]; + yield 'negative' => [-1, 4]; + } + + public function testUnsignedIntegersCanBeReadInLittleEndianOrder(): void + { + $reader = new ByteReader("\x01\x02\x03\x04\x05\x06"); + + $this->assertSame(0x0201, $reader->uint(2, Endianness::Little)); + $this->assertSame(0x06050403, $reader->uint(4, Endianness::Little)); + } + + public function testIntegersWiderThanThePlatformOnesAreNotRead(): void + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Integer is too large'); + + (new ByteReader("\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF"))->uint(8); + } + + public function testPositionCanBeSetAndRead(): void + { + $reader = new ByteReader('abcdef', 2); + + $this->assertSame(2, $reader->position()); + $this->assertSame('cd', $reader->bytes(2)); + $this->assertSame(4, $reader->position()); + + $reader->seek(1); + $this->assertSame('bc', $reader->bytes(2)); + + $reader->seek(6); + $this->assertSame(0, $reader->remaining()); + } + + #[DataProvider('invalidPositionProvider')] + public function testPositionOutsideTheDataIsNotAllowed(int $position): void + { + $this->expectException(UnexpectedValueException::class); + + (new ByteReader('abc'))->seek($position); + } + + /** + * @return iterable + */ + public static function invalidPositionProvider(): iterable + { + yield 'past the end' => [4]; + yield 'negative' => [-1]; + } + + public function testBytesCanBePeekedWithoutMovingThePosition(): void + { + $reader = new ByteReader('abcdef'); + $reader->skip(1); + + $this->assertSame('bcd', $reader->peek(3)); + $this->assertSame(1, $reader->position()); + $this->assertSame('bcd', $reader->bytes(3)); + } + + public function testPeekingBeyondTheDataIsNotAllowed(): void + { + $this->expectException(UnexpectedValueException::class); + + (new ByteReader('abc'))->peek(4); + } + + public function testNullTerminatedStringsAreRead(): void + { + $reader = new ByteReader("name\x00\x00rest"); + + $this->assertSame('name', $reader->cString()); + $this->assertSame('', $reader->cString()); + $this->assertSame('rest', $reader->rest()); + } + + public function testUnterminatedStringsAreNotRead(): void + { + $this->expectException(UnexpectedValueException::class); + + (new ByteReader('name'))->cString(); + } +} diff --git a/tests/Unit/Images/Binary/BytesTest.php b/tests/Unit/Images/Binary/BytesTest.php new file mode 100644 index 000000000..7aac4a139 --- /dev/null +++ b/tests/Unit/Images/Binary/BytesTest.php @@ -0,0 +1,94 @@ +assertSame("\x02\x01", Bytes::pack(0x0102, 2, Endianness::Little)); + $this->assertSame("\x04\x03\x02\x01", Bytes::pack(0x01020304, 4, Endianness::Little)); + $this->assertSame("\x01\x02", Bytes::pack(0x0102, 2)); + } + + public function testUnsignedIntegersAreUnpackedAtAnOffset(): void + { + $this->assertSame(0x0203, Bytes::unpack("\x01\x02\x03\x04", 1, 2)); + $this->assertSame(0x0302, Bytes::unpack("\x01\x02\x03\x04", 1, 2, Endianness::Little)); + $this->assertSame(0, Bytes::unpack('abc', 3, 0)); + } + + #[DataProvider('invalidUnpackProvider')] + public function testUnpackingBeyondTheDataIsNotAllowed(int $offset, int $size): void + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Unexpected end of data'); + + Bytes::unpack('abc', $offset, $size); + } + + /** + * @return iterable + */ + public static function invalidUnpackProvider(): iterable + { + yield 'past the end' => [2, 2]; + yield 'negative offset' => [-1, 1]; + yield 'negative size' => [0, -1]; + yield 'empty data' => [4, 0]; + } + + public function testPackingAndUnpackingAreSymmetricalInBothOrders(): void + { + foreach ([Endianness::Big, Endianness::Little] as $endianness) { + $this->assertSame(0xCAFEBABE, Bytes::unpack(Bytes::pack(0xCAFEBABE, 4, $endianness), 0, 4, $endianness)); + } + } + + public function testReplacementsReferToTheOriginalOffsets(): void + { + $data = '0123456789'; + + $this->assertSame('01ABX789', Bytes::replace($data, [[6, 1, 'X'], [2, 3, 'AB'], [5, 1, '']])); + } + + public function testReplacementsCanInsertData(): void + { + $this->assertSame('ab-cd', Bytes::replace('abcd', [[2, 0, '-']])); + } + + public function testNoReplacementsLeaveTheDataUntouched(): void + { + $this->assertSame('abcd', Bytes::replace('abcd', [])); + } + + /** + * @param list $replacements + */ + #[DataProvider('invalidReplacementProvider')] + public function testInvalidReplacementsAreNotApplied(array $replacements): void + { + $this->expectException(UnexpectedValueException::class); + + Bytes::replace('abcd', $replacements); + } + + /** + * @return iterable}> + */ + public static function invalidReplacementProvider(): iterable + { + yield 'beyond the end' => [[[3, 2, '']]]; + yield 'negative offset' => [[[-1, 1, '']]]; + yield 'negative length' => [[[1, -1, '']]]; + yield 'overlapping' => [[[0, 3, ''], [2, 1, 'x']]]; + } +} From a049b6350acd4c9f5829f9cbd9723f8416f1404a Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Sat, 31 Oct 2026 10:22:03 +0100 Subject: [PATCH 03/10] Rewrite the AVIF handler over an ISOBMFF model The handler patched the metadata of AVIF files in place, recomputing the offsets by hand at every step. The byte surgery left dangling offsets behind: removing the Exif item did not move the item locations, removing a color profile did not renumber the property associations, an added profile was never associated with the primary item, and the length of a replaced Exif item was not updated. Parse the file into the boxes of the Isobmff namespace instead, change them as objects, and rebuild the file in a single step: the item data is laid out in one media data box and every offset is recomputed in one place, so no operation can leave the file inconsistent. The new model also covers the layouts the previous code mishandled: 64-bit and unsized boxes, item location boxes of any version, base offsets, extent indexes, items stored in the item data box, and media data placed before the metadata. Exif items now get the content description reference decoders need to find them, and image sequences are rejected instead of being corrupted. Image sequences report their number of frames and how many times they are played, read from the sample table and the edit list of the first track, and monochrome images are reported as grayscale. The decoder reads boxes through ByteReader and rejects boxes that are truncated or smaller than their header. Add the tests for the decoder, the handler and the whole Isobmff namespace, over an encoded image and over files built with a controlled box layout. Co-Authored-By: Claude Sonnet 5.5 --- formwork/src/Images/Decoder/AvifDecoder.php | 44 +- formwork/src/Images/Handler/AvifHandler.php | 1152 +++-------------- formwork/src/Images/Isobmff/Box.php | 119 ++ formwork/src/Images/Isobmff/Container.php | 320 +++++ formwork/src/Images/Isobmff/DataArea.php | 31 + formwork/src/Images/Isobmff/Encodable.php | 14 + formwork/src/Images/Isobmff/Extent.php | 20 + formwork/src/Images/Isobmff/ImageSequence.php | 157 +++ formwork/src/Images/Isobmff/ItemInfoBox.php | 83 ++ formwork/src/Images/Isobmff/ItemInfoEntry.php | 64 + formwork/src/Images/Isobmff/ItemLocation.php | 69 + .../src/Images/Isobmff/ItemLocationBox.php | 202 +++ .../src/Images/Isobmff/ItemPropertiesBox.php | 129 ++ formwork/src/Images/Isobmff/ItemReference.php | 65 + .../src/Images/Isobmff/ItemReferenceBox.php | 82 ++ formwork/src/Images/Isobmff/MetaBox.php | 172 +++ .../Images/Isobmff/PropertyAssociation.php | 41 + .../Images/Isobmff/PropertyAssociationBox.php | 153 +++ tests/Unit/Images/Decoder/AvifDecoderTest.php | 118 ++ tests/Unit/Images/Fixtures/AvifFile.php | 618 +++++++++ tests/Unit/Images/Fixtures/alternate.tiff | Bin 0 -> 98 bytes tests/Unit/Images/Fixtures/exif.tiff | Bin 0 -> 72 bytes tests/Unit/Images/Fixtures/image.avif | Bin 0 -> 1425 bytes tests/Unit/Images/Fixtures/lab.icc | Bin 0 -> 572 bytes tests/Unit/Images/Fixtures/srgb.icc | Bin 0 -> 588 bytes tests/Unit/Images/Handler/AvifHandlerTest.php | 537 ++++++++ tests/Unit/Images/Isobmff/BoxTest.php | 90 ++ tests/Unit/Images/Isobmff/ContainerTest.php | 277 ++++ .../Unit/Images/Isobmff/ImageSequenceTest.php | 103 ++ tests/Unit/Images/Isobmff/ItemInfoBoxTest.php | 157 +++ .../Images/Isobmff/ItemLocationBoxTest.php | 221 ++++ .../Images/Isobmff/ItemPropertiesBoxTest.php | 205 +++ .../Images/Isobmff/ItemReferenceBoxTest.php | 134 ++ tests/Unit/Images/Isobmff/MetaBoxTest.php | 144 +++ 34 files changed, 4504 insertions(+), 1017 deletions(-) create mode 100644 formwork/src/Images/Isobmff/Box.php create mode 100644 formwork/src/Images/Isobmff/Container.php create mode 100644 formwork/src/Images/Isobmff/DataArea.php create mode 100644 formwork/src/Images/Isobmff/Encodable.php create mode 100644 formwork/src/Images/Isobmff/Extent.php create mode 100644 formwork/src/Images/Isobmff/ImageSequence.php create mode 100644 formwork/src/Images/Isobmff/ItemInfoBox.php create mode 100644 formwork/src/Images/Isobmff/ItemInfoEntry.php create mode 100644 formwork/src/Images/Isobmff/ItemLocation.php create mode 100644 formwork/src/Images/Isobmff/ItemLocationBox.php create mode 100644 formwork/src/Images/Isobmff/ItemPropertiesBox.php create mode 100644 formwork/src/Images/Isobmff/ItemReference.php create mode 100644 formwork/src/Images/Isobmff/ItemReferenceBox.php create mode 100644 formwork/src/Images/Isobmff/MetaBox.php create mode 100644 formwork/src/Images/Isobmff/PropertyAssociation.php create mode 100644 formwork/src/Images/Isobmff/PropertyAssociationBox.php create mode 100644 tests/Unit/Images/Decoder/AvifDecoderTest.php create mode 100644 tests/Unit/Images/Fixtures/AvifFile.php create mode 100644 tests/Unit/Images/Fixtures/alternate.tiff create mode 100644 tests/Unit/Images/Fixtures/exif.tiff create mode 100644 tests/Unit/Images/Fixtures/image.avif create mode 100644 tests/Unit/Images/Fixtures/lab.icc create mode 100644 tests/Unit/Images/Fixtures/srgb.icc create mode 100644 tests/Unit/Images/Handler/AvifHandlerTest.php create mode 100644 tests/Unit/Images/Isobmff/BoxTest.php create mode 100644 tests/Unit/Images/Isobmff/ContainerTest.php create mode 100644 tests/Unit/Images/Isobmff/ImageSequenceTest.php create mode 100644 tests/Unit/Images/Isobmff/ItemInfoBoxTest.php create mode 100644 tests/Unit/Images/Isobmff/ItemLocationBoxTest.php create mode 100644 tests/Unit/Images/Isobmff/ItemPropertiesBoxTest.php create mode 100644 tests/Unit/Images/Isobmff/ItemReferenceBoxTest.php create mode 100644 tests/Unit/Images/Isobmff/MetaBoxTest.php diff --git a/formwork/src/Images/Decoder/AvifDecoder.php b/formwork/src/Images/Decoder/AvifDecoder.php index 80cdfe424..2a15e1fe3 100644 --- a/formwork/src/Images/Decoder/AvifDecoder.php +++ b/formwork/src/Images/Decoder/AvifDecoder.php @@ -2,6 +2,7 @@ namespace Formwork\Images\Decoder; +use Formwork\Images\Binary\ByteReader; use Generator; use InvalidArgumentException; use UnexpectedValueException; @@ -27,53 +28,44 @@ public function decode(string &$data): Generator throw new InvalidArgumentException('Invalid AVIF data'); } - $position = 0; - $this->unpack('N', $data, $position); - $boxType = substr($data, 4, 4); - - if ($boxType !== self::FTYP_BOX) { + if (substr($data, 4, 4) !== self::FTYP_BOX) { throw new InvalidArgumentException('Invalid AVIF data: missing ftyp box'); } - $majorBrand = substr($data, 8, 4); - if (!in_array($majorBrand, self::VALID_BRANDS, true)) { + if (!in_array(substr($data, 8, 4), self::VALID_BRANDS, true)) { throw new InvalidArgumentException('Invalid AVIF data: unsupported brand'); } - $position = 0; + $reader = new ByteReader($data); - while ($position < strlen($data)) { - $offset = $position; + while ($reader->remaining() > 0) { + $offset = $reader->position(); - $size = $this->unpack('N', $data, $position)[1]; + $size = $reader->uint(4); + $type = $reader->bytes(4); $headerSize = 8; if ($size === 1) { - $size = $this->unpack('J', $data, $position + 8)[1]; + $size = $reader->uint(8); $headerSize = 16; } elseif ($size === 0) { - $size = strlen($data) - $position; + // The box extends to the end of the file + $size = strlen($data) - $offset; + } + + if ($size < $headerSize) { + throw new UnexpectedValueException('Invalid box size'); } - $type = substr($data, $position + 4, 4); - $contentSize = $size - $headerSize; - $position += $size; + $value = $reader->bytes($size - $headerSize); yield [ 'offset' => $offset, 'size' => $size, 'type' => $type, - 'value' => substr($data, $offset + $headerSize, $contentSize), - 'position' => &$position, + 'value' => $value, + 'position' => $reader->position(), ]; } } - - /** - * @return array - */ - private function unpack(string $format, string $string, int $offset = 0): array - { - return unpack($format, $string, $offset) ?: throw new UnexpectedValueException('Cannot unpack string'); - } } diff --git a/formwork/src/Images/Handler/AvifHandler.php b/formwork/src/Images/Handler/AvifHandler.php index 749bd9bc2..96f4f95b8 100644 --- a/formwork/src/Images/Handler/AvifHandler.php +++ b/formwork/src/Images/Handler/AvifHandler.php @@ -2,12 +2,18 @@ namespace Formwork\Images\Handler; +use Formwork\Images\Binary\ByteReader; use Formwork\Images\ColorProfile\ColorProfile; use Formwork\Images\ColorProfile\ColorSpace; use Formwork\Images\Decoder\AvifDecoder; use Formwork\Images\Exif\ExifData; -use Formwork\Images\Handler\Exceptions\UnsupportedFeatureException; use Formwork\Images\ImageInfo; +use Formwork\Images\Isobmff\Box; +use Formwork\Images\Isobmff\Container; +use Formwork\Images\Isobmff\ItemInfoEntry; +use Formwork\Images\Isobmff\ItemPropertiesBox; +use Formwork\Images\Isobmff\ItemReferenceBox; +use Formwork\Images\Isobmff\MetaBox; use GdImage; use RuntimeException; use UnexpectedValueException; @@ -17,6 +23,26 @@ */ final class AvifHandler extends AbstractHandler { + /** + * Major brand of AVIF image sequences + */ + private const string SEQUENCE_BRAND = 'avis'; + + /** + * Color information types holding an ICC profile (unrestricted and restricted) + */ + private const array ICC_COLOR_TYPES = ['prof', 'rICC']; + + /** + * Type used when writing ICC profiles, since it is not restricted to simple profiles + */ + private const string ICC_WRITE_TYPE = 'prof'; + + /** + * Size of the field storing the offset of the TIFF header in Exif items + */ + private const int EXIF_OFFSET_SIZE = 4; + public function getInfo(): ImageInfo { $info = [ @@ -32,14 +58,22 @@ public function getInfo(): ImageInfo 'animationRepeatCount' => null, ]; - foreach ($this->decoder->decode($this->data) as $box) { - if ($box['type'] === 'ftyp' && str_starts_with($box['value'], 'avis')) { - $info['isAnimation'] = true; - } + $container = $this->container(); + $meta = $container->meta(); - if ($box['type'] === 'meta') { - $this->parseMetaBox($box['value'], $info); - } + if ($container->majorBrand() === self::SEQUENCE_BRAND) { + $info['isAnimation'] = true; + } + + if ($meta !== null) { + $info = [...$info, ...$this->describeProperties($meta)]; + } + + $sequence = $container->imageSequence(); + + if ($sequence !== null) { + $info['animationFrames'] = $sequence->frameCount; + $info['animationRepeatCount'] = $sequence->playCount; } return new ImageInfo($info); @@ -57,106 +91,34 @@ public static function supportsColorProfile(): bool public function hasColorProfile(): bool { - foreach ($this->decoder->decode($this->data) as $box) { - if ($box['type'] === 'meta' && $this->findColorProfileInMetaBox($box['value']) !== null) { - return true; - } - } - - return false; + return $this->findColorProfileData() !== null; } public function getColorProfile(): ?ColorProfile { - foreach ($this->decoder->decode($this->data) as $box) { - if ($box['type'] === 'meta' && ($profileData = $this->findColorProfileInMetaBox($box['value'])) !== null) { - return new ColorProfile($profileData); - } - } + $data = $this->findColorProfileData(); - return null; + return $data === null ? null : new ColorProfile($data); } public function setColorProfile(ColorProfile $colorProfile): void { - [$metaOffset, $metaSize] = $this->findBox($this->data, 0, strlen($this->data), 'meta'); - if ($metaOffset === 0) { - return; - } - [$ilocOffset] = $this->findBox($this->data, $metaOffset + 12, $metaOffset + $metaSize, 'iloc'); - [$iprpOffset, $iprpSize] = $this->findBox($this->data, $metaOffset + 12, $metaOffset + $metaSize, 'iprp'); - if ($iprpOffset === 0) { - return; - } - [$ipcoOffset, $ipcoSize] = $this->findBox($this->data, $iprpOffset + 8, $iprpOffset + $iprpSize, 'ipco'); - if ($ipcoOffset === 0) { - return; - } - $offset = $ipcoOffset + 8; - $colrFound = false; - - while ($offset < $ipcoOffset + $ipcoSize) { - $boxSize = $this->unpack('N', $this->data, $offset)[1]; - if (substr($this->data, $offset + 4, 4) === 'colr' && (substr($this->data, $offset + 8, 4) === 'prof' || substr($this->data, $offset + 8, 4) === 'rICC')) { - $newColrSize = 8 + 4 + strlen($colorProfile->getData()); - $sizeDiff = $newColrSize - $boxSize; - $this->data = substr_replace($this->data, pack('N', $ipcoSize + $sizeDiff), $ipcoOffset, 4); - $this->data = substr_replace($this->data, pack('N', $iprpSize + $sizeDiff), $iprpOffset, 4); - $this->data = substr_replace($this->data, pack('N', $metaSize + $sizeDiff), $metaOffset, 4); - if ($ilocOffset > 0 && $sizeDiff !== 0) { - $this->updateIlocBoxOffsets($ilocOffset, $sizeDiff); - } - $this->data = substr($this->data, 0, $offset) . pack('N', $newColrSize) . 'colr' . 'rICC' . $colorProfile->getData() . substr($this->data, $offset + $boxSize); - $colrFound = true; - break; - } - $offset += $boxSize; - } + $container = $this->container(); + $meta = $container->requireMeta(); + $properties = $meta->require(ItemPropertiesBox::class); - if (!$colrFound) { - $newColrSize = 8 + 4 + strlen($colorProfile->getData()); - $this->data = substr_replace($this->data, pack('N', $ipcoSize + $newColrSize), $ipcoOffset, 4); - $this->data = substr_replace($this->data, pack('N', $iprpSize + $newColrSize), $iprpOffset, 4); - $this->data = substr_replace($this->data, pack('N', $metaSize + $newColrSize), $metaOffset, 4); + $properties->removeWhere($this->isColorProfile(...)); + $properties->add(new Box('colr', self::ICC_WRITE_TYPE . $colorProfile->getData()), $meta->requirePrimaryItemId()); - if ($ilocOffset > 0) { - $this->updateIlocBoxOffsets($ilocOffset, $newColrSize); - } - $insertPosition = $ipcoOffset + $ipcoSize; - $this->data = substr($this->data, 0, $insertPosition) . pack('N', $newColrSize) . 'colr' . 'rICC' . $colorProfile->getData() . substr($this->data, $insertPosition); - } + $this->data = $container->toString(); } public function removeColorProfile(): void { - [$metaOffset, $metaSize] = $this->findBox($this->data, 0, strlen($this->data), 'meta'); - if ($metaOffset === 0) { - return; - } - [$ilocOffset] = $this->findBox($this->data, $metaOffset + 12, $metaOffset + $metaSize, 'iloc'); - [$iprpOffset, $iprpSize] = $this->findBox($this->data, $metaOffset + 12, $metaOffset + $metaSize, 'iprp'); - if ($iprpOffset === 0) { - return; - } - [$ipcoOffset, $ipcoSize] = $this->findBox($this->data, $iprpOffset + 8, $iprpOffset + $iprpSize, 'ipco'); - if ($ipcoOffset === 0) { - return; - } - $offset = $ipcoOffset + 8; - while ($offset < $ipcoOffset + $ipcoSize) { - $boxSize = (int) $this->unpack('N', $this->data, $offset)[1]; - if (substr($this->data, $offset + 4, 4) === 'colr' && (substr($this->data, $offset + 8, 4) === 'prof' || substr($this->data, $offset + 8, 4) === 'rICC')) { - $sizeDiff = -$boxSize; - $this->data = substr_replace($this->data, pack('N', $ipcoSize + $sizeDiff), $ipcoOffset, 4); - $this->data = substr_replace($this->data, pack('N', $iprpSize + $sizeDiff), $iprpOffset, 4); - $this->data = substr_replace($this->data, pack('N', $metaSize + $sizeDiff), $metaOffset, 4); - if ($ilocOffset > 0) { - $this->updateIlocBoxOffsets($ilocOffset, $sizeDiff); - } - $this->data = substr($this->data, 0, $offset) . substr($this->data, $offset + $boxSize); - return; - } - $offset += $boxSize; + $container = $this->container(); + + if ($container->meta()?->find(ItemPropertiesBox::class)?->removeWhere($this->isColorProfile(...))) { + $this->data = $container->toString(); } } @@ -167,104 +129,38 @@ public static function supportsExifData(): bool public function hasExifData(): bool { - foreach ($this->decoder->decode($this->data) as $box) { - if ($box['type'] === 'meta') { - return $this->findExifItemInMetaBox($box['value']) !== null; - } - } - - return false; + return $this->container()->itemIdsByType(ItemInfoEntry::EXIF_TYPE) !== []; } public function getExifData(): ?ExifData { - foreach ($this->decoder->decode($this->data) as $box) { - if ($box['type'] === 'meta' && ($exifItemId = $this->findExifItemInMetaBox($box['value'])) !== null && ($exifData = $this->extractExifDataFromItem($box['value'], $exifItemId)) !== null) { - return new ExifData($exifData); - } - } + $container = $this->container(); + $id = $container->itemIdsByType(ItemInfoEntry::EXIF_TYPE)[0] ?? null; + $item = $id === null ? null : $container->itemData($id); + $tiff = $item === null ? null : $this->extractTiffData($item); - return null; + return $tiff === null ? null : new ExifData($tiff); } public function setExifData(ExifData $exifData): void { - [$metaOffset, $metaSize] = $this->findBox($this->data, 0, strlen($this->data), 'meta'); - if ($metaOffset === 0) { - throw new UnsupportedFeatureException('AVIF file does not contain a meta box'); - } - $exifPayload = pack('N', 0) . $exifData->getData(); - $fullMetaContent = substr($this->data, $metaOffset + 8, $metaSize - 8); - $existingExifItemId = $this->findExifItemInMetaBox($fullMetaContent); - - if ($existingExifItemId !== null) { - $this->updateExifDataInMdatBox($existingExifItemId, $fullMetaContent, $exifPayload); - } else { - $this->insertExifData($metaOffset, $metaSize, $exifPayload); - } + $container = $this->container(); + $primaryId = $container->requireMeta()->requirePrimaryItemId(); + + $this->removeExifItems($container); + + $id = $container->addItem(ItemInfoEntry::EXIF_TYPE, str_repeat("\x00", self::EXIF_OFFSET_SIZE) . $exifData->getData()); + $container->addReference(ItemReferenceBox::CONTENT_DESCRIBES, $id, $primaryId); + + $this->data = $container->toString(); } public function removeExifData(): void { - [$metaOffset, $metaSize] = $this->findBox($this->data, 0, strlen($this->data), 'meta'); - if ($metaOffset === 0) { - return; - } - $metaContentStart = $metaOffset + 8; - $metaContent = substr($this->data, $metaContentStart, $metaSize - 8); - $exifItemId = $this->findExifItemInMetaBox($metaContent); + $container = $this->container(); - if ($exifItemId === null) { - return; - } - $offset = 4; - while ($offset < strlen($metaContent)) { - $size = $this->unpack('N', $metaContent, $offset)[1]; - if ($size === 0) { - break; - } - $type = substr($metaContent, $offset + 4, 4); - - if ($type === 'iinf') { - $iinfOffset = $metaContentStart + $offset; - $contentOffset = $iinfOffset + 8; - $version = ord($this->data[$contentOffset]); - $entryCountOffset = $contentOffset + 4; - $entryCount = $version === 0 - ? $this->unpack('n', $this->data, $entryCountOffset)[1] - : $this->unpack('N', $this->data, $entryCountOffset)[1]; - - $infeOffset = $entryCountOffset + ($version === 0 ? 2 : 4); - - for ($i = 0; $i < $entryCount; $i++) { - $infeSize = $this->unpack('N', $this->data, $infeOffset)[1]; - - if (substr($this->data, $infeOffset + 4, 4) === 'infe') { - $infeContentOffset = $infeOffset + 8; - $infeVersion = ord($this->data[$infeContentOffset]); - $itemIdOffset = $infeContentOffset + 4; - $itemId = $infeVersion < 3 - ? $this->unpack('n', $this->data, $itemIdOffset)[1] - : $this->unpack('N', $this->data, $itemIdOffset)[1]; - - if ($itemId === $exifItemId) { - $this->data = substr($this->data, 0, $infeOffset) . substr($this->data, $infeOffset + $infeSize); - $this->data = substr_replace($this->data, $version === 0 ? pack('n', $entryCount - 1) : pack('N', $entryCount - 1), $entryCountOffset, $version === 0 ? 2 : 4); - $this->data = substr_replace($this->data, pack('N', $size - $infeSize), $iinfOffset, 4); - foreach ($this->decoder->decode($this->data) as $box) { - if ($box['type'] === 'meta') { - $this->data = substr_replace($this->data, pack('N', $box['size'] - $infeSize), $box['offset'], 4); - break; - } - } - return; - } - } - $infeOffset += $infeSize; - } - return; - } - $offset += $size; + if ($this->removeExifItems($container) > 0) { + $this->data = $container->toString(); } } @@ -282,891 +178,165 @@ protected function setDataFromGdImage(GdImage $gdImage): void } ob_start(); + $success = imageavif($gdImage, null, $this->options['avifQuality']); + $data = (string) ob_get_clean(); - if (imageavif($gdImage, null, $this->options['avifQuality']) === false) { + if ($success === false) { throw new RuntimeException('Cannot set data from GdImage'); } - $this->data = ob_get_clean() ?: throw new UnexpectedValueException('Unexpected empty image data'); - } - /** - * @param array $info - */ - private function parseMetaBox(string $metaData, array &$info): void - { - $offset = 4; - - while ($offset < strlen($metaData)) { - $size = $this->unpack('N', $metaData, $offset)[1]; - if ($size === 0) { - break; - } - $type = substr($metaData, $offset + 4, 4); - $offset += 8; - - if ($type === 'iprp') { - $this->parseIprpBox(substr($metaData, $offset, $size - 8), $info); - } - $offset += $size - 8; - } + $this->data = $data !== '' ? $data : throw new UnexpectedValueException('Unexpected empty image data'); } /** - * @param array $info + * Get the info stored in the properties of the image + * + * @return array */ - private function parseIprpBox(string $iprpData, array &$info): void + private function describeProperties(MetaBox $meta): array { - $offset = 0; - - while ($offset < strlen($iprpData)) { - $size = $this->unpack('N', $iprpData, $offset)[1]; - if ($size === 0) { - break; - } - $type = substr($iprpData, $offset + 4, 4); - $offset += 8; + $properties = $meta->find(ItemPropertiesBox::class); - if ($type === 'ipco') { - $this->parseIpcoBox(substr($iprpData, $offset, $size - 8), $info); - } - $offset += $size - 8; + if ($properties === null) { + return []; } - } - /** - * @param array $info - */ - private function parseIpcoBox(string $ipcoData, array &$info): void - { - $offset = 0; + $primaryId = $meta->primaryItemId(); + $primary = ($primaryId === null ? [] : $properties->forItem($primaryId)) ?: $properties->all(); - while ($offset < strlen($ipcoData)) { - $size = $this->unpack('N', $ipcoData, $offset)[1]; - if ($size === 0) { - break; - } - $type = substr($ipcoData, $offset + 4, 4); - $contentOffset = $offset + 8; - - if ($type === 'ispe') { - $info['width'] = $this->unpack('N', $ipcoData, $contentOffset + 4)[1]; - $info['height'] = $this->unpack('N', $ipcoData, $contentOffset + 8)[1]; - } elseif ($type === 'pixi') { - if (ord($ipcoData[$contentOffset + 4]) > 0) { - $info['colorDepth'] = ord($ipcoData[$contentOffset + 5]); - } - } elseif ($type === 'auxC') { - if (str_contains($auxType = $this->unpack('Z*', substr($ipcoData, $contentOffset + 4))[1], 'alpha') || str_contains($auxType, 'urn:mpeg:mpegB:cicp:systems:auxiliary:alpha')) { - $info['hasAlphaChannel'] = true; - } - } - $offset += $size; - } + return [ + ...$this->describeDimensions($this->firstOfType($primary, 'ispe')), + ...$this->describeDepth($this->firstOfType($primary, 'pixi')), + ...$this->describeColorSpace($this->firstOfType($primary, 'av1C')), + 'hasAlphaChannel' => $this->hasAlphaChannel($properties->all()), + ]; } /** - * @return array + * @param list $properties */ - private function findBox(string $data, int $offset, int $end, string $type): array + private function firstOfType(array $properties, string $type): ?Box { - while ($offset < $end) { - $size = $this->unpack('N', $data, $offset)[1]; - if ($size === 0) { - break; + foreach ($properties as $property) { + if ($property->type === $type) { + return $property; } - $boxType = substr($data, $offset + 4, 4); - if ($boxType === $type) { - return [$offset, $size]; - } - $offset += $size; } - return [0, 0]; + return null; } /** - * @return array + * @return array{width?: int, height?: int} */ - private function findMetaBox(): array - { - foreach ($this->decoder->decode($this->data) as $box) { - if ($box['type'] === 'meta') { - return [$box['offset'], $box['size']]; - } - } - return [0, 0]; - } - - private function findColorProfileInMetaBox(string $metaData): ?string - { - $offset = 4; - - while ($offset < strlen($metaData)) { - $size = $this->unpack('N', $metaData, $offset)[1]; - if ($size === 0) { - break; - } - $type = substr($metaData, $offset + 4, 4); - $offset += 8; - - if ($type === 'iprp' && ($result = $this->findColorProfileInIprpBox(substr($metaData, $offset, $size - 8))) !== null) { - return $result; - } - $offset += $size - 8; - } - - return null; - } - - private function findColorProfileInIprpBox(string $iprpData): ?string - { - $offset = 0; - - while ($offset < strlen($iprpData)) { - $size = $this->unpack('N', $iprpData, $offset)[1]; - if ($size === 0) { - break; - } - $type = substr($iprpData, $offset + 4, 4); - $offset += 8; - - if ($type === 'ipco' && ($result = $this->findColorProfileInIpcoBox(substr($iprpData, $offset, $size - 8))) !== null) { - return $result; - } - $offset += $size - 8; - } - - return null; - } - - private function findColorProfileInIpcoBox(string $ipcoData): ?string + private function describeDimensions(?Box $ispe): array { - $offset = 0; - - while ($offset < strlen($ipcoData)) { - $size = $this->unpack('N', $ipcoData, $offset)[1]; - if ($size === 0) { - break; - } - $type = substr($ipcoData, $offset + 4, 4); - $contentOffset = $offset + 8; - - if ($type === 'colr') { - $colorType = substr($ipcoData, $contentOffset, 4); - if ($colorType === 'prof' || $colorType === 'rICC') { - return substr($ipcoData, $contentOffset + 4, $size - 12); - } - } - $offset += $size; + if ($ispe === null) { + return []; } - return null; - } - - private function findExifItemInMetaBox(string $metaData): ?int - { - $offset = 4; - - while ($offset < strlen($metaData)) { - $size = $this->unpack('N', $metaData, $offset)[1]; - if ($size === 0) { - break; - } - $type = substr($metaData, $offset + 4, 4); - $contentOffset = $offset + 8; - - if ($type === 'iinf') { - $version = ord($metaData[$contentOffset]); - $entryCountOffset = $contentOffset + 4; - $entryCount = $version === 0 - ? $this->unpack('n', $metaData, $entryCountOffset)[1] - : $this->unpack('N', $metaData, $entryCountOffset)[1]; - - $infeOffset = $entryCountOffset + ($version === 0 ? 2 : 4); - - for ($i = 0; $i < $entryCount; $i++) { - $infeSize = $this->unpack('N', $metaData, $infeOffset)[1]; - $infeType = substr($metaData, $infeOffset + 4, 4); - - if ($infeType === 'infe') { - $infeContentOffset = $infeOffset + 8; - $infeVersion = ord($metaData[$infeContentOffset]); - $itemIdOffset = $infeContentOffset + 4; - $itemId = $infeVersion < 3 - ? $this->unpack('n', $metaData, $itemIdOffset)[1] - : $this->unpack('N', $metaData, $itemIdOffset)[1]; - - $itemTypeOffset = $itemIdOffset + ($infeVersion < 3 ? 4 : 6); - $itemType = substr($metaData, $itemTypeOffset, 4); - - if ($itemType === 'Exif') { - return $itemId; - } - } - $infeOffset += $infeSize; - } - } - $offset += $size; - } + $reader = new ByteReader($ispe->payload); + $reader->skip(4); - return null; - } - - private function extractExifDataFromItem(string $metaData, int $itemId): ?string - { - $offset = 4; - - while ($offset < strlen($metaData)) { - $size = $this->unpack('N', $metaData, $offset)[1]; - if ($size === 0) { - break; - } - $type = substr($metaData, $offset + 4, 4); - $contentOffset = $offset + 8; - - if ($type === 'iloc') { - $location = $this->findItemLocationInIlocBox(substr($metaData, $contentOffset, $size - 8), $itemId); - if ($location !== null) { - $exifDataWithHeader = substr($this->data, $location['offset'], $location['length']); - $tiffHeaderOffset = $this->unpack('N', $exifDataWithHeader, 0)[1]; - return substr($exifDataWithHeader, 4 + $tiffHeaderOffset); - } - return null; - } - $offset += $size; - } - - return null; + return ['width' => $reader->uint(4), 'height' => $reader->uint(4)]; } /** - * @return array{offset: int, length: int}|null + * @return array{colorDepth?: int} */ - private function findItemLocationInIlocBox(string $ilocData, int $targetItemId): ?array - { - $offset = 0; - $version = ord($ilocData[$offset]); - $offset += 4; - $offsetSize = (ord($ilocData[$offset]) >> 4) & 0x0F; - $lengthSize = ord($ilocData[$offset]) & 0x0F; - $baseOffsetSize = (ord($ilocData[$offset + 1]) >> 4) & 0x0F; - $offset += 2; - $itemCount = $version < 2 - ? $this->unpack('n', $ilocData, $offset)[1] - : $this->unpack('N', $ilocData, $offset)[1]; - $offset += $version < 2 ? 2 : 4; - - for ($i = 0; $i < $itemCount; $i++) { - $itemId = $version < 2 - ? $this->unpack('n', $ilocData, $offset)[1] - : $this->unpack('N', $ilocData, $offset)[1]; - $offset += $version < 2 ? 2 : 4; - - if ($version === 1 || $version === 2) { - $offset += 2; - } - $offset += 2; - $baseOffset = 0; - if ($baseOffsetSize > 0) { - $baseOffset = $this->unpackInt($ilocData, $offset, $baseOffsetSize); - $offset += $baseOffsetSize; - } - $extentCount = $this->unpack('n', $ilocData, $offset)[1]; - $offset += 2; - - if ($itemId === $targetItemId && $extentCount > 0) { - if ($version === 1 || $version === 2) { - $offset += $offsetSize; - } - - return [ - 'offset' => $baseOffset + $this->unpackInt($ilocData, $offset, $offsetSize), - 'length' => $this->unpackInt($ilocData, $offset + $offsetSize, $lengthSize), - ]; - } - - for ($j = 0; $j < $extentCount; $j++) { - if ($version === 1 || $version === 2) { - $offset += $offsetSize; - } - $offset += $offsetSize + $lengthSize; - } - } - - return null; - } - - private function updateIlocBoxOffsets(int $ilocOffset, int $sizeDiff): void + private function describeDepth(?Box $pixi): array { - [$metaOffset, $metaSize] = $this->findMetaBox(); - - if ($metaOffset === 0) { - return; - } - $metaEndBeforeExpansion = $metaOffset + $metaSize - $sizeDiff; - $offset = $ilocOffset + 8; - $version = ord($this->data[$offset]); - $offset += 4; - $offsetSize = (ord($this->data[$offset]) >> 4) & 0x0F; - $lengthSize = ord($this->data[$offset]) & 0x0F; - $baseOffsetSize = (ord($this->data[$offset + 1]) >> 4) & 0x0F; - $offset += 2; - $itemCount = $version < 2 - ? $this->unpack('n', $this->data, $offset)[1] - : $this->unpack('N', $this->data, $offset)[1]; - $offset += $version < 2 ? 2 : 4; - - for ($i = 0; $i < $itemCount; $i++) { - $offset += $version < 2 ? 2 : 4; - if ($version === 1 || $version === 2) { - $offset += 2; - } - $offset += 2; - - if ($baseOffsetSize > 0) { - if ($baseOffsetSize === 4) { - $old = $this->unpack('N', $this->data, $offset)[1]; - if ($old >= $metaEndBeforeExpansion) { - $this->data = substr_replace($this->data, pack('N', $old + $sizeDiff), $offset, 4); - } - } elseif ($baseOffsetSize === 8) { - $old = $this->unpack('J', $this->data, $offset)[1]; - if ($old >= $metaEndBeforeExpansion) { - $this->data = substr_replace($this->data, pack('J', $old + $sizeDiff), $offset, 8); - } - } - $offset += $baseOffsetSize; - } - $extentCount = $this->unpack('n', $this->data, $offset)[1]; - $offset += 2; - - for ($j = 0; $j < $extentCount; $j++) { - if ($version === 1 || $version === 2) { - $offset += $offsetSize; - } - - if ($offsetSize === 4) { - $old = $this->unpack('N', $this->data, $offset)[1]; - if ($old >= $metaEndBeforeExpansion) { - $this->data = substr_replace($this->data, pack('N', $old + $sizeDiff), $offset, 4); - } - } elseif ($offsetSize === 8) { - $old = $this->unpack('J', $this->data, $offset)[1]; - if ($old >= $metaEndBeforeExpansion) { - $this->data = substr_replace($this->data, pack('J', $old + $sizeDiff), $offset, 8); - } - } - $offset += $offsetSize + $lengthSize; - } - } - } - - private function updateExifDataInMdatBox(int $itemId, string $metaContent, string $exifPayload): void - { - $offset = 4; - while ($offset < strlen($metaContent)) { - $size = $this->unpack('N', $metaContent, $offset)[1]; - if ($size === 0) { - break; - } - $type = substr($metaContent, $offset + 4, 4); - $contentOffset = $offset + 8; - - if ($type === 'iloc') { - $location = $this->findItemLocationInIlocBox(substr($metaContent, $contentOffset, $size - 8), $itemId); - if ($location !== null) { - foreach ($this->decoder->decode($this->data) as $box) { - if ($box['type'] === 'mdat') { - $mdatContentStart = $box['offset'] + 8; - $relativeOffset = $location['offset'] - $mdatContentStart; - $oldLength = $location['length']; - $newLength = strlen($exifPayload); - $sizeDiff = $newLength - $oldLength; - $mdatContentOffset = $box['offset'] + 8 + $relativeOffset; - $this->data = substr($this->data, 0, $mdatContentOffset) - . $exifPayload - . substr($this->data, $mdatContentOffset + $oldLength); - - $newMdatSize = $box['size'] + $sizeDiff; - $this->data = substr_replace($this->data, pack('N', $newMdatSize), $box['offset'], 4); - - if ($sizeDiff !== 0) { - [$metaOffset, $metaSize] = $this->findMetaBox(); - if ($metaOffset > 0) { - $metaContentStart = $metaOffset + 12; - $metaContent = substr($this->data, $metaContentStart, $metaSize - 12); - $offset = 4; - while ($offset < strlen($metaContent)) { - $size = $this->unpack('N', $metaContent, $offset)[1]; - if ($size === 0) { - break; - } - $type = substr($metaContent, $offset + 4, 4); - if ($type === 'iloc') { - $ilocOffset = $metaContentStart + $offset; - $ilocContentOffset = $ilocOffset + 8; - $version = ord($this->data[$ilocContentOffset]); - $ilocContentOffset += 4; - $offsetSize = (ord($this->data[$ilocContentOffset]) >> 4) & 0x0F; - $lengthSize = ord($this->data[$ilocContentOffset]) & 0x0F; - $baseOffsetSize = (ord($this->data[$ilocContentOffset + 1]) >> 4) & 0x0F; - $ilocContentOffset += 2; - $itemCount = $version < 2 - ? $this->unpack('n', $this->data, $ilocContentOffset)[1] - : $this->unpack('N', $this->data, $ilocContentOffset)[1]; - $ilocContentOffset += $version < 2 ? 2 : 4; - for ($i = 0; $i < $itemCount; $i++) { - $currentItemId = $version < 2 - ? $this->unpack('n', $this->data, $ilocContentOffset)[1] - : $this->unpack('N', $this->data, $ilocContentOffset)[1]; - $ilocContentOffset += $version < 2 ? 2 : 4; - if ($version === 1 || $version === 2) { - $ilocContentOffset += 2; - } - $ilocContentOffset += 2; - if ($baseOffsetSize > 0) { - $ilocContentOffset += $baseOffsetSize; - } - $extentCount = $this->unpack('n', $this->data, $ilocContentOffset)[1]; - $ilocContentOffset += 2; - if ($currentItemId === $itemId && $extentCount > 0) { - if ($version === 1 || $version === 2) { - $ilocContentOffset += $offsetSize; - } - $ilocContentOffset += $offsetSize; - if ($lengthSize === 4) { - $this->data = substr_replace($this->data, pack('N', $newLength), $ilocContentOffset, 4); - } elseif ($lengthSize === 8) { - $this->data = substr_replace($this->data, pack('J', $newLength), $ilocContentOffset, 8); - } - break 2; - } - for ($j = 0; $j < $extentCount; $j++) { - if ($version === 1 || $version === 2) { - $ilocContentOffset += $offsetSize; - } - $ilocContentOffset += $offsetSize + $lengthSize; - } - } - break; - } - $offset += $size; - } - } - } - - return; - } - } - } - break; - } - $offset += $size; - } - } - - private function insertExifData(int $metaOffset, int $metaSize, string $exifPayload): void - { - $originalMetaSize = $metaSize; - $metaContentStart = $metaOffset + 12; - $metaContent = substr($this->data, $metaContentStart, $metaSize - 12); - $iinfOffset = 0; - $iinfSize = 0; - $offset = 0; - - while ($offset < strlen($metaContent)) { - $size = $this->unpack('N', $metaContent, $offset)[1]; - if ($size === 0) { - break; - } - $type = substr($metaContent, $offset + 4, 4); - if ($type === 'iinf') { - $iinfOffset = $metaContentStart + $offset; - $iinfSize = $size; - break; - } - $offset += $size; - } - - if ($iinfOffset === 0) { - [$iinfOffset, $iinfSize] = $this->createIinfBox($metaOffset, $metaSize); + if ($pixi === null) { + return []; } - $newItemId = $this->getNextItemId(substr($this->data, $metaOffset + 12, $this->findBox($this->data, 0, strlen($this->data), 'meta')[1] - 12)); - $this->addInfeToIinfBox((int) $iinfOffset, (int) $iinfSize, $this->createInfeBox($newItemId, 'Exif')); - - $this->finalizeIlocBoxOffsets($metaOffset, $originalMetaSize); - [$mdatOffset, $mdatSize] = $this->findBox($this->data, 0, strlen($this->data), 'mdat'); - $mdatContentEnd = $mdatOffset + $mdatSize; - $absoluteExifOffset = $mdatContentEnd; + $reader = new ByteReader($pixi->payload); + $reader->skip(4); - $this->data = substr($this->data, 0, $mdatContentEnd) . $exifPayload . substr($this->data, $mdatContentEnd); - - $newMdatSize = $mdatSize + strlen($exifPayload); - $this->data = substr_replace($this->data, pack('N', $newMdatSize), $mdatOffset, 4); - - $this->addIlocEntry($newItemId, $absoluteExifOffset, strlen($exifPayload)); + return $reader->uint(1) > 0 ? ['colorDepth' => $reader->uint(1)] : []; } - private function finalizeIlocBoxOffsets(int $originalMetaOffset, int $originalMetaSize): void + /** + * @return array{colorSpace?: ColorSpace} + */ + private function describeColorSpace(?Box $av1C): array { - [$metaOffset, $metaSize] = $this->findMetaBox(); - - if ($metaOffset === 0) { - return; - } - $totalExpansion = $metaSize - $originalMetaSize; - if ($totalExpansion === 0) { - return; - } - $threshold = $originalMetaOffset + $originalMetaSize; - $metaContent = substr($this->data, $metaOffset + 12, $metaSize - 12); - $offset = 0; - - while ($offset < strlen($metaContent)) { - $size = $this->unpack('N', $metaContent, $offset)[1]; - if ($size === 0) { - break; - } - $type = substr($metaContent, $offset + 4, 4); - - if ($type === 'iloc') { - $ilocOffset = $metaOffset + 12 + $offset; - $ilocContentOffset = $ilocOffset + 8; - $version = ord($this->data[$ilocContentOffset]); - $ilocContentOffset += 4; - $offsetSize = (ord($this->data[$ilocContentOffset]) >> 4) & 0x0F; - $lengthSize = ord($this->data[$ilocContentOffset]) & 0x0F; - $baseOffsetSize = (ord($this->data[$ilocContentOffset + 1]) >> 4) & 0x0F; - $ilocContentOffset += 2; - $itemCount = $version < 2 - ? $this->unpack('n', $this->data, $ilocContentOffset)[1] - : $this->unpack('N', $this->data, $ilocContentOffset)[1]; - $ilocContentOffset += $version < 2 ? 2 : 4; - - for ($i = 0; $i < $itemCount; $i++) { - $ilocContentOffset += $version < 2 ? 2 : 4; - if ($version === 1 || $version === 2) { - $ilocContentOffset += 2; - } - $ilocContentOffset += 2; - - if ($baseOffsetSize > 0) { - $ilocContentOffset += $baseOffsetSize; - } - $extentCount = $this->unpack('n', $this->data, $ilocContentOffset)[1]; - $ilocContentOffset += 2; - - for ($j = 0; $j < $extentCount; $j++) { - if ($version === 1 || $version === 2) { - $ilocContentOffset += $offsetSize; - } - - if ($offsetSize === 4) { - $old = $this->unpack('N', $this->data, $ilocContentOffset)[1]; - if ($old > 0 && $old >= $threshold) { - $new = $old + $totalExpansion; - $this->data = substr_replace($this->data, pack('N', $new), $ilocContentOffset, 4); - } - } elseif ($offsetSize === 8) { - $old = $this->unpack('J', $this->data, $ilocContentOffset)[1]; - if ($old > 0 && $old >= $threshold) { - $new = $old + $totalExpansion; - $this->data = substr_replace($this->data, pack('J', $new), $ilocContentOffset, 8); - } - } - $ilocContentOffset += $offsetSize + $lengthSize; - } - } - - return; - } - $offset += $size; - } + // The monochrome flag is the fifth bit of the third byte of the codec configuration + return $av1C !== null && strlen($av1C->payload) > 2 && (ord($av1C->payload[2]) & 0x10) !== 0 + ? ['colorSpace' => ColorSpace::Grayscale] + : []; } - private function getNextItemId(string $metaContent): int + /** + * @param list $properties + */ + private function hasAlphaChannel(array $properties): bool { - $maxId = 0; - $offset = 0; - - while ($offset < strlen($metaContent)) { - $size = $this->unpack('N', $metaContent, $offset)[1]; - if ($size === 0) { - break; - } - $type = substr($metaContent, $offset + 4, 4); - $contentOffset = $offset + 8; - - if ($type === 'iinf') { - $version = ord($metaContent[$contentOffset]); - $entryCountOffset = $contentOffset + 4; - $entryCount = $version === 0 - ? $this->unpack('n', $metaContent, $entryCountOffset)[1] - : $this->unpack('N', $metaContent, $entryCountOffset)[1]; - - $infeOffset = $entryCountOffset + ($version === 0 ? 2 : 4); - - for ($i = 0; $i < $entryCount; $i++) { - $infeSize = $this->unpack('N', $metaContent, $infeOffset)[1]; - $infeContentOffset = $infeOffset + 8; - $infeVersion = ord($metaContent[$infeContentOffset]); - $itemIdOffset = $infeContentOffset + 4; - $itemId = $infeVersion < 3 - ? $this->unpack('n', $metaContent, $itemIdOffset)[1] - : $this->unpack('N', $metaContent, $itemIdOffset)[1]; - - $maxId = max($maxId, $itemId); - $infeOffset += $infeSize; - } - break; + foreach ($properties as $property) { + if ($property->type === 'auxC' && str_contains(explode("\x00", substr($property->payload, 4), 2)[0], 'alpha')) { + return true; } - $offset += $size; } - return $maxId + 1; - } - - private function createInfeBox(int $itemId, string $itemType): string - { - // version (1 byte) + flags (3 bytes) + item_ID (2 bytes) + item_protection_index (2 bytes) + item_type (4 bytes) + null terminator - $content = pack('C', 2) . substr(pack('N', 0), 1, 3) . pack('nn', $itemId, 0) . $itemType . "\x00"; - - return pack('N', 8 + strlen($content)) . 'infe' . $content; + return false; } - private function addInfeToIinfBox(int $iinfOffset, int $iinfSize, string $infeBox): void + /** + * Return whether a property is an embedded ICC profile + */ + private function isColorProfile(Box $property): bool { - $contentOffset = $iinfOffset + 8; - $version = ord($this->data[$contentOffset]); - $entryCountOffset = $contentOffset + 4; - $entryCount = $version === 0 - ? $this->unpack('n', $this->data, $entryCountOffset)[1] - : $this->unpack('N', $this->data, $entryCountOffset)[1]; - - $this->data = substr_replace($this->data, $version === 0 ? pack('n', $entryCount + 1) : pack('N', $entryCount + 1), $entryCountOffset, $version === 0 ? 2 : 4); - $insertPosition = $iinfOffset + $iinfSize; - $this->data = substr($this->data, 0, $insertPosition) . $infeBox . substr($this->data, $insertPosition); - $this->data = substr_replace($this->data, pack('N', $iinfSize + strlen($infeBox)), $iinfOffset, 4); - - foreach ($this->decoder->decode($this->data) as $box) { - if ($box['type'] === 'meta') { - $newMetaSize = $box['size'] + strlen($infeBox); - $this->data = substr_replace($this->data, pack('N', $newMetaSize), $box['offset'], 4); - $ilocOffset = 0; - $metaContentStart = $box['offset'] + 12; - $metaContent = substr($this->data, $metaContentStart, $newMetaSize - 12); - $offset = 4; - while ($offset < strlen($metaContent)) { - $size = $this->unpack('N', $metaContent, $offset)[1]; - if ($size === 0) { - break; - } - $type = substr($metaContent, $offset + 4, 4); - if ($type === 'iloc') { - $ilocOffset = $metaContentStart + $offset; - break; - } - $offset += $size; - } - - if ($ilocOffset > 0) { - $this->updateIlocBoxOffsets($ilocOffset, strlen($infeBox)); - } - - break; - } - } + return $property->type === 'colr' && in_array(substr($property->payload, 0, 4), self::ICC_COLOR_TYPES, true); } - private function addIlocEntry(int $itemId, int $dataOffset, int $dataLength): void + /** + * Remove all Exif items + * + * @return int Number of removed items + */ + private function removeExifItems(Container $container): int { - [$metaOffset, $metaSize] = $this->findMetaBox(); + $ids = $container->itemIdsByType(ItemInfoEntry::EXIF_TYPE); - if ($metaOffset === 0) { - return; - } - $metaContentStart = $metaOffset + 12; - $metaContent = substr($this->data, $metaContentStart, $metaSize - 12); - $offset = 0; - while ($offset < strlen($metaContent)) { - $size = $this->unpack('N', $metaContent, $offset)[1]; - if ($size === 0) { - break; - } - $type = substr($metaContent, $offset + 4, 4); - - if ($type === 'iloc') { - $ilocOffset = $metaContentStart + $offset; - $ilocContentOffset = $ilocOffset + 8; - $version = ord($this->data[$ilocContentOffset]); - $ilocContentOffset += 4; - $offsetSize = (ord($this->data[$ilocContentOffset]) >> 4) & 0x0F; - $lengthSize = ord($this->data[$ilocContentOffset]) & 0x0F; - $ilocContentOffset += 2; - $itemCountOffset = $ilocContentOffset; - $itemCount = $version < 2 - ? $this->unpack('n', $this->data, $itemCountOffset)[1] - : $this->unpack('N', $this->data, $itemCountOffset)[1]; - - $newEntry = ''; - if ($version === 0) { - $newEntry .= pack('nnn', $itemId, 0, 1); - } elseif ($version === 1 || $version === 2) { - $newEntry .= pack('Nnnn', $itemId, 0, 0, 1); - } else { - $newEntry .= pack('Nnn', $itemId, 0, 1); - } - if ($offsetSize === 4) { - $newEntry .= pack('N', $dataOffset); - } elseif ($offsetSize === 8) { - $newEntry .= pack('J', $dataOffset); - } - if ($lengthSize === 4) { - $newEntry .= pack('N', $dataLength); - } elseif ($lengthSize === 8) { - $newEntry .= pack('J', $dataLength); - } - $this->data = substr_replace($this->data, $version < 2 ? pack('n', $itemCount + 1) : pack('N', $itemCount + 1), $itemCountOffset, $version < 2 ? 2 : 4); - $insertPosition = $ilocOffset + $size; - $this->data = substr($this->data, 0, $insertPosition) . $newEntry . substr($this->data, $insertPosition); - $this->data = substr_replace($this->data, pack('N', $size + strlen($newEntry)), $ilocOffset, 4); - $this->data = substr_replace($this->data, pack('N', $metaSize + strlen($newEntry)), $metaOffset, 4); - $this->updateIlocBoxOffsets($ilocOffset, strlen($newEntry)); - - return; - } - $offset += $size; - } - $this->createIlocBox($metaOffset); - $metaSize = 0; - foreach ($this->decoder->decode($this->data) as $box) { - if ($box['type'] === 'meta') { - $metaOffset = $box['offset']; - $metaSize = $box['size']; - break; - } + foreach ($ids as $id) { + $container->removeItem($id); } - if ($metaSize === 0) { - return; - } - $metaContentStart = $metaOffset + 12; - $metaContent = substr($this->data, $metaContentStart, $metaSize - 12); - $offset = 0; - while ($offset < strlen($metaContent)) { - $size = $this->unpack('N', $metaContent, $offset)[1]; - if ($size === 0) { - break; - } - $type = substr($metaContent, $offset + 4, 4); - - if ($type === 'iloc') { - $ilocOffset = $metaContentStart + $offset; - $ilocContentOffset = $ilocOffset + 8; - $version = ord($this->data[$ilocContentOffset]); - $ilocContentOffset += 4; - $offsetSize = (ord($this->data[$ilocContentOffset]) >> 4) & 0x0F; - $lengthSize = ord($this->data[$ilocContentOffset]) & 0x0F; - $ilocContentOffset += 2; - $itemCountOffset = $ilocContentOffset; - $itemCount = $version < 2 - ? $this->unpack('n', $this->data, $itemCountOffset)[1] - : $this->unpack('N', $this->data, $itemCountOffset)[1]; - - $newEntry = ''; - if ($version === 0) { - $newEntry .= pack('nnn', $itemId, 0, 1); - } elseif ($version === 1 || $version === 2) { - $newEntry .= pack('Nnnn', $itemId, 0, 0, 1); - } else { - $newEntry .= pack('Nnn', $itemId, 0, 1); - } - - if ($offsetSize === 4) { - $newEntry .= pack('N', $dataOffset); - } elseif ($offsetSize === 8) { - $newEntry .= pack('J', $dataOffset); - } - - if ($lengthSize === 4) { - $newEntry .= pack('N', $dataLength); - } elseif ($lengthSize === 8) { - $newEntry .= pack('J', $dataLength); - } - $this->data = substr_replace($this->data, $version < 2 ? pack('n', $itemCount + 1) : pack('N', $itemCount + 1), $itemCountOffset, $version < 2 ? 2 : 4); - $insertPosition = $ilocOffset + $size; - $this->data = substr($this->data, 0, $insertPosition) . $newEntry . substr($this->data, $insertPosition); - $this->data = substr_replace($this->data, pack('N', $size + strlen($newEntry)), $ilocOffset, 4); - $this->data = substr_replace($this->data, pack('N', $metaSize + strlen($newEntry)), $metaOffset, 4); - $this->updateIlocBoxOffsets($ilocOffset, strlen($newEntry)); - - return; - } - $offset += $size; - } + return count($ids); } /** - * @return array + * Get the TIFF data of an Exif item, which is preceded by the offset of its header */ - private function createIinfBox(int $metaOffset, int $metaSize): array + private function extractTiffData(string $item): ?string { - $metaContentStart = $metaOffset + 12; - $metaContent = substr($this->data, $metaContentStart, $metaSize - 12); - $ilocOffset = 0; - $offset = 4; - while ($offset < strlen($metaContent)) { - $size = $this->unpack('N', $metaContent, $offset)[1]; - if ($size === 0) { - break; - } - $type = substr($metaContent, $offset + 4, 4); - if ($type === 'iloc') { - $ilocOffset = $metaContentStart + $offset; - break; - } - $offset += $size; + if (strlen($item) < self::EXIF_OFFSET_SIZE) { + return null; } - $iinfBox = pack('N', 14) . 'iinf' . pack('C', 0) . substr(pack('N', 0), 1, 3) . pack('n', 0); - $insertPosition = $ilocOffset > 0 ? (int) $ilocOffset : $metaOffset + $metaSize; - $this->data = substr($this->data, 0, $insertPosition) . $iinfBox . substr($this->data, $insertPosition); - $this->data = substr_replace($this->data, pack('N', $metaSize + strlen($iinfBox)), $metaOffset, 4); - return [$insertPosition, 14]; + $tiff = substr($item, self::EXIF_OFFSET_SIZE + (new ByteReader($item))->uint(self::EXIF_OFFSET_SIZE)); + + return $tiff === '' ? null : $tiff; } - private function createIlocBox(int $metaOffset): void + private function container(): Container { - $metaSize = $this->findBox($this->data, 0, strlen($this->data), 'meta')[1]; - $insertPosition = $metaOffset + $metaSize; - $this->data = substr($this->data, 0, $insertPosition) . pack('N', 16) . 'iloc' . pack('C', 0) . substr(pack('N', 0), 1, 3) . pack('C', 0x44) . pack('C', 0) . pack('n', 0) . substr($this->data, $insertPosition); - $this->data = substr_replace($this->data, pack('N', $metaSize + 16), $metaOffset, 4); + return Container::parse($this->decoder, $this->data); } /** - * @return array + * Get the data of the first embedded ICC profile */ - private function unpack(string $format, string $string, int $offset = 0): array + private function findColorProfileData(): ?string { - return unpack($format, $string, $offset) ?: throw new UnexpectedValueException('Cannot unpack string'); - } + $properties = $this->container()->meta()?->find(ItemPropertiesBox::class); - private function unpackInt(string $data, int $offset, int $size): int - { - if ($size === 0) { - return 0; - } - if ($size === 4) { - return $this->unpack('N', $data, $offset)[1]; - } - if ($size === 8) { - return $this->unpack('J', $data, $offset)[1]; - } - $value = 0; - for ($i = 0; $i < $size; $i++) { - $value = ($value << 8) | ord($data[$offset + $i]); + foreach ($properties?->all() ?? [] as $property) { + if ($this->isColorProfile($property)) { + return substr($property->payload, 4); + } } - return $value; + + return null; } } diff --git a/formwork/src/Images/Isobmff/Box.php b/formwork/src/Images/Isobmff/Box.php new file mode 100644 index 000000000..f0f4aedee --- /dev/null +++ b/formwork/src/Images/Isobmff/Box.php @@ -0,0 +1,119 @@ + + */ + public static function parseAll(string $data): array + { + $reader = new ByteReader($data); + $boxes = []; + + while ($reader->remaining() > 0) { + $boxes[] = self::read($reader); + } + + return $boxes; + } + + /** + * Pack the version and flags fields at the start of the payload of a full box + */ + public static function fullBoxHeader(int $version, int $flags = 0): string + { + return Bytes::pack($version, 1) . Bytes::pack($flags, 3); + } + + /** + * Encode a sequence of boxes + * + * @param iterable $boxes + */ + public static function encodeAll(iterable $boxes): string + { + $data = ''; + + foreach ($boxes as $box) { + $data .= $box->toBox()->encode(); + } + + return $data; + } + + public function toBox(): self + { + return $this; + } + + /** + * Get the length of the box header + */ + public function headerLength(): int + { + return strlen($this->payload) + self::HEADER_LENGTH > self::MAX_LENGTH ? self::LARGE_HEADER_LENGTH : self::HEADER_LENGTH; + } + + /** + * Get the length of the encoded box + */ + public function length(): int + { + return $this->headerLength() + strlen($this->payload); + } + + public function encode(): string + { + if ($this->headerLength() === self::HEADER_LENGTH) { + return Bytes::pack($this->length(), 4) . $this->type . $this->payload; + } + + return Bytes::pack(1, 4) . $this->type . Bytes::pack($this->length(), 8) . $this->payload; + } + + private static function read(ByteReader $reader): self + { + $size = $reader->uint(4); + $type = $reader->bytes(4); + + if ($size === 1) { + return new self($type, $reader->bytes($reader->uint(8) - self::LARGE_HEADER_LENGTH)); + } + + if ($size === 0) { + return new self($type, $reader->rest()); + } + + return new self($type, $reader->bytes($size - self::HEADER_LENGTH)); + } +} diff --git a/formwork/src/Images/Isobmff/Container.php b/formwork/src/Images/Isobmff/Container.php new file mode 100644 index 000000000..bd155681d --- /dev/null +++ b/formwork/src/Images/Isobmff/Container.php @@ -0,0 +1,320 @@ + + */ + private readonly array $boxes; + + /** + * Position at which the media data box has to be placed + */ + private readonly int $mediaDataPosition; + + private readonly bool $hasMovie; + + private readonly string $majorBrand; + + /** + * Data of the items added to the container + * + * @var array + */ + private array $addedData = []; + + /** + * @param list $boxes + */ + private function __construct( + private readonly string $source, + array $boxes, + ) { + $types = array_map(static fn(Box $box): string => $box->type, $boxes); + $position = array_search(self::MEDIA_DATA_TYPE, $types, true); + + $this->mediaDataPosition = $position === false ? count($boxes) : $position; + $this->hasMovie = in_array(self::MOVIE_TYPE, $types, true); + $this->majorBrand = substr($boxes[0]->payload, 0, 4); + $this->boxes = $this->withoutMediaData($boxes); + } + + /** + * Parse a file using the given decoder + */ + public static function parse(DecoderInterface $decoder, string $data): self + { + $boxes = []; + + foreach ($decoder->decode($data) as $decoded) { + $boxes[] = new Box((string) $decoded['type'], (string) $decoded['value']); + } + + return new self($data, $boxes); + } + + public function majorBrand(): string + { + return $this->majorBrand; + } + + /** + * Get the timeline of the image sequence, or null if the file does not contain one + */ + public function imageSequence(): ?ImageSequence + { + foreach ($this->boxes as $box) { + if ($box instanceof Box && $box->type === ImageSequence::TYPE) { + return ImageSequence::fromBox($box); + } + } + + return null; + } + + public function meta(): ?MetaBox + { + foreach ($this->boxes as $box) { + if ($box instanceof MetaBox) { + return $box; + } + } + + return null; + } + + /** + * @throws UnsupportedFeatureException If the file does not contain a meta box + */ + public function requireMeta(): MetaBox + { + return $this->meta() ?? throw new UnsupportedFeatureException('AVIF file does not contain a meta box'); + } + + /** + * Get the identifiers of all items of the given type + * + * @return list + */ + public function itemIdsByType(string $type): array + { + return $this->meta()?->find(ItemInfoBox::class)?->idsByType($type) ?? []; + } + + /** + * Get the data of an item, or null if the item does not exist or its data cannot be read + */ + public function itemData(int $id): ?string + { + $location = $this->meta()?->find(ItemLocationBox::class)?->find($id); + + return $location === null ? null : ($this->addedData[$id] ?? $this->readLocation($location)); + } + + /** + * Add an item and return its identifier + */ + public function addItem(string $type, string $data): int + { + $meta = $this->requireMeta(); + $info = $meta->require(ItemInfoBox::class); + $id = $info->nextId(); + + $info->add(ItemInfoEntry::create($id, $type)); + $meta->require(ItemLocationBox::class)->set(new ItemLocation($id, 0, 0, [new Extent(0, strlen($data))])); + $this->addedData[$id] = $data; + + return $id; + } + + /** + * Add a reference between two items + */ + public function addReference(string $type, int $from, int $to): void + { + $meta = $this->requireMeta(); + $references = $meta->find(ItemReferenceBox::class); + + if ($references === null) { + $references = new ItemReferenceBox(); + $meta->add($references); + } + + $references->add($type, $from, $to); + } + + /** + * Remove an item together with its data and references + */ + public function removeItem(int $id): void + { + $meta = $this->requireMeta(); + + $meta->require(ItemInfoBox::class)->remove($id); + $meta->require(ItemLocationBox::class)->remove($id); + $meta->find(ItemReferenceBox::class)?->removeItem($id); + + unset($this->addedData[$id]); + } + + /** + * Rebuild the file + * + * @throws UnsupportedFeatureException If the file contains an image sequence + */ + public function toString(): string + { + if ($this->hasMovie) { + throw new UnsupportedFeatureException('Modifying AVIF image sequences is not supported'); + } + + $meta = $this->requireMeta(); + $locations = $meta->require(ItemLocationBox::class); + + [$payload, $idat, $items] = $this->layoutItemData($locations); + + $mediaData = new Box(self::MEDIA_DATA_TYPE, $payload); + $provisional = $meta->withItemData($locations->withItems($items), $idat); + $offset = $this->mediaDataPayloadOffset($provisional, $mediaData); + $final = $meta->withItemData($locations->withItems($this->shiftItems($items, $offset)), $idat); + + return Box::encodeAll($this->sequence($final, $mediaData)); + } + + /** + * @param list $boxes + * + * @return list + */ + private function withoutMediaData(array $boxes): array + { + $kept = array_filter($boxes, static fn(Box $box): bool => $box->type !== self::MEDIA_DATA_TYPE); + + return array_values(array_map(static fn(Box $box): Box|MetaBox => $box->type === MetaBox::TYPE ? MetaBox::fromBox($box) : $box, $kept)); + } + + /** + * @param array $items + * + * @return array + */ + private function shiftItems(array $items, int $delta): array + { + return array_map(static fn(ItemLocation $item): ItemLocation => $item->isInFile() ? $item->shifted($delta) : $item, $items); + } + + /** + * @param list $extents + * + * @throws UnexpectedValueException If an extent is not valid + * @throws UnsupportedFeatureException If an extent has no explicit length + */ + private function readExtents(string $source, array $extents): string + { + $data = ''; + + foreach ($extents as $extent) { + if ($extent->length === 0) { + throw new UnsupportedFeatureException('Item extents without an explicit length are not supported'); + } + + if ($extent->offset + $extent->length > strlen($source)) { + throw new UnexpectedValueException('Item extent exceeds the available data'); + } + + $data .= substr($source, $extent->offset, $extent->length); + } + + return $data; + } + + private function readLocation(ItemLocation $location): ?string + { + $source = $this->sourceOf($location); + + return $source === null ? null : $this->readExtents($source, $location->extents); + } + + /** + * Get the data the extents of an item refer to + */ + private function sourceOf(ItemLocation $location): ?string + { + if ($location->isInFile()) { + return $this->source; + } + + return $location->isInIdat() ? $this->meta()?->idat() : null; + } + + /** + * Place the data of all items stored in the file or in the item data box one after the other + * + * @return array{string, string, array} Media data payload, item data box payload and item locations relative to their start + */ + private function layoutItemData(ItemLocationBox $locations): array + { + $file = new DataArea(); + $idat = new DataArea(); + $items = []; + + foreach ($locations->items() as $id => $item) { + $area = $item->isInFile() ? $file : ($item->isInIdat() ? $idat : null); + $items[$id] = $area === null ? $item : $item->withExtents($area->place($this->addedData[$id] ?? $this->readLocation($item) ?? '')); + } + + return [$file->payload(), $idat->payload(), $items]; + } + + /** + * Get the position of the media data payload in the rebuilt file + */ + private function mediaDataPayloadOffset(MetaBox $meta, Box $mediaData): int + { + $preceding = array_slice($this->sequence($meta, $mediaData), 0, $this->mediaDataPosition); + $length = array_sum(array_map(static fn(Box $box): int => $box->length(), $preceding)); + + return $length + $mediaData->headerLength(); + } + + /** + * Get the boxes of the rebuilt file + * + * @return list + */ + private function sequence(MetaBox $meta, Box $mediaData): array + { + $boxes = array_map(static fn(Box|MetaBox $box): Box => $box instanceof MetaBox ? $meta->toBox() : $box, $this->boxes); + array_splice($boxes, $this->mediaDataPosition, 0, [$mediaData]); + + return $boxes; + } +} diff --git a/formwork/src/Images/Isobmff/DataArea.php b/formwork/src/Images/Isobmff/DataArea.php new file mode 100644 index 000000000..3b66631c4 --- /dev/null +++ b/formwork/src/Images/Isobmff/DataArea.php @@ -0,0 +1,31 @@ + + */ + public function place(string $data): array + { + $extents = $data === '' ? [] : [new Extent(strlen($this->payload), strlen($data))]; + $this->payload .= $data; + + return $extents; + } + + public function payload(): string + { + return $this->payload; + } +} diff --git a/formwork/src/Images/Isobmff/Encodable.php b/formwork/src/Images/Isobmff/Encodable.php new file mode 100644 index 000000000..90fccd7e2 --- /dev/null +++ b/formwork/src/Images/Isobmff/Encodable.php @@ -0,0 +1,14 @@ +offset + $delta, $this->length, $this->index); + } +} diff --git a/formwork/src/Images/Isobmff/ImageSequence.php b/formwork/src/Images/Isobmff/ImageSequence.php new file mode 100644 index 000000000..31673cb06 --- /dev/null +++ b/formwork/src/Images/Isobmff/ImageSequence.php @@ -0,0 +1,157 @@ +payload) as $box) { + if ($box->type === 'trak' && self::find($box, ['tref']) === null) { + return $box; + } + } + } catch (UnexpectedValueException) { + } + + return null; + } + + /** + * Find a descendant box following a path of box types + * + * @param list $path + */ + private static function find(Box $box, array $path): ?Box + { + foreach ($path as $type) { + $found = null; + + foreach (Box::parseAll($box->payload) as $child) { + if ($child->type === $type) { + $found = $child; + break; + } + } + + if ($found === null) { + return null; + } + + $box = $found; + } + + return $box; + } + + private static function readFrameCount(Box $track): ?int + { + $sizes = self::find($track, ['mdia', 'minf', 'stbl', 'stsz']); + + if ($sizes === null) { + return null; + } + + $reader = new ByteReader($sizes->payload); + $reader->skip(8); + + return $reader->uint(4); + } + + private static function readPlayCount(Box $track): ?int + { + $header = self::find($track, ['tkhd']); + $edits = self::find($track, ['edts', 'elst']); + + if ($header === null) { + return null; + } + + if ($edits === null) { + return 1; + } + + $reader = new ByteReader($edits->payload); + $version = $reader->uint(1); + $repeats = ($reader->uint(3) & self::REPEAT_FLAG) !== 0; + + if (!$repeats) { + return 1; + } + + if ($reader->uint(4) < 1) { + return null; + } + + $segmentDuration = $reader->uint($version === 1 ? 8 : 4); + + $reader = new ByteReader($header->payload); + $version = $reader->uint(1); + $durationSize = $version === 1 ? 8 : 4; + + // Skip flags, creation and modification times, track identifier and reserved field + $reader->skip(3 + 2 * $durationSize + 8); + $duration = $reader->bytes($durationSize); + + if ($duration === str_repeat("\xff", $durationSize)) { + return 0; + } + + return $segmentDuration > 0 ? max(1, intdiv(Bytes::unpack($duration, 0, $durationSize), $segmentDuration)) : null; + } +} diff --git a/formwork/src/Images/Isobmff/ItemInfoBox.php b/formwork/src/Images/Isobmff/ItemInfoBox.php new file mode 100644 index 000000000..575e6d2e1 --- /dev/null +++ b/formwork/src/Images/Isobmff/ItemInfoBox.php @@ -0,0 +1,83 @@ + $entries + */ + public function __construct( + private readonly int $version = 0, + private array $entries = [], + ) {} + + public static function fromBox(Box $box): self + { + $reader = new ByteReader($box->payload); + $version = $reader->uint(1); + $reader->skip(3); + $reader->skip($version === 0 ? 2 : 4); + + $entries = []; + + foreach (Box::parseAll($reader->rest()) as $child) { + if ($child->type === ItemInfoEntry::TYPE) { + $entries[] = ItemInfoEntry::fromBox($child); + } + } + + return new self($version, $entries); + } + + /** + * Get the identifiers of all items of the given type + * + * @return list + */ + public function idsByType(string $type): array + { + $entries = array_filter($this->entries, static fn(ItemInfoEntry $entry): bool => $entry->type === $type); + + return array_values(array_map(static fn(ItemInfoEntry $entry): int => $entry->id, $entries)); + } + + /** + * Get an identifier higher than the ones used by all items + */ + public function nextId(): int + { + return max([0, ...array_map(static fn(ItemInfoEntry $entry): int => $entry->id, $this->entries)]) + 1; + } + + public function add(ItemInfoEntry $entry): void + { + $this->entries[] = $entry; + } + + public function remove(int $id): void + { + $this->entries = array_values(array_filter($this->entries, static fn(ItemInfoEntry $entry): bool => $entry->id !== $id)); + } + + public function toBox(): Box + { + $version = count($this->entries) > 0xFFFF ? 1 : $this->version; + + $payload = Box::fullBoxHeader($version) + . Bytes::pack(count($this->entries), $version === 0 ? 2 : 4) + . Box::encodeAll($this->entries); + + return new Box(self::TYPE, $payload); + } +} diff --git a/formwork/src/Images/Isobmff/ItemInfoEntry.php b/formwork/src/Images/Isobmff/ItemInfoEntry.php new file mode 100644 index 000000000..5a42efb8e --- /dev/null +++ b/formwork/src/Images/Isobmff/ItemInfoEntry.php @@ -0,0 +1,64 @@ +payload); + $version = $reader->uint(1); + $reader->skip(3); + $id = $reader->uint($version >= 3 ? 4 : 2); + + if ($version < 2) { + return new self($id, null, $box); + } + + $reader->skip(2); + + return new self($id, $reader->bytes(4), $box); + } + + /** + * Create an entry for an item without a name + */ + public static function create(int $id, string $type): self + { + $version = $id > 0xFFFF ? 3 : 2; + + $payload = Box::fullBoxHeader($version) + . Bytes::pack($id, $version === 3 ? 4 : 2) + . Bytes::pack(0, 2) + . $type + . "\x00"; + + return new self($id, $type, new Box(self::TYPE, $payload)); + } + + public function toBox(): Box + { + return $this->box; + } +} diff --git a/formwork/src/Images/Isobmff/ItemLocation.php b/formwork/src/Images/Isobmff/ItemLocation.php new file mode 100644 index 000000000..c32aa127f --- /dev/null +++ b/formwork/src/Images/Isobmff/ItemLocation.php @@ -0,0 +1,69 @@ + $extents + */ + public function __construct( + public int $id, + public int $constructionMethod = self::METHOD_FILE, + public int $dataReferenceIndex = 0, + public array $extents = [], + ) {} + + /** + * Return whether the item data is stored in the file itself + */ + public function isInFile(): bool + { + return $this->constructionMethod === self::METHOD_FILE && $this->dataReferenceIndex === 0; + } + + /** + * Return whether the item data is stored in the item data box + */ + public function isInIdat(): bool + { + return $this->constructionMethod === self::METHOD_IDAT && $this->dataReferenceIndex === 0; + } + + /** + * Return whether any extent uses an extent index + */ + public function usesIndex(): bool + { + return array_filter($this->extents, static fn(Extent $extent): bool => $extent->index > 0) !== []; + } + + /** + * @param list $extents + */ + public function withExtents(array $extents): self + { + return new self($this->id, $this->constructionMethod, $this->dataReferenceIndex, $extents); + } + + /** + * Get a copy of the location with all extent offsets moved by the given amount + */ + public function shifted(int $delta): self + { + return $this->withExtents(array_map(static fn(Extent $extent): Extent => $extent->shifted($delta), $this->extents)); + } +} diff --git a/formwork/src/Images/Isobmff/ItemLocationBox.php b/formwork/src/Images/Isobmff/ItemLocationBox.php new file mode 100644 index 000000000..dd35aa0d2 --- /dev/null +++ b/formwork/src/Images/Isobmff/ItemLocationBox.php @@ -0,0 +1,202 @@ + $items + */ + public function __construct( + private readonly int $version = 0, + private readonly int $offsetSize = 4, + private array $items = [], + ) {} + + /** + * @throws UnexpectedValueException If the box version is not supported + */ + public static function fromBox(Box $box): self + { + $reader = new ByteReader($box->payload); + $version = $reader->uint(1); + + if ($version > 2) { + throw new UnexpectedValueException(sprintf('Unsupported iloc box version %d', $version)); + } + + $reader->skip(3); + $sizes = self::readSizes($reader, $version); + $items = []; + + for ($count = $reader->uint(self::wideSize($version)); $count > 0; $count--) { + $item = self::readItem($reader, $version, $sizes); + $items[$item->id] = $item; + } + + return new self($version, max($sizes['offset'], 4), $items); + } + + /** + * @return array + */ + public function items(): array + { + return $this->items; + } + + public function find(int $id): ?ItemLocation + { + return $this->items[$id] ?? null; + } + + public function set(ItemLocation $item): void + { + $this->items[$item->id] = $item; + } + + public function remove(int $id): void + { + unset($this->items[$id]); + } + + /** + * Get a copy of the box with different items + * + * @param array $items + */ + public function withItems(array $items): self + { + return new self($this->version, $this->offsetSize, $items); + } + + public function toBox(): Box + { + $version = $this->encodedVersion(); + $indexSize = $version > 0 && $this->usesIndex() ? self::INDEX_SIZE : 0; + + $payload = Box::fullBoxHeader($version) + . Bytes::pack(($this->offsetSize << 12) | (self::LENGTH_SIZE << 8) | $indexSize, 2) + . Bytes::pack(count($this->items), self::wideSize($version)); + + foreach ($this->items as $item) { + $payload .= $this->encodeItem($item, $version, $indexSize); + } + + return new Box(self::TYPE, $payload); + } + + /** + * Get the size of item identifiers and counts for the given version + */ + private static function wideSize(int $version): int + { + return $version < 2 ? 2 : 4; + } + + /** + * @return array{offset: int, length: int, base: int, index: int} + */ + private static function readSizes(ByteReader $reader, int $version): array + { + $packed = $reader->uint(2); + + return [ + 'offset' => ($packed >> 12) & 0x0F, + 'length' => ($packed >> 8) & 0x0F, + 'base' => ($packed >> 4) & 0x0F, + 'index' => $version > 0 ? $packed & 0x0F : 0, + ]; + } + + /** + * @param array{offset: int, length: int, base: int, index: int} $sizes + */ + private static function readItem(ByteReader $reader, int $version, array $sizes): ItemLocation + { + $id = $reader->uint(self::wideSize($version)); + $method = $version > 0 ? $reader->uint(2) & 0x0F : 0; + $dataReferenceIndex = $reader->uint(2); + $baseOffset = $reader->uint($sizes['base']); + $extents = []; + + for ($count = $reader->uint(2); $count > 0; $count--) { + $extents[] = self::readExtent($reader, $sizes, $baseOffset); + } + + return new ItemLocation($id, $method, $dataReferenceIndex, $extents); + } + + /** + * @param array{offset: int, length: int, base: int, index: int} $sizes + */ + private static function readExtent(ByteReader $reader, array $sizes, int $baseOffset): Extent + { + $index = $reader->uint($sizes['index']); + $offset = $baseOffset + $reader->uint($sizes['offset']); + + return new Extent($offset, $reader->uint($sizes['length']), $index); + } + + /** + * Get the lowest version able to represent the items, since only the versions after the + * first one can store construction methods and extent indexes + */ + private function encodedVersion(): int + { + if (max([0, count($this->items), ...array_keys($this->items)]) > 0xFFFF) { + return 2; + } + + return $this->usesIndex() ? max($this->version, 1) : $this->version; + } + + private function usesIndex(): bool + { + return array_filter($this->items, static fn(ItemLocation $item): bool => $item->usesIndex()) !== []; + } + + private function encodeItem(ItemLocation $item, int $version, int $indexSize): string + { + $data = Bytes::pack($item->id, self::wideSize($version)); + + if ($version > 0) { + $data .= Bytes::pack($item->constructionMethod, 2); + } + + $data .= Bytes::pack($item->dataReferenceIndex, 2) . Bytes::pack(count($item->extents), 2); + + foreach ($item->extents as $extent) { + $data .= Bytes::pack($extent->index, $indexSize) + . Bytes::pack($extent->offset, $this->offsetSize) + . Bytes::pack($extent->length, self::LENGTH_SIZE); + } + + return $data; + } +} diff --git a/formwork/src/Images/Isobmff/ItemPropertiesBox.php b/formwork/src/Images/Isobmff/ItemPropertiesBox.php new file mode 100644 index 000000000..d688ea813 --- /dev/null +++ b/formwork/src/Images/Isobmff/ItemPropertiesBox.php @@ -0,0 +1,129 @@ + $properties + * @param list $associations + * @param list $others + */ + public function __construct( + private array $properties = [], + private array $associations = [], + private readonly array $others = [], + ) {} + + public static function fromBox(Box $box): self + { + $properties = []; + $associations = []; + $others = []; + + foreach (Box::parseAll($box->payload) as $child) { + match ($child->type) { + self::CONTAINER_TYPE => $properties = Box::parseAll($child->payload), + PropertyAssociationBox::TYPE => $associations[] = PropertyAssociationBox::fromBox($child), + default => $others[] = $child, + }; + } + + return new self($properties, $associations, $others); + } + + /** + * Get all properties + * + * @return list + */ + public function all(): array + { + return $this->properties; + } + + /** + * Get the properties associated with an item + * + * @return list + */ + public function forItem(int $itemId): array + { + $properties = []; + + foreach ($this->associations as $association) { + foreach ($association->indexesFor($itemId) as $index) { + $properties[] = $this->properties[$index - 1] ?? null; + } + } + + return array_values(array_filter($properties)); + } + + /** + * Add a property and associate it with an item + */ + public function add(Box $property, int $itemId): void + { + $this->properties[] = $property; + $this->primaryAssociations()->associate($itemId, count($this->properties)); + } + + /** + * Remove all properties matching a predicate + * + * @param callable(Box): bool $predicate + * + * @return int Number of removed properties + */ + public function removeWhere(callable $predicate): int + { + $keys = array_keys(array_filter($this->properties, $predicate)); + + foreach (array_reverse($keys) as $key) { + $this->removeAt($key + 1); + } + + return count($keys); + } + + public function toBox(): Box + { + $container = new Box(self::CONTAINER_TYPE, Box::encodeAll($this->properties)); + + return new Box(self::TYPE, Box::encodeAll([$container, ...$this->associations, ...$this->others])); + } + + /** + * Remove the property with the given one-based index + */ + private function removeAt(int $index): void + { + array_splice($this->properties, $index - 1, 1); + + foreach ($this->associations as $association) { + $association->removeProperty($index); + } + } + + private function primaryAssociations(): PropertyAssociationBox + { + if ($this->associations === []) { + $this->associations[] = new PropertyAssociationBox(); + } + + return $this->associations[0]; + } +} diff --git a/formwork/src/Images/Isobmff/ItemReference.php b/formwork/src/Images/Isobmff/ItemReference.php new file mode 100644 index 000000000..2ab8858d3 --- /dev/null +++ b/formwork/src/Images/Isobmff/ItemReference.php @@ -0,0 +1,65 @@ + $to + */ + public function __construct( + public string $type, + public int $from, + public array $to, + ) {} + + public static function fromBox(Box $box, int $idSize): self + { + $reader = new ByteReader($box->payload); + $from = $reader->uint($idSize); + $to = []; + + for ($count = $reader->uint(2); $count > 0; $count--) { + $to[] = $reader->uint($idSize); + } + + return new self($box->type, $from, $to); + } + + /** + * Get all the item identifiers involved in the reference + * + * @return list + */ + public function ids(): array + { + return [$this->from, ...$this->to]; + } + + public function toBox(int $idSize): Box + { + $payload = Bytes::pack($this->from, $idSize) . Bytes::pack(count($this->to), 2); + + foreach ($this->to as $id) { + $payload .= Bytes::pack($id, $idSize); + } + + return new Box($this->type, $payload); + } + + /** + * Get a copy of the reference without the given item, or null if nothing is left to reference + */ + public function withoutItem(int $id): ?self + { + $to = array_values(array_diff($this->to, [$id])); + + return $this->from === $id || $to === [] ? null : new self($this->type, $this->from, $to); + } +} diff --git a/formwork/src/Images/Isobmff/ItemReferenceBox.php b/formwork/src/Images/Isobmff/ItemReferenceBox.php new file mode 100644 index 000000000..dd15bddf9 --- /dev/null +++ b/formwork/src/Images/Isobmff/ItemReferenceBox.php @@ -0,0 +1,82 @@ + $references + */ + public function __construct( + private readonly int $version = 0, + private array $references = [], + ) {} + + public static function fromBox(Box $box): self + { + $reader = new ByteReader($box->payload); + $version = $reader->uint(1); + $reader->skip(3); + $idSize = self::idSize($version); + + $references = array_map( + static fn(Box $child): ItemReference => ItemReference::fromBox($child, $idSize), + Box::parseAll($reader->rest()), + ); + + return new self($version, $references); + } + + public function add(string $type, int $from, int $to): void + { + $this->references[] = new ItemReference($type, $from, [$to]); + } + + /** + * Remove all the references from or to an item + */ + public function removeItem(int $id): void + { + $references = array_map(static fn(ItemReference $reference): ?ItemReference => $reference->withoutItem($id), $this->references); + $this->references = array_values(array_filter($references)); + } + + public function toBox(): Box + { + $version = $this->encodedVersion(); + $idSize = self::idSize($version); + + $children = array_map(static fn(ItemReference $reference): Box => $reference->toBox($idSize), $this->references); + + return new Box(self::TYPE, Box::fullBoxHeader($version) . Box::encodeAll($children)); + } + + private static function idSize(int $version): int + { + return $version === 0 ? 2 : 4; + } + + /** + * Get the lowest version able to represent the references + */ + private function encodedVersion(): int + { + $ids = array_merge(...array_map(static fn(ItemReference $reference): array => $reference->ids(), $this->references)); + + return max([0, ...$ids]) > 0xFFFF ? 1 : $this->version; + } +} diff --git a/formwork/src/Images/Isobmff/MetaBox.php b/formwork/src/Images/Isobmff/MetaBox.php new file mode 100644 index 000000000..87c0f2ae9 --- /dev/null +++ b/formwork/src/Images/Isobmff/MetaBox.php @@ -0,0 +1,172 @@ + ItemInfoBox::class, + ItemLocationBox::TYPE => ItemLocationBox::class, + ItemPropertiesBox::TYPE => ItemPropertiesBox::class, + ItemReferenceBox::TYPE => ItemReferenceBox::class, + ]; + + /** + * @param string $header Version and flags of the box + * @param list $children + */ + public function __construct( + private readonly string $header, + private array $children, + ) {} + + public static function fromBox(Box $box): self + { + $reader = new ByteReader($box->payload); + $header = $reader->bytes(4); + + return new self($header, array_map(self::parseChild(...), Box::parseAll($reader->rest()))); + } + + /** + * Find the first child of the given class + * + * @template T of Encodable + * + * @param class-string $class + * + * @return T|null + */ + public function find(string $class): ?Encodable + { + foreach ($this->children as $child) { + if ($child instanceof $class) { + return $child; + } + } + + return null; + } + + /** + * Get the first child of the given class + * + * @template T of Encodable + * + * @param class-string $class + * + * @throws UnsupportedFeatureException If the child does not exist + * + * @return T + */ + public function require(string $class): Encodable + { + return $this->find($class) ?? throw new UnsupportedFeatureException(sprintf('AVIF file does not contain a required "%s" box', constant($class . '::TYPE'))); + } + + public function add(Encodable $child): void + { + $this->children[] = $child; + } + + /** + * Get a copy of the box with a different item location box and item data box content + */ + public function withItemData(ItemLocationBox $locations, string $idat): self + { + $children = array_map(static fn(Encodable $child): ?Encodable => self::replaceItemData($child, $locations, $idat), $this->children); + + return new self($this->header, array_values(array_filter($children))); + } + + /** + * Get the identifier of the primary item + */ + public function primaryItemId(): ?int + { + $box = $this->findGeneric('pitm'); + + if ($box === null) { + return null; + } + + $reader = new ByteReader($box->payload); + $version = $reader->uint(1); + $reader->skip(3); + + return $reader->uint($version === 0 ? 2 : 4); + } + + /** + * @throws UnsupportedFeatureException If the primary item is not defined + */ + public function requirePrimaryItemId(): int + { + return $this->primaryItemId() ?? throw new UnsupportedFeatureException('AVIF file does not define a primary item'); + } + + /** + * Get the content of the item data box + */ + public function idat(): ?string + { + return $this->findGeneric(self::ITEM_DATA_TYPE)?->payload; + } + + public function toBox(): Box + { + return new Box(self::TYPE, $this->header . Box::encodeAll($this->children)); + } + + /** + * Replace the item location box and the content of the item data box, which is dropped when empty + */ + private static function replaceItemData(Encodable $child, ItemLocationBox $locations, string $idat): ?Encodable + { + if ($child instanceof ItemLocationBox) { + return $locations; + } + + if (!$child instanceof Box || $child->type !== self::ITEM_DATA_TYPE) { + return $child; + } + + return $idat === '' ? null : new Box(self::ITEM_DATA_TYPE, $idat); + } + + private static function parseChild(Box $box): Encodable + { + $class = self::PARSED_BOXES[$box->type] ?? null; + + return $class === null ? $box : $class::fromBox($box); + } + + private function findGeneric(string $type): ?Box + { + foreach ($this->children as $child) { + if ($child instanceof Box && $child->type === $type) { + return $child; + } + } + + return null; + } +} diff --git a/formwork/src/Images/Isobmff/PropertyAssociation.php b/formwork/src/Images/Isobmff/PropertyAssociation.php new file mode 100644 index 000000000..0faed9f03 --- /dev/null +++ b/formwork/src/Images/Isobmff/PropertyAssociation.php @@ -0,0 +1,41 @@ +uint($wide ? 2 : 1); + + return new self($value & ((1 << $bits) - 1), ($value >> $bits) === 1); + } + + public function encode(bool $wide): string + { + $flag = $this->essential ? 1 << self::indexBits($wide) : 0; + + return Bytes::pack($flag | $this->index, $wide ? 2 : 1); + } + + private static function indexBits(bool $wide): int + { + return $wide ? 15 : 7; + } +} diff --git a/formwork/src/Images/Isobmff/PropertyAssociationBox.php b/formwork/src/Images/Isobmff/PropertyAssociationBox.php new file mode 100644 index 000000000..1df46358b --- /dev/null +++ b/formwork/src/Images/Isobmff/PropertyAssociationBox.php @@ -0,0 +1,153 @@ +> $entries Associations keyed by item identifier + */ + public function __construct( + private readonly int $version = 0, + private readonly int $flags = 0, + private array $entries = [], + ) {} + + public static function fromBox(Box $box): self + { + $reader = new ByteReader($box->payload); + $version = $reader->uint(1); + $flags = $reader->uint(3); + $wide = ($flags & self::WIDE_INDEXES_FLAG) !== 0; + $entries = []; + + for ($count = $reader->uint(4); $count > 0; $count--) { + $entries[$reader->uint($version < 1 ? 2 : 4)] = self::readAssociations($reader, $wide); + } + + return new self($version, $flags, $entries); + } + + /** + * Get the one-based indexes of the properties associated with an item + * + * @return list + */ + public function indexesFor(int $itemId): array + { + return array_map(static fn(PropertyAssociation $association): int => $association->index, $this->entries[$itemId] ?? []); + } + + public function associate(int $itemId, int $index, bool $essential = false): void + { + $this->entries[$itemId][] = new PropertyAssociation($index, $essential); + } + + /** + * Remove all associations with a property and renumber the following ones + */ + public function removeProperty(int $index): void + { + foreach ($this->entries as $itemId => $associations) { + $this->entries[$itemId] = $this->withoutProperty($associations, $index); + } + } + + public function toBox(): Box + { + $version = max([0, ...array_keys($this->entries)]) > 0xFFFF ? 1 : $this->version; + $wide = $this->usesWideIndexes(); + $flags = $wide ? $this->flags | self::WIDE_INDEXES_FLAG : $this->flags; + + $payload = Box::fullBoxHeader($version, $flags) . Bytes::pack(count($this->entries), 4); + + foreach ($this->entries as $itemId => $associations) { + $payload .= Bytes::pack($itemId, $version < 1 ? 2 : 4) . $this->encodeAssociations($associations, $wide); + } + + return new Box(self::TYPE, $payload); + } + + /** + * @return list + */ + private static function readAssociations(ByteReader $reader, bool $wide): array + { + $associations = []; + + for ($count = $reader->uint(1); $count > 0; $count--) { + $associations[] = PropertyAssociation::read($reader, $wide); + } + + return $associations; + } + + /** + * @param list $associations + * + * @return list + */ + private function withoutProperty(array $associations, int $index): array + { + $kept = []; + + foreach ($associations as $association) { + if ($association->index !== $index) { + $kept[] = $association->index > $index ? new PropertyAssociation($association->index - 1, $association->essential) : $association; + } + } + + return $kept; + } + + /** + * @param list $associations + */ + private function encodeAssociations(array $associations, bool $wide): string + { + $data = Bytes::pack(count($associations), 1); + + foreach ($associations as $association) { + $data .= $association->encode($wide); + } + + return $data; + } + + private function usesWideIndexes(): bool + { + $indexes = array_merge(...array_map($this->indexesOf(...), $this->entries)); + + return ($this->flags & self::WIDE_INDEXES_FLAG) !== 0 || max([0, ...$indexes]) > self::MAX_NARROW_INDEX; + } + + /** + * @param list $associations + * + * @return list + */ + private function indexesOf(array $associations): array + { + return array_map(static fn(PropertyAssociation $association): int => $association->index, $associations); + } +} diff --git a/tests/Unit/Images/Decoder/AvifDecoderTest.php b/tests/Unit/Images/Decoder/AvifDecoderTest.php new file mode 100644 index 000000000..c1bf299ee --- /dev/null +++ b/tests/Unit/Images/Decoder/AvifDecoderTest.php @@ -0,0 +1,118 @@ +box('ftyp', 'avif') . $this->box('meta', 'abc') . $this->box('mdat', ''); + + $boxes = iterator_to_array((new AvifDecoder())->decode($data), false); + + $this->assertSame(['ftyp', 'meta', 'mdat'], array_column($boxes, 'type')); + $this->assertSame(['avif', 'abc', ''], array_column($boxes, 'value')); + $this->assertSame([0, 12, 23], array_column($boxes, 'offset')); + $this->assertSame([12, 23, 31], array_column($boxes, 'position')); + } + + public function testBoxesWithALargeSizeAreDecoded(): void + { + $large = pack('N', 1) . 'free' . pack('J', 19) . 'abc'; + $data = $this->box('ftyp', 'avis') . $large; + + $boxes = iterator_to_array((new AvifDecoder())->decode($data), false); + + $this->assertSame('abc', $boxes[1]['value']); + $this->assertSame(19, $boxes[1]['size']); + } + + public function testBoxesExtendingToTheEndOfTheFileAreDecoded(): void + { + $data = $this->box('ftyp', 'avif') . pack('N', 0) . 'mdat' . 'payload'; + + $boxes = iterator_to_array((new AvifDecoder())->decode($data), false); + + $this->assertSame('payload', $boxes[1]['value']); + $this->assertSame(strlen($data), $boxes[1]['position']); + } + + public function testTheEncodedFixtureIsDecoded(): void + { + $data = (string) file_get_contents(__DIR__ . '/../Fixtures/image.avif'); + + $types = array_column(iterator_to_array((new AvifDecoder())->decode($data), false), 'type'); + + $this->assertSame('ftyp', $types[0]); + $this->assertContains('meta', $types); + } + + public function testShortDataIsNotDecoded(): void + { + $data = 'short'; + + $this->expectException(InvalidArgumentException::class); + + iterator_to_array((new AvifDecoder())->decode($data)); + } + + public function testDataWithoutTheFileTypeBoxIsNotDecoded(): void + { + $data = $this->box('meta', 'avif'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('missing ftyp box'); + + iterator_to_array((new AvifDecoder())->decode($data)); + } + + public function testUnsupportedBrandsAreNotDecoded(): void + { + $data = $this->box('ftyp', 'heic'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('unsupported brand'); + + iterator_to_array((new AvifDecoder())->decode($data)); + } + + public function testBoxesLongerThanTheDataAreNotDecoded(): void + { + $data = $this->box('ftyp', 'avif') . pack('N', 100) . 'meta' . 'short'; + + $this->expectException(UnexpectedValueException::class); + + iterator_to_array((new AvifDecoder())->decode($data)); + } + + public function testBoxesSmallerThanTheirHeaderAreNotDecoded(): void + { + $data = $this->box('ftyp', 'avif') . pack('N', 4) . 'meta'; + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Invalid box size'); + + iterator_to_array((new AvifDecoder())->decode($data)); + } + + public function testTruncatedBoxHeadersAreNotDecoded(): void + { + $data = $this->box('ftyp', 'avif') . "\x00\x00"; + + $this->expectException(UnexpectedValueException::class); + + iterator_to_array((new AvifDecoder())->decode($data)); + } + + private function box(string $type, string $payload): string + { + return pack('N', strlen($payload) + 8) . $type . $payload; + } +} diff --git a/tests/Unit/Images/Fixtures/AvifFile.php b/tests/Unit/Images/Fixtures/AvifFile.php new file mode 100644 index 000000000..0125d5c92 --- /dev/null +++ b/tests/Unit/Images/Fixtures/AvifFile.php @@ -0,0 +1,618 @@ + + */ + private array $extentOverrides = []; + + /** + * @var array + */ + private array $dataReferences = []; + + public static function create(): self + { + return new self(); + } + + /** + * Encode a box with the standard 32-bit size + */ + public static function box(string $type, string $payload = ''): string + { + return pack('N', 8 + strlen($payload)) . $type . $payload; + } + + /** + * Encode a box with the 64-bit size + */ + public static function largeBox(string $type, string $payload = ''): string + { + return pack('N', 1) . $type . pack('J', 16 + strlen($payload)) . $payload; + } + + /** + * Encode a box without an explicit size, which extends to the end of the file + */ + public static function unsizedBox(string $type, string $payload = ''): string + { + return pack('N', 0) . $type . $payload; + } + + /** + * Encode a box with a version and flags + */ + public static function fullBox(string $type, int $version, string $payload = '', int $flags = 0): string + { + return self::box($type, chr($version) . substr(pack('N', $flags), 1) . $payload); + } + + /** + * Use a different major brand + */ + public function withBrand(string $brand): self + { + $this->brand = $brand; + + return $this; + } + + /** + * Use different image dimensions + */ + public function withDimensions(int $width, int $height): self + { + $this->width = $width; + $this->height = $height; + + return $this; + } + + /** + * Use a different color depth, or no depth property at all + */ + public function withDepth(int $depth): self + { + $this->depth = $depth; + + return $this; + } + + /** + * Embed an ICC profile in a color information property + */ + public function withColorProfile(string $data, string $type = 'prof'): self + { + $this->colorProfile = $type . $data; + + return $this; + } + + /** + * Embed Exif metadata, preceded by the offset of its TIFF header + */ + public function withExif(string $tiff, int $headerOffset = 0): self + { + $this->exif = pack('N', $headerOffset) . str_repeat("\x00", $headerOffset) . $tiff; + + return $this; + } + + /** + * Add an Exif item with the given data, which does not have to be well-formed + */ + public function withRawExif(string $data): self + { + $this->exif = $data; + + return $this; + } + + /** + * Store the given extent for an item, whatever the position and the length of its data + */ + public function withExtentOverride(int $itemId, ?int $offset = null, ?int $length = null): self + { + $this->extentOverrides[$itemId] = ['offset' => $offset, 'length' => $length]; + + return $this; + } + + /** + * Make an item point to the data of another file through a data reference + */ + public function withDataReference(int $itemId, int $index = 1): self + { + $this->dataReferences[$itemId] = $index; + + return $this; + } + + /** + * Add a free space box the rewriting has to keep + */ + public function withFreeSpace(): self + { + $this->freeSpace = true; + + return $this; + } + + /** + * Leave out the image spatial extents property + */ + public function withoutDimensions(): self + { + $this->dimensions = false; + + return $this; + } + + /** + * Add an auxiliary item holding the alpha channel + */ + public function withAlpha(): self + { + $this->alpha = true; + + return $this; + } + + /** + * Turn the file into an image sequence, which cannot be rewritten + */ + public function asSequence(): self + { + $this->sequence = true; + $this->brand = 'avis'; + + return $this; + } + + /** + * Give the image sequence a timeline of frames, played a number of times (0 to loop forever) + */ + public function withFrames(int $frames, int $plays = 1): self + { + $this->frames = $frames; + $this->plays = $plays; + + return $this->asSequence(); + } + + /** + * Describe the codec configuration of the primary item + */ + public function withCodecConfiguration(bool $monochrome): self + { + $this->monochrome = $monochrome; + + return $this; + } + + /** + * Use a different version of the item location box, optionally with base offsets + */ + public function withLocationVersion(int $version, int $baseOffsetSize = 0): self + { + $this->locationVersion = $version; + $this->baseOffsetSize = $baseOffsetSize; + + return $this; + } + + /** + * Give the extents of the item location box an explicit index + */ + public function withExtentIndexes(): self + { + $this->extentIndexes = true; + + return $this; + } + + /** + * Store the data of the Exif item in the item data box instead of the media data one + */ + public function withItemDataBox(): self + { + $this->itemDataBox = true; + + return $this; + } + + /** + * Place the media data box before the metadata + */ + public function withMediaDataFirst(): self + { + $this->mediaDataFirst = true; + + return $this; + } + + /** + * Give the media data box a 64-bit size + */ + public function withLargeMediaDataBox(): self + { + $this->largeMediaDataBox = true; + + return $this; + } + + /** + * Give the media data box no explicit size, so that it extends to the end of the file + */ + public function withUnsizedMediaDataBox(): self + { + $this->unsizedMediaDataBox = true; + $this->mediaDataFirst = false; + + return $this; + } + + /** + * Leave the primary item undefined + */ + public function withoutPrimaryItem(): self + { + $this->primaryItem = false; + + return $this; + } + + /** + * Leave out the item properties box + */ + public function withoutItemProperties(): self + { + $this->itemProperties = false; + + return $this; + } + + /** + * Store the property associations as 15-bit indexes + */ + public function withWideAssociations(): self + { + $this->wideAssociations = true; + + return $this; + } + + /** + * Add properties not associated with any item, to push the following indexes further + */ + public function withFillerProperties(int $count): self + { + $this->fillerProperties = $count; + + return $this; + } + + /** + * Build the file + */ + public function build(): string + { + $items = $this->items(); + $inMediaData = array_filter($items, static fn(array $item): bool => !$item['idat']); + + $mediaData = implode('', array_column($inMediaData, 'data')); + $header = $this->fileType() + . ($this->freeSpace ? self::box('free', 'free-space') : '') + . ($this->sequence ? $this->movie() : ''); + + // The item location box has a fixed layout, so the metadata keeps the same size + // once the placeholder offsets are replaced with the definitive ones + $placeholder = $this->meta($items, 0); + $offset = strlen($header) + ($this->mediaDataFirst ? $this->mediaDataHeaderLength() : strlen($placeholder) + $this->mediaDataHeaderLength()); + + $meta = $this->meta($items, $offset); + $mediaDataBox = $this->mediaDataBox($mediaData); + + return $header . ($this->mediaDataFirst ? $mediaDataBox . $meta : $meta . $mediaDataBox); + } + + /** + * Get the items of the file, with the offsets of their data relative to the area holding it + * + * @return list + */ + private function items(): array + { + $entries = [['id' => self::PRIMARY_ID, 'type' => 'av01', 'data' => self::PRIMARY_DATA, 'idat' => false]]; + + if ($this->alpha) { + $entries[] = ['id' => self::ALPHA_ID, 'type' => 'av01', 'data' => self::ALPHA_DATA, 'idat' => false]; + } + + if ($this->exif !== null) { + $entries[] = ['id' => self::EXIF_ID, 'type' => 'Exif', 'data' => $this->exif, 'idat' => $this->itemDataBox]; + } + + $offsets = [false => 0, true => 0]; + $items = []; + + foreach ($entries as $entry) { + $items[] = [...$entry, 'offset' => $offsets[$entry['idat']]]; + $offsets[$entry['idat']] += strlen($entry['data']); + } + + return $items; + } + + private function fileType(): string + { + return self::box('ftyp', $this->brand . pack('N', 0) . 'avif' . 'mif1' . 'miaf'); + } + + private function mediaDataHeaderLength(): int + { + return $this->largeMediaDataBox ? 16 : 8; + } + + private function mediaDataBox(string $payload): string + { + if ($this->largeMediaDataBox) { + return self::largeBox('mdat', $payload); + } + + return $this->unsizedMediaDataBox ? self::unsizedBox('mdat', $payload) : self::box('mdat', $payload); + } + + /** + * @param list $items + */ + private function meta(array $items, int $mediaDataOffset): string + { + $children = $this->handler() + . ($this->primaryItem ? self::fullBox('pitm', 0, pack('n', self::PRIMARY_ID)) : '') + . $this->itemInfo($items) + . $this->itemLocation($items, $mediaDataOffset) + . ($this->itemProperties ? $this->properties() : '') + . $this->references() + . $this->itemData($items); + + return self::fullBox('meta', 0, $children); + } + + private function handler(): string + { + return self::fullBox('hdlr', 0, pack('N', 0) . 'pict' . str_repeat("\x00", 12) . "\x00"); + } + + /** + * @param list $items + */ + private function itemInfo(array $items): string + { + $entries = ''; + + foreach ($items as $item) { + $entries .= self::fullBox('infe', 2, pack('nn', $item['id'], 0) . $item['type'] . "\x00"); + } + + return self::fullBox('iinf', 0, pack('n', count($items)) . $entries); + } + + /** + * @param list $items + */ + private function itemLocation(array $items, int $mediaDataOffset): string + { + $version = $this->locationVersion; + $indexSize = $this->extentIndexes ? 4 : 0; + $wide = $version < 2 ? 2 : 4; + + $payload = pack('n', (4 << 12) | (4 << 8) | ($this->baseOffsetSize << 4) | ($version > 0 ? $indexSize : 0)) + . ($version < 2 ? pack('n', count($items)) : pack('N', count($items))); + + foreach ($items as $item) { + $method = $item['idat'] ? 1 : 0; + $base = $item['idat'] ? 0 : $mediaDataOffset; + $override = $this->extentOverrides[$item['id']] ?? ['offset' => null, 'length' => null]; + + $payload .= ($wide === 2 ? pack('n', $item['id']) : pack('N', $item['id'])) + . ($version > 0 ? pack('n', $method) : '') + . pack('n', $this->dataReferences[$item['id']] ?? 0) + . ($this->baseOffsetSize > 0 ? substr(pack('J', $base), 8 - $this->baseOffsetSize) : '') + . pack('n', 1) + . ($version > 0 && $indexSize > 0 ? pack('N', 1) : '') + . pack('N', $override['offset'] ?? $item['offset'] + ($this->baseOffsetSize > 0 ? 0 : $base)) + . pack('N', $override['length'] ?? strlen($item['data'])); + } + + return self::fullBox('iloc', $version, $payload); + } + + private function movie(): string + { + if ($this->frames === null) { + return self::box('moov'); + } + + // Every frame lasts one unit, so the duration of the track is the one of all its plays + $duration = $this->plays === 0 ? str_repeat("\xff", 8) : pack('J', $this->frames * $this->plays); + $header = self::fullBox('tkhd', 1, pack('JJ', 0, 0) . pack('NN', 1, 0) . $duration . str_repeat("\x00", 60), 3); + $edits = self::box('edts', self::fullBox('elst', 1, pack('N', 1) . pack('JJ', $this->frames, 0) . pack('N', 0x10000), $this->plays === 1 ? 0 : 1)); + $sizes = self::fullBox('stsz', 0, pack('NN', 0, $this->frames) . str_repeat(pack('N', 10), $this->frames)); + $media = self::box('mdia', self::box('minf', self::box('stbl', $sizes))); + + return self::box('moov', self::box('trak', $header . $edits . $media)); + } + + private function properties(): string + { + $properties = []; + $associations = [self::PRIMARY_ID => []]; + + if ($this->dimensions) { + $properties[] = self::fullBox('ispe', 0, pack('NN', $this->width, $this->height)); + $associations[self::PRIMARY_ID][] = count($properties); + } + + if ($this->depth > 0) { + $properties[] = self::fullBox('pixi', 0, chr(3) . str_repeat(chr($this->depth), 3)); + $associations[self::PRIMARY_ID][] = count($properties); + } + + if ($this->monochrome !== null) { + $properties[] = self::box('av1C', "\x81\x00" . chr($this->monochrome ? 0x1c : 0x0c) . "\x00"); + $associations[self::PRIMARY_ID][] = count($properties); + } + + if ($this->colorProfile !== null) { + $properties[] = self::box('colr', $this->colorProfile); + $associations[self::PRIMARY_ID][] = count($properties); + } + + for ($i = 0; $i < $this->fillerProperties; $i++) { + $properties[] = self::box('free', 'filler'); + } + + if ($this->alpha) { + $properties[] = self::fullBox('auxC', 0, self::ALPHA_AUX_TYPE . "\x00"); + $associations[self::ALPHA_ID] = [...($this->dimensions ? [1] : []), count($properties)]; + } + + $container = self::box('ipco', implode('', $properties)); + + return self::box('iprp', $container . $this->associations($associations)); + } + + /** + * @param array> $associations + */ + private function associations(array $associations): string + { + $wide = $this->wideAssociations; + $payload = pack('N', count($associations)); + + foreach ($associations as $itemId => $indexes) { + $payload .= pack('n', $itemId) . chr(count($indexes)); + + foreach ($indexes as $index) { + $payload .= $wide ? pack('n', $index) : chr($index); + } + } + + return self::fullBox('ipma', 0, $payload, $wide ? 1 : 0); + } + + private function references(): string + { + $references = ''; + + if ($this->alpha) { + $references .= self::box('auxl', pack('nnn', self::ALPHA_ID, 1, self::PRIMARY_ID)); + } + + if ($this->exif !== null) { + $references .= self::box('cdsc', pack('nnn', self::EXIF_ID, 1, self::PRIMARY_ID)); + } + + return $references === '' ? '' : self::fullBox('iref', 0, $references); + } + + /** + * @param list $items + */ + private function itemData(array $items): string + { + $payload = implode('', array_column(array_filter($items, static fn(array $item): bool => $item['idat']), 'data')); + + return $payload === '' ? '' : self::box('idat', $payload); + } +} diff --git a/tests/Unit/Images/Fixtures/alternate.tiff b/tests/Unit/Images/Fixtures/alternate.tiff new file mode 100644 index 0000000000000000000000000000000000000000..2ca5b0a80a5fbe5dd4424a1ed392714647db229b GIT binary patch literal 98 zcmebEWzb?^VBlb2VdQ6E0kq*KD literal 0 HcmV?d00001 diff --git a/tests/Unit/Images/Fixtures/exif.tiff b/tests/Unit/Images/Fixtures/exif.tiff new file mode 100644 index 0000000000000000000000000000000000000000..d9aa7003ac62f7a9b4d1279b2397f0ee3bd88913 GIT binary patch literal 72 zcmebEWzb?^VBlb2X5?pJ0}2A literal 0 HcmV?d00001 diff --git a/tests/Unit/Images/Fixtures/image.avif b/tests/Unit/Images/Fixtures/image.avif new file mode 100644 index 0000000000000000000000000000000000000000..4c782a430d851b833d546ec71402c42f33b69786 GIT binary patch literal 1425 zcmZ`&eQZ-z6#u<`tld_&>5AY|v3xid++>{yvy#|$cGK9AS*C6vQk*Jzv|g z#yBU45TZh|0ZxVC4AI3;7BfhUW(gW0fn=gnNeE%_5AY*#IL03%OFj3MS^jY6<=peT z=i}b<&N&x=JDN;ue3ujj5tE7(^(Yb_ZS;ErfW@hZNuJq=md7Hpo`rxW3CVdpFN-uO zsTdoMR<9(h0^234N6tcKX7S5fjAx#A{})4N2fL*xV`iw3bTNB0P{gOYWlDuJk}DD-3BgE|t}(kL zP1gX^jHC%F#W|&fCb9#Fe8knnC7RSNk%ZZ9x07)2U7osE;UH*g5mZ^%bd~Nhg=9fV zSbTsIPwL@@fV-u2i`(`G3SdJ%?tzyV5}LoMDMWz_(2bvs9PwinJ8J=_h17)+k^T%E z%W=J+>C`vQM>w=SsWJQpVW$w|BZQ9yoC8P=P_b@XrSOn1j^ zxcxRP#B>@5Qw8~~QsflnU=Y<84l(}pW|C#ZE+h~}0|IcP9VtkVkwg{AIMwX`<5>M& z*aVeg1X?wup;JT$Via3R+7z8Cl8lmGq)$eg)t%yqa`j9^AFMG{oFf2OFJ&^*_W`y( zU~YdVbMyU7W^RZ&d=VIn=i=3)KP)`2EZDc5Aa}^ilZkk&Y@_I={ zuP!@=MuTq+p^FTtr2(fV0VzH1RWz}~SOQ)_5;Sijok)sG!b?7qEb)5U%g?`_dnHZD zCQGS;vvQ`L7As5^E^jyWo7<*Dc=q=5vX82<5o>5?hlwjDcls&fa<0U{Y(CZzqh8gO z?W*3{EG8(nQH_XlRSWJ!LEh5EuYA4BSF^$ihsQU1R(#WYzc%(>=X7~b)3#He zwnd)XTYU19De}%|-a{74B74mbT+lN)IpEu2U)@i6Ui^N^TNjQU4BFNd+E4Uc+xTw( z+2CFN(POR`&hK46JQzQ4a^JGw9c{ZBMqYfp&7FRE!^(!ur~iIp_G*9EN8I?~iR*%W zQ~!aXGarAxc*1klzom8cla`Tvr#p+p%7K7#WcbUT;fF%L*~;fDrb5S?`<9(?K05u= zUpvF*n;7XUTsdPMOx>||Xv*fw|8ws3r`BDXZGU+D?TNHnd;Y8NGc(%q!$)TQ*XljL jZul;C?s9O?w`DEIKP&Bh_L1t6*0uF3mfRSG3r85?5U03|jW1^Nd4#?3{fU8? zAECUpx2@keNUS$(tvTOKQ4%nCU_N`ILim;W#4O^3`Hgupn_izYubE$dXBjb;f4juy zXMASOc1iblnpaTW6YH_{FM|0Mg&{|9bX%B+gIbdL*~ zsLETFmeyvqc(-@=N^W4gL!VwuTdCb?OWn~^FH>rJhr@tZvePTrR|nw6rrG9ZW}}HC OA$q5ERV(hyHO&w17h$CU literal 0 HcmV?d00001 diff --git a/tests/Unit/Images/Fixtures/srgb.icc b/tests/Unit/Images/Fixtures/srgb.icc new file mode 100644 index 0000000000000000000000000000000000000000..88ae44dc7e14876cb0e3658cdb71f5679835f0fa GIT binary patch literal 588 zcmZ{hze_?<6vsbmVLuFq8X^iDEE*J|Afm}^aFGZz$ZFB2PXo2bdrC-a(9$3@L@ikj z{Rg!Mtp!0)1QE@R{aQj^=jvdC-N(Dw#lE0&3J4coT{fU`-fA1Sj(9OsTr3Ve<5F%Gc=a^6HlhN zyd-f*-eD|aLJ%FNpPCK|p4HpQdKZcxD#N0yv;1EQ9tYeu3jdPbf+ z6*YJ9$!g8h(x)aHlEV{*&KKMM7zOt})T_4rb7I@|Q*b@QRk7vYtijBe=+&0yKL+m( zTwJ%b)B#-X!r+sUG?R_*xMX+gI=r7lUjq8?pl7W)E6J%mXO9rymMgl2!y_1zdhhx# DF3V^i literal 0 HcmV?d00001 diff --git a/tests/Unit/Images/Handler/AvifHandlerTest.php b/tests/Unit/Images/Handler/AvifHandlerTest.php new file mode 100644 index 000000000..11331888b --- /dev/null +++ b/tests/Unit/Images/Handler/AvifHandlerTest.php @@ -0,0 +1,537 @@ +encoded()->getInfo(); + + $this->assertSame('image/avif', $info->mimeType()); + $this->assertSame(64, $info->width()); + $this->assertSame(48, $info->height()); + $this->assertSame(ColorSpace::RGB, $info->colorSpace()); + $this->assertSame(8, $info->colorDepth()); + $this->assertTrue($info->hasAlphaChannel()); + $this->assertFalse($info->isAnimation()); + } + + public function testInfoIsReadFromThePropertiesOfThePrimaryItem(): void + { + $info = $this->handler(AvifFile::create()->withDimensions(1920, 1080)->withDepth(10))->getInfo(); + + $this->assertSame(1920, $info->width()); + $this->assertSame(1080, $info->height()); + $this->assertSame(10, $info->colorDepth()); + $this->assertFalse($info->hasAlphaChannel()); + } + + public function testInfoFallsBackToTheDefaultsWithoutProperties(): void + { + $info = $this->handler(AvifFile::create()->withoutItemProperties())->getInfo(); + + $this->assertSame(0, $info->width()); + $this->assertSame(0, $info->height()); + $this->assertSame(8, $info->colorDepth()); + $this->assertNull($info->colorNumber()); + $this->assertNull($info->animationFrames()); + $this->assertNull($info->animationRepeatCount()); + } + + public function testInfoReadsThePropertiesOfAnyItemWithoutAPrimaryOne(): void + { + $info = $this->handler(AvifFile::create()->withDimensions(32, 24)->withoutPrimaryItem())->getInfo(); + + $this->assertSame(32, $info->width()); + $this->assertSame(24, $info->height()); + } + + public function testInfoKeepsTheDefaultSizeWithoutSpatialExtents(): void + { + $info = $this->handler(AvifFile::create()->withoutDimensions()->withDepth(10))->getInfo(); + + $this->assertSame(0, $info->width()); + $this->assertSame(0, $info->height()); + $this->assertSame(10, $info->colorDepth()); + } + + public function testDepthIsTheDefaultOneWhenTheChannelsAreNotDeclared(): void + { + $this->assertSame(8, $this->handler(AvifFile::create()->withDepth(0))->getInfo()->colorDepth()); + } + + public function testAlphaChannelIsDetectedFromTheAuxiliaryType(): void + { + $this->assertTrue($this->handler(AvifFile::create()->withAlpha())->getInfo()->hasAlphaChannel()); + } + + public function testImageSequencesAreAnimations(): void + { + $this->assertTrue($this->handler(AvifFile::create()->asSequence())->getInfo()->isAnimation()); + } + + public function testImageSequencesHaveAFrameCountAndARepeatCount(): void + { + $info = $this->handler(AvifFile::create()->withFrames(12, 3))->getInfo(); + + $this->assertSame(12, $info->animationFrames()); + $this->assertSame(3, $info->animationRepeatCount()); + } + + public function testImageSequencesCanLoopForever(): void + { + $this->assertSame(0, $this->handler(AvifFile::create()->withFrames(5, 0))->getInfo()->animationRepeatCount()); + } + + public function testStillImagesHaveNoFrameCount(): void + { + $info = $this->encoded()->getInfo(); + + $this->assertNull($info->animationFrames()); + $this->assertNull($info->animationRepeatCount()); + } + + public function testMonochromeImagesAreGrayscale(): void + { + $this->assertSame(ColorSpace::Grayscale, $this->handler(AvifFile::create()->withCodecConfiguration(monochrome: true))->getInfo()->colorSpace()); + $this->assertSame(ColorSpace::RGB, $this->handler(AvifFile::create()->withCodecConfiguration(monochrome: false))->getInfo()->colorSpace()); + $this->assertSame(ColorSpace::RGB, $this->handler(AvifFile::create())->getInfo()->colorSpace()); + } + + public function testTransformsAreSupportedOnlyForStillImages(): void + { + $this->assertTrue($this->encoded()->supportsTransforms()); + $this->assertFalse($this->handler(AvifFile::create()->asSequence())->supportsTransforms()); + } + + public function testColorProfilesAndExifDataAreSupported(): void + { + $this->assertTrue(AvifHandler::supportsColorProfile()); + $this->assertTrue(AvifHandler::supportsExifData()); + } + + public function testColorProfileIsReadFromAnEncodedImage(): void + { + $handler = $this->encoded(); + + $this->assertTrue($handler->hasColorProfile()); + $this->assertSame($this->colorProfileData(), $handler->getColorProfile()?->getData()); + } + + #[DataProvider('colorInformationTypeProvider')] + public function testColorProfileIsReadFromBothColorInformationTypes(string $type): void + { + $handler = $this->handler(AvifFile::create()->withColorProfile($this->colorProfileData(), $type)); + + $this->assertTrue($handler->hasColorProfile()); + $this->assertSame($this->colorProfileData(), $handler->getColorProfile()?->getData()); + } + + /** + * @return iterable + */ + public static function colorInformationTypeProvider(): iterable + { + yield 'unrestricted profile' => ['prof']; + yield 'restricted profile' => ['rICC']; + } + + public function testColorProfileIsNotReadFromOtherColorInformation(): void + { + $handler = $this->handler(AvifFile::create()->withColorProfile(str_repeat("\x00", 15), 'nclx')); + + $this->assertFalse($handler->hasColorProfile()); + $this->assertNull($handler->getColorProfile()); + } + + public function testColorProfileIsMissingWithoutProperties(): void + { + $this->assertFalse($this->handler(AvifFile::create()->withoutItemProperties())->hasColorProfile()); + } + + public function testColorProfileIsAdded(): void + { + $handler = $this->handler(AvifFile::create()); + $handler->setColorProfile(new ColorProfile($this->colorProfileData())); + + $this->assertSame($this->colorProfileData(), $this->reread($handler)->getColorProfile()?->getData()); + } + + public function testColorProfileIsAddedToAnEncodedImage(): void + { + $handler = $this->encoded(); + $handler->setColorProfile(new ColorProfile($this->colorProfileData('lab'))); + + $reread = $this->reread($handler); + + $this->assertSame($this->colorProfileData('lab'), $reread->getColorProfile()?->getData()); + $this->assertSame(64, $reread->getInfo()->width()); + $this->assertTrue($reread->getInfo()->hasAlphaChannel()); + } + + public function testColorProfileReplacesTheExistingOne(): void + { + $handler = $this->handler(AvifFile::create()->withColorProfile($this->colorProfileData())); + $handler->setColorProfile(new ColorProfile($this->colorProfileData('lab'))); + + $this->assertSame($this->colorProfileData('lab'), $this->reread($handler)->getColorProfile()?->getData()); + $this->assertSame(1, substr_count($handler->getData(), 'colr')); + } + + public function testColorProfileKeepsTheAssociationsOfTheOtherProperties(): void + { + $handler = $this->handler(AvifFile::create()->withAlpha()->withColorProfile($this->colorProfileData())); + $handler->setColorProfile(new ColorProfile($this->colorProfileData('lab'))); + + $info = $this->reread($handler)->getInfo(); + + $this->assertSame(64, $info->width()); + $this->assertSame(48, $info->height()); + $this->assertTrue($info->hasAlphaChannel()); + } + + public function testColorProfileIsRemoved(): void + { + $handler = $this->handler(AvifFile::create()->withColorProfile($this->colorProfileData())); + $handler->removeColorProfile(); + + $this->assertFalse($handler->hasColorProfile()); + $this->assertFalse($this->reread($handler)->hasColorProfile()); + $this->assertStringNotContainsString('colr', $handler->getData()); + } + + public function testColorProfileIsRemovedFromAnEncodedImage(): void + { + $handler = $this->encoded(); + $handler->removeColorProfile(); + + $reread = $this->reread($handler); + + $this->assertFalse($reread->hasColorProfile()); + $this->assertSame('FormworkTest Model-X', $reread->getExifData()?->makeAndModel()); + $this->assertTrue($reread->getInfo()->hasAlphaChannel()); + } + + public function testRemovingAMissingColorProfileLeavesTheImageUntouched(): void + { + $data = AvifFile::create()->build(); + $handler = new AvifHandler($data); + $handler->removeColorProfile(); + + $this->assertSame($data, $handler->getData()); + } + + public function testExifDataIsReadFromAnEncodedImage(): void + { + $handler = $this->encoded(); + + $this->assertTrue($handler->hasExifData()); + $this->assertStringStartsWith('MM', $handler->getExifData()?->getData() ?? ''); + $this->assertSame('FormworkTest Model-X', $handler->getExifData()?->makeAndModel()); + } + + public function testExifDataIsReadAfterTheHeaderOffset(): void + { + $handler = $this->handler(AvifFile::create()->withExif($this->exifData(), 6)); + + $this->assertSame($this->exifData(), $handler->getExifData()?->getData()); + } + + #[DataProvider('incompleteExifItemProvider')] + public function testExifDataIsMissingWhenTheItemIsTooShortToHoldIt(string $data): void + { + $handler = $this->handler(AvifFile::create()->withRawExif($data)); + + $this->assertTrue($handler->hasExifData()); + $this->assertNull($handler->getExifData()); + } + + /** + * @return iterable + */ + public static function incompleteExifItemProvider(): iterable + { + yield 'shorter than the header offset' => ["\x00\x00"]; + yield 'without data after the header offset' => ["\x00\x00\x00\x00"]; + } + + public function testExifDataIsMissingWhenTheItemIsNotDeclared(): void + { + $handler = $this->handler(AvifFile::create()); + + $this->assertFalse($handler->hasExifData()); + $this->assertNull($handler->getExifData()); + } + + #[DataProvider('itemLayoutProvider')] + public function testExifDataIsReadFromAnyItemLayout(AvifFile $file): void + { + $this->assertSame($this->exifData(), $this->handler($file)->getExifData()?->getData()); + } + + /** + * @return iterable + */ + public static function itemLayoutProvider(): iterable + { + $exif = (string) file_get_contents(self::FIXTURES . '/exif.tiff'); + + yield 'item location version 0' => [AvifFile::create()->withExif($exif)]; + yield 'item location version 1' => [AvifFile::create()->withExif($exif)->withLocationVersion(1)]; + yield 'item location version 2' => [AvifFile::create()->withExif($exif)->withLocationVersion(2)]; + yield 'base offsets' => [AvifFile::create()->withExif($exif)->withLocationVersion(1, 4)]; + yield 'extent indexes' => [AvifFile::create()->withExif($exif)->withLocationVersion(2)->withExtentIndexes()]; + yield 'item data box' => [AvifFile::create()->withExif($exif)->withLocationVersion(1)->withItemDataBox()]; + yield 'media data before the metadata' => [AvifFile::create()->withExif($exif)->withMediaDataFirst()]; + yield 'media data with a 64-bit size' => [AvifFile::create()->withExif($exif)->withLargeMediaDataBox()]; + yield 'media data without a size' => [AvifFile::create()->withExif($exif)->withUnsizedMediaDataBox()]; + } + + #[DataProvider('itemLayoutProvider')] + public function testExifDataSurvivesTheRewritingOfAnyItemLayout(AvifFile $file): void + { + $handler = $this->handler($file); + $handler->setColorProfile(new ColorProfile($this->colorProfileData())); + + $this->assertSame($this->exifData(), $this->reread($handler)->getExifData()?->getData()); + } + + public function testExifDataIsAdded(): void + { + $handler = $this->handler(AvifFile::create()); + $handler->setExifData(new ExifData($this->exifData())); + + $reread = $this->reread($handler); + + $this->assertTrue($reread->hasExifData()); + $this->assertSame($this->exifData(), $reread->getExifData()?->getData()); + } + + public function testExifDataIsAddedToAnImageWithoutReferences(): void + { + $handler = $this->handler(AvifFile::create()); + $handler->setExifData(new ExifData($this->exifData())); + + $this->assertStringContainsString('iref', $handler->getData()); + $this->assertStringContainsString('cdsc', $handler->getData()); + } + + public function testExifDataIsAddedToAnImageWithExistingReferences(): void + { + $handler = $this->handler(AvifFile::create()->withAlpha()); + $handler->setExifData(new ExifData($this->exifData())); + + $reread = $this->reread($handler); + + $this->assertSame($this->exifData(), $reread->getExifData()?->getData()); + $this->assertTrue($reread->getInfo()->hasAlphaChannel()); + $this->assertSame(1, substr_count($handler->getData(), 'iref')); + } + + public function testExifDataReplacesTheExistingOne(): void + { + $handler = $this->handler(AvifFile::create()->withExif($this->exifData())); + $handler->setExifData(new ExifData($this->exifData('alternate'))); + + $this->assertSame($this->exifData('alternate'), $this->reread($handler)->getExifData()?->getData()); + $this->assertSame(1, substr_count($handler->getData(), 'Exif')); + } + + public function testExifDataIsRemoved(): void + { + $handler = $this->handler(AvifFile::create()->withExif($this->exifData())); + $handler->removeExifData(); + + $this->assertFalse($handler->hasExifData()); + $this->assertFalse($this->reread($handler)->hasExifData()); + $this->assertStringNotContainsString('cdsc', $handler->getData()); + } + + public function testExifDataIsRemovedFromAnEncodedImage(): void + { + $handler = $this->encoded(); + $handler->removeExifData(); + + $reread = $this->reread($handler); + + $this->assertFalse($reread->hasExifData()); + $this->assertSame($this->colorProfileData(), $reread->getColorProfile()?->getData()); + $this->assertTrue($reread->getInfo()->hasAlphaChannel()); + } + + public function testRemovingMissingExifDataLeavesTheImageUntouched(): void + { + $data = AvifFile::create()->build(); + $handler = new AvifHandler($data); + $handler->removeExifData(); + + $this->assertSame($data, $handler->getData()); + } + + public function testMetadataIsWrittenInASingleRewrite(): void + { + $handler = $this->encoded(); + $handler->setColorProfile(new ColorProfile($this->colorProfileData('lab'))); + $handler->setExifData(new ExifData($this->exifData('alternate'))); + + $reread = $this->reread($handler); + + $this->assertSame($this->colorProfileData('lab'), $reread->getColorProfile()?->getData()); + $this->assertSame($this->exifData('alternate'), $reread->getExifData()?->getData()); + $this->assertSame(64, $reread->getInfo()->width()); + $this->assertTrue($reread->getInfo()->hasAlphaChannel()); + } + + public function testRewritingIsIdempotent(): void + { + $handler = $this->handler(AvifFile::create()->withAlpha()->withExif($this->exifData())); + $handler->setColorProfile(new ColorProfile($this->colorProfileData())); + $once = $handler->getData(); + + $handler->setColorProfile(new ColorProfile($this->colorProfileData())); + + $this->assertSame($once, $handler->getData()); + } + + public function testImageDataIsPreservedByTheRewriting(): void + { + $handler = $this->handler(AvifFile::create()->withAlpha()); + $handler->setColorProfile(new ColorProfile($this->colorProfileData())); + + $this->assertStringContainsString(AvifFile::PRIMARY_DATA, $handler->getData()); + $this->assertStringContainsString(AvifFile::ALPHA_DATA, $handler->getData()); + } + + #[DataProvider('modificationProvider')] + public function testImageSequencesCannotBeModified(string $method): void + { + $handler = $this->handler(AvifFile::create()->asSequence()->withColorProfile($this->colorProfileData())->withExif($this->exifData())); + + $this->expectException(UnsupportedFeatureException::class); + $this->expectExceptionMessage('Modifying AVIF image sequences is not supported'); + + $this->modify($handler, $method); + } + + /** + * @return iterable + */ + public static function modificationProvider(): iterable + { + yield 'set color profile' => ['setColorProfile']; + yield 'remove color profile' => ['removeColorProfile']; + yield 'set Exif data' => ['setExifData']; + yield 'remove Exif data' => ['removeExifData']; + } + + public function testImagesWithoutMetadataCannotBeModified(): void + { + $handler = new AvifHandler(AvifFile::box('ftyp', 'avif' . pack('N', 0) . 'avif')); + + $this->expectException(UnsupportedFeatureException::class); + $this->expectExceptionMessage('AVIF file does not contain a meta box'); + + $handler->setExifData(new ExifData($this->exifData())); + } + + public function testColorProfilesNeedAPrimaryItem(): void + { + $handler = $this->handler(AvifFile::create()->withoutPrimaryItem()); + + $this->expectException(UnsupportedFeatureException::class); + $this->expectExceptionMessage('AVIF file does not define a primary item'); + + $handler->setColorProfile(new ColorProfile($this->colorProfileData())); + } + + public function testColorProfilesNeedTheItemProperties(): void + { + $handler = $this->handler(AvifFile::create()->withoutItemProperties()); + + $this->expectException(UnsupportedFeatureException::class); + $this->expectExceptionMessage('AVIF file does not contain a required "iprp" box'); + + $handler->setColorProfile(new ColorProfile($this->colorProfileData())); + } + + public function testDataIsSetFromAGdImage(): void + { + if (!(gd_info()['AVIF Support'] ?? false)) { + $this->markTestSkipped('The GD extension is built without AVIF support.'); + } + + $image = imagecreatetruecolor(8, 6); + $this->assertNotFalse($image); + + $handler = AvifHandler::fromGdImage($image); + + $this->assertNotSame('', $handler->getData()); + $this->assertSame(8, $handler->getInfo()->width()); + $this->assertSame(6, $handler->getInfo()->height()); + } + + public function testDataIsSetFromAPaletteGdImage(): void + { + if (!(gd_info()['AVIF Support'] ?? false)) { + $this->markTestSkipped('The GD extension is built without AVIF support.'); + } + + $image = imagecreate(8, 6); + $this->assertNotFalse($image); + imagecolorallocate($image, 255, 0, 0); + + $this->assertSame(8, AvifHandler::fromGdImage($image)->getInfo()->width()); + } + + private function encoded(): AvifHandler + { + return AvifHandler::fromPath(self::FIXTURES . '/image.avif'); + } + + private function handler(AvifFile $file): AvifHandler + { + return new AvifHandler($file->build()); + } + + /** + * Parse again the data written by a handler, to read it back the way a decoder would + */ + private function reread(AvifHandler $handler): AvifHandler + { + return new AvifHandler($handler->getData()); + } + + private function colorProfileData(string $name = 'srgb'): string + { + return (string) file_get_contents(self::FIXTURES . '/' . $name . '.icc'); + } + + private function exifData(string $name = 'exif'): string + { + return (string) file_get_contents(self::FIXTURES . '/' . $name . '.tiff'); + } + + private function modify(AvifHandler $handler, string $method): void + { + match ($method) { + 'setColorProfile' => $handler->setColorProfile(new ColorProfile($this->colorProfileData())), + 'removeColorProfile' => $handler->removeColorProfile(), + 'setExifData' => $handler->setExifData(new ExifData($this->exifData())), + default => $handler->removeExifData(), + }; + } +} diff --git a/tests/Unit/Images/Isobmff/BoxTest.php b/tests/Unit/Images/Isobmff/BoxTest.php new file mode 100644 index 000000000..91f6ea3d0 --- /dev/null +++ b/tests/Unit/Images/Isobmff/BoxTest.php @@ -0,0 +1,90 @@ +assertSame(8, $box->headerLength()); + $this->assertSame(15, $box->length()); + $this->assertSame(AvifFile::box('free', 'payload'), $box->encode()); + } + + public function testFullBoxHeadersHoldAVersionAndThreeFlagBytes(): void + { + $this->assertSame("\x01\x00\x00\x00", Box::fullBoxHeader(1)); + $this->assertSame("\x02\x00\x00\x07", Box::fullBoxHeader(2, 7)); + } + + public function testEmptyBoxesOnlyHaveAHeader(): void + { + $box = new Box('free'); + + $this->assertSame(8, $box->length()); + $this->assertSame(pack('N', 8) . 'free', $box->encode()); + } + + public function testBoxesAreTheirOwnGenericRepresentation(): void + { + $box = new Box('free'); + + $this->assertSame($box, $box->toBox()); + } + + public function testSequencesOfBoxesAreParsed(): void + { + $boxes = Box::parseAll(AvifFile::box('abcd', 'first') . AvifFile::box('efgh', 'second')); + + $this->assertCount(2, $boxes); + $this->assertSame('abcd', $boxes[0]->type); + $this->assertSame('first', $boxes[0]->payload); + $this->assertSame('efgh', $boxes[1]->type); + $this->assertSame('second', $boxes[1]->payload); + } + + public function testEmptySequencesHaveNoBoxes(): void + { + $this->assertSame([], Box::parseAll('')); + } + + public function testBoxesWithA64BitSizeAreParsed(): void + { + $boxes = Box::parseAll(AvifFile::largeBox('mdat', 'payload') . AvifFile::box('free')); + + $this->assertCount(2, $boxes); + $this->assertSame('mdat', $boxes[0]->type); + $this->assertSame('payload', $boxes[0]->payload); + $this->assertSame('free', $boxes[1]->type); + } + + public function testBoxesWithoutASizeExtendToTheEndOfTheData(): void + { + $boxes = Box::parseAll(AvifFile::box('free') . AvifFile::unsizedBox('mdat', 'to the end')); + + $this->assertCount(2, $boxes); + $this->assertSame('to the end', $boxes[1]->payload); + } + + public function testSequencesOfBoxesAreEncoded(): void + { + $boxes = [new Box('abcd', 'first'), new Box('efgh', 'second')]; + + $this->assertSame(AvifFile::box('abcd', 'first') . AvifFile::box('efgh', 'second'), Box::encodeAll($boxes)); + } + + public function testEncodingAndParsingAreSymmetrical(): void + { + $boxes = [new Box('abcd', 'first'), new Box('efgh', '')]; + + $this->assertEquals($boxes, Box::parseAll(Box::encodeAll($boxes))); + } +} diff --git a/tests/Unit/Images/Isobmff/ContainerTest.php b/tests/Unit/Images/Isobmff/ContainerTest.php new file mode 100644 index 000000000..4ead96128 --- /dev/null +++ b/tests/Unit/Images/Isobmff/ContainerTest.php @@ -0,0 +1,277 @@ +assertSame('avif', $this->parse(AvifFile::create())->majorBrand()); + $this->assertSame('avis', $this->parse(AvifFile::create()->asSequence())->majorBrand()); + } + + public function testTheMetadataIsFound(): void + { + $container = $this->parse(AvifFile::create()); + + $this->assertInstanceOf(MetaBox::class, $container->meta()); + $this->assertSame($container->meta(), $container->requireMeta()); + } + + public function testTheMissingMetadataIsReported(): void + { + $container = Container::parse(new AvifDecoder(), AvifFile::box('ftyp', 'avif' . pack('N', 0) . 'avif')); + + $this->assertNull($container->meta()); + + $this->expectException(UnsupportedFeatureException::class); + $this->expectExceptionMessage('AVIF file does not contain a meta box'); + + $container->requireMeta(); + } + + public function testItemsAreFoundByType(): void + { + $container = $this->parse(AvifFile::create()->withAlpha()->withExif('TIFF')); + + $this->assertSame([AvifFile::PRIMARY_ID, AvifFile::ALPHA_ID], $container->itemIdsByType('av01')); + $this->assertSame([AvifFile::EXIF_ID], $container->itemIdsByType(ItemInfoEntry::EXIF_TYPE)); + $this->assertSame([], $container->itemIdsByType('mime')); + } + + public function testItemsHaveNoTypeWithoutTheItemInformation(): void + { + $container = Container::parse(new AvifDecoder(), AvifFile::box('ftyp', 'avif' . pack('N', 0) . 'avif')); + + $this->assertSame([], $container->itemIdsByType('av01')); + } + + public function testTheDataOfItemsStoredInTheFileIsRead(): void + { + $container = $this->parse(AvifFile::create()->withAlpha()); + + $this->assertSame(AvifFile::PRIMARY_DATA, $container->itemData(AvifFile::PRIMARY_ID)); + $this->assertSame(AvifFile::ALPHA_DATA, $container->itemData(AvifFile::ALPHA_ID)); + } + + public function testTheDataOfItemsStoredInTheItemDataBoxIsRead(): void + { + $container = $this->parse(AvifFile::create()->withRawExif('in the item data box')->withLocationVersion(1)->withItemDataBox()); + + $this->assertSame('in the item data box', $container->itemData(AvifFile::EXIF_ID)); + } + + public function testTheDataOfUnknownItemsIsNotRead(): void + { + $this->assertNull($this->parse(AvifFile::create())->itemData(99)); + } + + public function testTheDataOfItemsStoredInOtherFilesIsNotRead(): void + { + $container = $this->parse(AvifFile::create()->withRawExif('elsewhere')->withDataReference(AvifFile::EXIF_ID)); + + $this->assertNull($container->itemData(AvifFile::EXIF_ID)); + } + + public function testExtentsWithoutALengthAreNotSupported(): void + { + $container = $this->parse(AvifFile::create()->withRawExif('data')->withExtentOverride(AvifFile::EXIF_ID, length: 0)); + + $this->expectException(UnsupportedFeatureException::class); + $this->expectExceptionMessage('Item extents without an explicit length are not supported'); + + $container->itemData(AvifFile::EXIF_ID); + } + + public function testExtentsBeyondTheAvailableDataAreRejected(): void + { + $container = $this->parse(AvifFile::create()->withRawExif('data')->withExtentOverride(AvifFile::EXIF_ID, offset: 100000)); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Item extent exceeds the available data'); + + $container->itemData(AvifFile::EXIF_ID); + } + + public function testItemsAreAdded(): void + { + $container = $this->parse(AvifFile::create()); + $id = $container->addItem(ItemInfoEntry::EXIF_TYPE, 'added'); + + $this->assertSame(2, $id); + $this->assertSame('added', $container->itemData($id)); + $this->assertSame([$id], $container->itemIdsByType(ItemInfoEntry::EXIF_TYPE)); + } + + public function testAddedItemsAreWrittenToTheFile(): void + { + $container = $this->parse(AvifFile::create()); + $id = $container->addItem(ItemInfoEntry::EXIF_TYPE, 'added'); + + $rewritten = Container::parse(new AvifDecoder(), $container->toString()); + + $this->assertSame('added', $rewritten->itemData($id)); + $this->assertSame(AvifFile::PRIMARY_DATA, $rewritten->itemData(AvifFile::PRIMARY_ID)); + } + + public function testItemsWithoutDataHaveNoExtents(): void + { + $container = $this->parse(AvifFile::create()); + $id = $container->addItem(ItemInfoEntry::EXIF_TYPE, ''); + + $rewritten = Container::parse(new AvifDecoder(), $container->toString()); + + $this->assertSame('', $rewritten->itemData($id)); + $this->assertSame([], $rewritten->requireMeta()->require(ItemLocationBox::class)->find($id)?->extents); + } + + public function testReferencesAreAddedToFilesWithoutThem(): void + { + $container = $this->parse(AvifFile::create()); + $container->addReference(ItemReferenceBox::CONTENT_DESCRIBES, 2, AvifFile::PRIMARY_ID); + + $rewritten = Container::parse(new AvifDecoder(), $container->toString()); + + $this->assertInstanceOf(ItemReferenceBox::class, $rewritten->requireMeta()->find(ItemReferenceBox::class)); + $this->assertStringContainsString(ItemReferenceBox::CONTENT_DESCRIBES, $container->toString()); + } + + public function testReferencesAreAddedToTheExistingBox(): void + { + $container = $this->parse(AvifFile::create()->withAlpha()); + $container->addReference(ItemReferenceBox::CONTENT_DESCRIBES, 4, AvifFile::PRIMARY_ID); + + $this->assertSame(1, substr_count($container->toString(), ItemReferenceBox::TYPE)); + $this->assertStringContainsString('auxl', $container->toString()); + } + + public function testItemsAreRemovedWithTheirDataAndReferences(): void + { + $container = $this->parse(AvifFile::create()->withAlpha()->withRawExif('metadata')); + $container->removeItem(AvifFile::EXIF_ID); + + $rewritten = Container::parse(new AvifDecoder(), $container->toString()); + + $this->assertSame([], $rewritten->itemIdsByType(ItemInfoEntry::EXIF_TYPE)); + $this->assertNull($rewritten->itemData(AvifFile::EXIF_ID)); + $this->assertStringNotContainsString('metadata', $rewritten->toString()); + $this->assertStringNotContainsString(ItemReferenceBox::CONTENT_DESCRIBES, $rewritten->toString()); + } + + public function testRemovedItemsDoNotKeepTheirAddedData(): void + { + $container = $this->parse(AvifFile::create()); + $id = $container->addItem(ItemInfoEntry::EXIF_TYPE, 'added'); + $container->removeItem($id); + + $this->assertStringNotContainsString('added', $container->toString()); + } + + #[DataProvider('layoutProvider')] + public function testTheDataOfTheItemsSurvivesTheRewriting(AvifFile $file): void + { + $rewritten = Container::parse(new AvifDecoder(), $this->parse($file)->toString()); + + $this->assertSame(AvifFile::PRIMARY_DATA, $rewritten->itemData(AvifFile::PRIMARY_ID)); + $this->assertSame(AvifFile::ALPHA_DATA, $rewritten->itemData(AvifFile::ALPHA_ID)); + $this->assertSame('metadata', $rewritten->itemData(AvifFile::EXIF_ID)); + } + + /** + * @return iterable + */ + public static function layoutProvider(): iterable + { + $file = static fn(): AvifFile => AvifFile::create()->withAlpha()->withRawExif('metadata'); + + yield 'media data after the metadata' => [$file()]; + yield 'media data before the metadata' => [$file()->withMediaDataFirst()]; + yield 'media data with a 64-bit size' => [$file()->withLargeMediaDataBox()]; + yield 'media data without a size' => [$file()->withUnsizedMediaDataBox()]; + yield 'item data box' => [$file()->withLocationVersion(1)->withItemDataBox()]; + yield 'base offsets' => [$file()->withLocationVersion(1, 4)]; + yield 'wide item location' => [$file()->withLocationVersion(2)]; + } + + public function testTheRewrittenFileKeepsTheOtherBoxes(): void + { + $rewritten = $this->parse(AvifFile::create()->withFreeSpace())->toString(); + + $this->assertStringContainsString('free-space', $rewritten); + $this->assertSame('avif', Container::parse(new AvifDecoder(), $rewritten)->majorBrand()); + $this->assertSame(AvifFile::PRIMARY_DATA, Container::parse(new AvifDecoder(), $rewritten)->itemData(AvifFile::PRIMARY_ID)); + } + + public function testTheRewritingIsIdempotent(): void + { + $once = $this->parse(AvifFile::create()->withAlpha()->withRawExif('metadata'))->toString(); + $twice = Container::parse(new AvifDecoder(), $once)->toString(); + + $this->assertSame($once, $twice); + } + + public function testImageSequencesExposeTheirTimeline(): void + { + $sequence = $this->parse(AvifFile::create()->withFrames(6, 2))->imageSequence(); + + $this->assertInstanceOf(ImageSequence::class, $sequence); + $this->assertSame(6, $sequence->frameCount); + $this->assertSame(2, $sequence->playCount); + } + + public function testStillImagesHaveNoTimeline(): void + { + $this->assertNull($this->parse(AvifFile::create())->imageSequence()); + } + + public function testImageSequencesCannotBeRewritten(): void + { + $container = $this->parse(AvifFile::create()->asSequence()); + + $this->expectException(UnsupportedFeatureException::class); + $this->expectExceptionMessage('Modifying AVIF image sequences is not supported'); + + $container->toString(); + } + + public function testItemsPointingToOtherFilesAreLeftUntouched(): void + { + $container = $this->parse(AvifFile::create()->withRawExif('elsewhere')->withDataReference(AvifFile::EXIF_ID)); + $before = $container->requireMeta()->require(ItemLocationBox::class)->find(AvifFile::EXIF_ID); + + $rewritten = Container::parse(new AvifDecoder(), $container->toString()); + + $this->assertEquals($before, $rewritten->requireMeta()->require(ItemLocationBox::class)->find(AvifFile::EXIF_ID)); + } + + public function testTheItemInformationIsRequiredToAddItems(): void + { + $container = $this->parse(AvifFile::create()); + $container->requireMeta()->require(ItemInfoBox::class)->remove(AvifFile::PRIMARY_ID); + + $this->assertSame(1, $container->addItem(ItemInfoEntry::EXIF_TYPE, 'added')); + } + + private function parse(AvifFile $file): Container + { + return Container::parse(new AvifDecoder(), $file->build()); + } +} diff --git a/tests/Unit/Images/Isobmff/ImageSequenceTest.php b/tests/Unit/Images/Isobmff/ImageSequenceTest.php new file mode 100644 index 000000000..77a00b073 --- /dev/null +++ b/tests/Unit/Images/Isobmff/ImageSequenceTest.php @@ -0,0 +1,103 @@ +movie(AvifFile::create()->withFrames($frames, $plays)->build())); + + $this->assertSame($frames, $sequence->frameCount); + $this->assertSame($plays, $sequence->playCount); + } + + /** + * @return iterable + */ + public static function timelineProvider(): iterable + { + yield 'played once' => [3, 1]; + yield 'played three times' => [3, 3]; + yield 'looping forever' => [7, 0]; + yield 'single frame' => [1, 2]; + } + + public function testMoviesWithoutTracksHaveAnUnknownTimeline(): void + { + $sequence = ImageSequence::fromBox(new Box('moov')); + + $this->assertNull($sequence->frameCount); + $this->assertNull($sequence->playCount); + } + + public function testTracksReferencingOthersAreIgnored(): void + { + $auxiliary = AvifFile::box('trak', AvifFile::box('tref', AvifFile::box('auxl', "\x00\x00\x00\x01")) . AvifFile::box('mdia', AvifFile::box('minf', AvifFile::box('stbl', AvifFile::fullBox('stsz', 0, pack('NN', 10, 99)))))); + $movie = $this->movie(AvifFile::create()->withFrames(4)->build()); + $sequence = ImageSequence::fromBox(new Box('moov', $auxiliary . $movie->payload)); + + $this->assertSame(4, $sequence->frameCount); + } + + public function testTracksWithoutEditListArePlayedOnce(): void + { + $stsz = AvifFile::fullBox('stsz', 0, pack('NN', 10, 6)); + $track = AvifFile::box('trak', AvifFile::fullBox('tkhd', 0, str_repeat("\x00", 80)) . AvifFile::box('mdia', AvifFile::box('minf', AvifFile::box('stbl', $stsz)))); + + $sequence = ImageSequence::fromBox(new Box('moov', $track)); + + $this->assertSame(6, $sequence->frameCount); + $this->assertSame(1, $sequence->playCount); + } + + public function testShortDurationFieldsAreRead(): void + { + $header = AvifFile::fullBox('tkhd', 0, pack('NNNNN', 0, 0, 1, 0, 12) . str_repeat("\x00", 60)); + $edits = AvifFile::box('edts', AvifFile::fullBox('elst', 0, pack('N', 1) . pack('NN', 4, 0) . pack('N', 0x10000), 1)); + $track = AvifFile::box('trak', $header . $edits); + + $sequence = ImageSequence::fromBox(new Box('moov', $track)); + + $this->assertNull($sequence->frameCount); + $this->assertSame(3, $sequence->playCount); + } + + public function testTruncatedBoxesLeaveTheTimelineUnknown(): void + { + $track = AvifFile::box('trak', AvifFile::fullBox('tkhd', 1, "\x00\x00") . AvifFile::box('edts', AvifFile::fullBox('elst', 1, "\x00", 1)) . AvifFile::box('mdia', AvifFile::box('minf', AvifFile::box('stbl', AvifFile::fullBox('stsz', 0, "\x00"))))); + + $sequence = ImageSequence::fromBox(new Box('moov', $track)); + + $this->assertNull($sequence->frameCount); + $this->assertNull($sequence->playCount); + } + + public function testMalformedMoviesHaveAnUnknownTimeline(): void + { + $sequence = ImageSequence::fromBox(new Box('moov', "\x00\x00\x00\xff" . 'trak')); + + $this->assertNull($sequence->frameCount); + $this->assertNull($sequence->playCount); + } + + private function movie(string $file): Box + { + foreach (Box::parseAll($file) as $box) { + if ($box->type === 'moov') { + return $box; + } + } + + $this->fail('Missing movie box'); + } +} diff --git a/tests/Unit/Images/Isobmff/ItemInfoBoxTest.php b/tests/Unit/Images/Isobmff/ItemInfoBoxTest.php new file mode 100644 index 000000000..789add27c --- /dev/null +++ b/tests/Unit/Images/Isobmff/ItemInfoBoxTest.php @@ -0,0 +1,157 @@ +box(0, [$this->entry(1, 'av01'), $this->entry(2, 'Exif')])); + + $this->assertSame([1], $box->idsByType('av01')); + $this->assertSame([2], $box->idsByType(ItemInfoEntry::EXIF_TYPE)); + $this->assertSame([], $box->idsByType('mime')); + } + + public function testEntriesAreParsedFromTheWiderVersion(): void + { + $box = ItemInfoBox::fromBox($this->box(1, [$this->entry(1, 'av01')])); + + $this->assertSame([1], $box->idsByType('av01')); + } + + public function testWideIdentifiersAreParsed(): void + { + $box = ItemInfoBox::fromBox($this->box(0, [$this->entry(70000, 'Exif', 3)])); + + $this->assertSame([70000], $box->idsByType(ItemInfoEntry::EXIF_TYPE)); + } + + public function testEntriesOfTheVersionsWithoutATypeAreParsed(): void + { + $entry = ItemInfoEntry::fromBox(new Box(ItemInfoEntry::TYPE, Box::fullBoxHeader(1) . Bytes::pack(4, 2) . Bytes::pack(0, 2) . "name\x00")); + + $this->assertSame(4, $entry->id); + $this->assertNull($entry->type); + } + + public function testUnknownChildrenAreIgnored(): void + { + $payload = Box::fullBoxHeader(0) . Bytes::pack(2, 2) . $this->entry(1, 'av01')->encode() . (new Box('free', 'ignored'))->encode(); + $box = ItemInfoBox::fromBox(new Box(ItemInfoBox::TYPE, $payload)); + + $this->assertSame([1], $box->idsByType('av01')); + $this->assertStringNotContainsString('ignored', $box->toBox()->payload); + } + + /** + * @param list $ids + */ + #[DataProvider('nextIdentifierProvider')] + public function testTheNextIdentifierFollowsTheHighestOne(array $ids, int $expected): void + { + $entries = array_map(fn(int $id): Box => $this->entry($id, 'av01'), $ids); + + $this->assertSame($expected, ItemInfoBox::fromBox($this->box(0, $entries))->nextId()); + } + + /** + * @return iterable, int}> + */ + public static function nextIdentifierProvider(): iterable + { + yield 'no entries' => [[], 1]; + yield 'one entry' => [[1], 2]; + yield 'entries out of order' => [[3, 1, 2], 4]; + yield 'sparse entries' => [[1, 9], 10]; + } + + public function testEntriesAreAddedAndRemoved(): void + { + $box = ItemInfoBox::fromBox($this->box(0, [$this->entry(1, 'av01')])); + $box->add(ItemInfoEntry::create(2, ItemInfoEntry::EXIF_TYPE)); + + $this->assertSame([2], $box->idsByType(ItemInfoEntry::EXIF_TYPE)); + + $box->remove(2); + + $this->assertSame([], $box->idsByType(ItemInfoEntry::EXIF_TYPE)); + $this->assertSame([1], $box->idsByType('av01')); + } + + public function testCreatedEntriesHaveNoName(): void + { + $entry = ItemInfoEntry::create(3, ItemInfoEntry::EXIF_TYPE); + $parsed = ItemInfoEntry::fromBox($entry->toBox()); + + $this->assertSame(3, $parsed->id); + $this->assertSame(ItemInfoEntry::EXIF_TYPE, $parsed->type); + $this->assertStringEndsWith("Exif\x00", $entry->toBox()->payload); + } + + public function testCreatedEntriesUseTheVersionFittingTheirIdentifier(): void + { + $this->assertSame(2, ord(ItemInfoEntry::create(3, 'av01')->toBox()->payload[0])); + $this->assertSame(3, ord(ItemInfoEntry::create(70000, 'av01')->toBox()->payload[0])); + $this->assertSame(70000, ItemInfoEntry::fromBox(ItemInfoEntry::create(70000, 'av01')->toBox())->id); + } + + public function testEncodingKeepsTheEntriesUnchanged(): void + { + $box = ItemInfoBox::fromBox($this->box(0, [$this->entry(1, 'av01'), $this->entry(2, 'Exif')])); + $encoded = ItemInfoBox::fromBox($box->toBox()); + + $this->assertSame([1], $encoded->idsByType('av01')); + $this->assertSame([2], $encoded->idsByType(ItemInfoEntry::EXIF_TYPE)); + $this->assertSame(ItemInfoBox::TYPE, $box->toBox()->type); + } + + public function testTheEntryCountIsUpdatedWhenEncoding(): void + { + $box = ItemInfoBox::fromBox($this->box(0, [$this->entry(1, 'av01')])); + $box->add(ItemInfoEntry::create(2, ItemInfoEntry::EXIF_TYPE)); + + $count = unpack('n', $box->toBox()->payload, 4); + + $this->assertNotFalse($count); + $this->assertSame(2, $count[1]); + } + + /** + * Build an item information entry + */ + private function entry(int $id, string $type, int $version = 2): Box + { + $payload = Box::fullBoxHeader($version) + . Bytes::pack($id, $version >= 3 ? 4 : 2) + . Bytes::pack(0, 2) + . $type + . "\x00"; + + return new Box(ItemInfoEntry::TYPE, $payload); + } + + /** + * Build an item information box with an explicit layout + * + * @param list $entries + */ + private function box(int $version, array $entries): Box + { + $payload = Box::fullBoxHeader($version) + . Bytes::pack(count($entries), $version === 0 ? 2 : 4) + . Box::encodeAll($entries); + + return new Box(ItemInfoBox::TYPE, $payload); + } +} diff --git a/tests/Unit/Images/Isobmff/ItemLocationBoxTest.php b/tests/Unit/Images/Isobmff/ItemLocationBoxTest.php new file mode 100644 index 000000000..035976182 --- /dev/null +++ b/tests/Unit/Images/Isobmff/ItemLocationBoxTest.php @@ -0,0 +1,221 @@ +box(0, [ + ['id' => 1, 'extents' => [[100, 20]]], + ['id' => 7, 'extents' => [[200, 30]]], + ])); + + $this->assertSame([1, 7], array_keys($box->items())); + $this->assertEquals([new Extent(100, 20)], $box->find(1)?->extents); + $this->assertEquals([new Extent(200, 30)], $box->find(7)?->extents); + $this->assertNull($box->find(2)); + } + + public function testItemsWithSeveralExtentsAreParsed(): void + { + $box = ItemLocationBox::fromBox($this->box(0, [['id' => 1, 'extents' => [[10, 2], [20, 3]]]])); + + $this->assertEquals([new Extent(10, 2), new Extent(20, 3)], $box->find(1)?->extents); + } + + public function testBaseOffsetsAreAddedToTheExtentOffsets(): void + { + $box = ItemLocationBox::fromBox($this->box(0, [['id' => 1, 'base' => 1000, 'extents' => [[24, 8]]]], baseOffsetSize: 4)); + + $this->assertEquals([new Extent(1024, 8)], $box->find(1)?->extents); + } + + #[DataProvider('constructionMethodProvider')] + public function testConstructionMethodsTellWhereTheDataIs(int $method, bool $file, bool $idat): void + { + $box = ItemLocationBox::fromBox($this->box(1, [['id' => 1, 'method' => $method, 'extents' => [[0, 4]]]])); + $item = $box->find(1); + + $this->assertInstanceOf(ItemLocation::class, $item); + $this->assertSame($method, $item->constructionMethod); + $this->assertSame($file, $item->isInFile()); + $this->assertSame($idat, $item->isInIdat()); + } + + /** + * @return iterable + */ + public static function constructionMethodProvider(): iterable + { + yield 'in the file' => [0, true, false]; + yield 'in the item data box' => [1, false, true]; + yield 'in another item' => [2, false, false]; + } + + public function testDataInOtherFilesIsNeitherInTheFileNorInTheItemDataBox(): void + { + $box = ItemLocationBox::fromBox($this->box(1, [['id' => 1, 'reference' => 2, 'extents' => [[0, 4]]]])); + $item = $box->find(1); + + $this->assertInstanceOf(ItemLocation::class, $item); + $this->assertFalse($item->isInFile()); + $this->assertFalse($item->isInIdat()); + } + + public function testWideItemIdentifiersAreParsed(): void + { + $box = ItemLocationBox::fromBox($this->box(2, [['id' => 70000, 'extents' => [[8, 4]]]])); + + $this->assertSame([70000], array_keys($box->items())); + } + + public function testExtentIndexesAreParsed(): void + { + $box = ItemLocationBox::fromBox($this->box(1, [['id' => 1, 'extents' => [[8, 4, 3]]]], indexSize: 4)); + $item = $box->find(1); + + $this->assertInstanceOf(ItemLocation::class, $item); + $this->assertSame(3, $item->extents[0]->index); + $this->assertTrue($item->usesIndex()); + } + + public function testExtentsHaveNoIndexWhenTheBoxDoesNotStoreThem(): void + { + $box = ItemLocationBox::fromBox($this->box(0, [['id' => 1, 'extents' => [[8, 4]]]])); + $item = $box->find(1); + + $this->assertInstanceOf(ItemLocation::class, $item); + $this->assertSame(0, $item->extents[0]->index); + $this->assertFalse($item->usesIndex()); + } + + public function testUnsupportedVersionsAreRejected(): void + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Unsupported iloc box version 3'); + + ItemLocationBox::fromBox($this->box(3, [])); + } + + public function testItemsAreAddedAndRemoved(): void + { + $box = ItemLocationBox::fromBox($this->box(0, [['id' => 1, 'extents' => [[8, 4]]]])); + $box->set(new ItemLocation(5, 0, 0, [new Extent(12, 6)])); + + $this->assertSame([1, 5], array_keys($box->items())); + + $box->remove(1); + + $this->assertSame([5], array_keys($box->items())); + + $box->remove(1); + + $this->assertSame([5], array_keys($box->items())); + } + + public function testItemsAreReplaced(): void + { + $box = ItemLocationBox::fromBox($this->box(0, [['id' => 1, 'extents' => [[8, 4]]]])); + $replaced = $box->withItems([2 => new ItemLocation(2)]); + + $this->assertSame([1], array_keys($box->items())); + $this->assertSame([2], array_keys($replaced->items())); + } + + public function testExtentsAreReplacedAndShifted(): void + { + $item = new ItemLocation(1, 0, 0, [new Extent(10, 4, 2)]); + $replaced = $item->withExtents([new Extent(30, 8)]); + + $this->assertEquals([new Extent(30, 8)], $replaced->extents); + $this->assertEquals([new Extent(40, 8)], $replaced->shifted(10)->extents); + $this->assertEquals([new Extent(110, 4, 2)], $item->shifted(100)->extents); + } + + #[DataProvider('roundTripProvider')] + public function testEncodingKeepsTheItemsUnchanged(ItemLocationBox $box): void + { + $encoded = ItemLocationBox::fromBox($box->toBox()); + + $this->assertEquals($box->items(), $encoded->items()); + } + + /** + * @return iterable + */ + public static function roundTripProvider(): iterable + { + yield 'no items' => [new ItemLocationBox()]; + yield 'one item' => [new ItemLocationBox(0, 4, [1 => new ItemLocation(1, 0, 0, [new Extent(100, 20)])])]; + yield 'several extents' => [new ItemLocationBox(0, 4, [1 => new ItemLocation(1, 0, 0, [new Extent(10, 2), new Extent(20, 3)])])]; + yield 'item in the item data box' => [new ItemLocationBox(1, 4, [1 => new ItemLocation(1, 1, 0, [new Extent(0, 20)])])]; + yield 'extent indexes' => [new ItemLocationBox(1, 4, [1 => new ItemLocation(1, 0, 0, [new Extent(0, 20, 3)])])]; + yield 'wide identifiers' => [new ItemLocationBox(2, 4, [70000 => new ItemLocation(70000, 0, 0, [new Extent(0, 20)])])]; + yield '64-bit offsets' => [new ItemLocationBox(0, 8, [1 => new ItemLocation(1, 0, 0, [new Extent(5000000000, 20)])])]; + } + + public function testTheBoxIsEncodedWithItsOwnType(): void + { + $this->assertSame(ItemLocationBox::TYPE, (new ItemLocationBox())->toBox()->type); + } + + public function testWideIdentifiersRaiseTheEncodedVersion(): void + { + $box = new ItemLocationBox(0, 4, [70000 => new ItemLocation(70000, 0, 0, [new Extent(0, 20)])]); + + $this->assertSame(2, ord($box->toBox()->payload[0])); + } + + public function testExtentIndexesRaiseTheEncodedVersion(): void + { + $box = new ItemLocationBox(0, 4, [1 => new ItemLocation(1, 0, 0, [new Extent(10, 4, 7)])]); + $encoded = $box->toBox(); + + $this->assertSame(1, ord($encoded->payload[0])); + $this->assertSame(7, ItemLocationBox::fromBox($encoded)->find(1)?->extents[0]->index); + } + + /** + * Build an item location box with an explicit layout + * + * @param list, method?: int, reference?: int, base?: int}> $items + */ + private function box(int $version, array $items, int $offsetSize = 4, int $lengthSize = 4, int $baseOffsetSize = 0, int $indexSize = 0): Box + { + $wide = $version < 2 ? 2 : 4; + + $payload = Box::fullBoxHeader($version) + . Bytes::pack(($offsetSize << 12) | ($lengthSize << 8) | ($baseOffsetSize << 4) | $indexSize, 2) + . Bytes::pack(count($items), $wide); + + foreach ($items as $item) { + $payload .= Bytes::pack($item['id'], $wide) + . ($version > 0 ? Bytes::pack($item['method'] ?? 0, 2) : '') + . Bytes::pack($item['reference'] ?? 0, 2) + . Bytes::pack($item['base'] ?? 0, $baseOffsetSize) + . Bytes::pack(count($item['extents']), 2); + + foreach ($item['extents'] as $extent) { + $payload .= Bytes::pack($extent[2] ?? 0, $version > 0 ? $indexSize : 0) + . Bytes::pack($extent[0], $offsetSize) + . Bytes::pack($extent[1], $lengthSize); + } + } + + return new Box(ItemLocationBox::TYPE, $payload); + } +} diff --git a/tests/Unit/Images/Isobmff/ItemPropertiesBoxTest.php b/tests/Unit/Images/Isobmff/ItemPropertiesBoxTest.php new file mode 100644 index 000000000..798d4d812 --- /dev/null +++ b/tests/Unit/Images/Isobmff/ItemPropertiesBoxTest.php @@ -0,0 +1,205 @@ +properties([new Box('ispe', 'size'), new Box('colr', 'profile')], [1 => [1, 2]]); + + $this->assertCount(2, $box->all()); + $this->assertSame('ispe', $box->all()[0]->type); + $this->assertSame('colr', $box->all()[1]->type); + } + + public function testPropertiesAreFoundByItem(): void + { + $box = $this->properties([new Box('ispe', 'size'), new Box('colr', 'profile'), new Box('auxC', 'alpha')], [1 => [1, 2], 2 => [3]]); + + $this->assertSame(['ispe', 'colr'], array_column($box->forItem(1), 'type')); + $this->assertSame(['auxC'], array_column($box->forItem(2), 'type')); + $this->assertSame([], $box->forItem(3)); + } + + public function testAssociationsWithMissingPropertiesAreIgnored(): void + { + $box = $this->properties([new Box('ispe', 'size')], [1 => [1, 9]]); + + $this->assertSame(['ispe'], array_column($box->forItem(1), 'type')); + } + + public function testUnknownChildrenAreKept(): void + { + $container = new Box('ipco', Box::encodeAll([new Box('ispe', 'size')])); + $other = new Box('free', 'kept'); + $box = ItemPropertiesBox::fromBox(new Box(ItemPropertiesBox::TYPE, Box::encodeAll([$container, $other]))); + + $this->assertStringContainsString('kept', $box->toBox()->payload); + } + + public function testPropertiesAreAddedAndAssociatedWithAnItem(): void + { + $box = $this->properties([new Box('ispe', 'size')], [1 => [1]]); + $box->add(new Box('colr', 'profile'), 1); + + $this->assertCount(2, $box->all()); + $this->assertSame(['ispe', 'colr'], array_column($box->forItem(1), 'type')); + } + + public function testPropertiesAreAddedToBoxesWithoutAssociations(): void + { + $container = new Box('ipco', Box::encodeAll([new Box('ispe', 'size')])); + $box = ItemPropertiesBox::fromBox(new Box(ItemPropertiesBox::TYPE, Box::encodeAll([$container]))); + $box->add(new Box('colr', 'profile'), 1); + + $this->assertSame(['colr'], array_column($this->reread($box)->forItem(1), 'type')); + } + + public function testPropertiesAreRemovedByPredicate(): void + { + $box = $this->properties([new Box('ispe', 'size'), new Box('colr', 'profile')], [1 => [1, 2]]); + + $this->assertSame(1, $box->removeWhere(static fn(Box $property): bool => $property->type === 'colr')); + $this->assertSame(['ispe'], array_column($box->all(), 'type')); + $this->assertSame(0, $box->removeWhere(static fn(Box $property): bool => $property->type === 'colr')); + } + + public function testRemovingPropertiesRenumbersTheFollowingAssociations(): void + { + $box = $this->properties([new Box('colr', 'profile'), new Box('ispe', 'size'), new Box('auxC', 'alpha')], [1 => [1, 2], 2 => [2, 3]]); + $box->removeWhere(static fn(Box $property): bool => $property->type === 'colr'); + + $reread = $this->reread($box); + + $this->assertSame(['ispe'], array_column($reread->forItem(1), 'type')); + $this->assertSame(['ispe', 'auxC'], array_column($reread->forItem(2), 'type')); + } + + public function testSeveralPropertiesAreRemovedAtOnce(): void + { + $box = $this->properties([new Box('colr', 'first'), new Box('ispe', 'size'), new Box('colr', 'second')], [1 => [1, 2, 3]]); + + $this->assertSame(2, $box->removeWhere(static fn(Box $property): bool => $property->type === 'colr')); + $this->assertSame(['ispe'], array_column($this->reread($box)->forItem(1), 'type')); + } + + public function testEssentialAssociationsAreKept(): void + { + $associations = new PropertyAssociationBox(); + $associations->associate(1, 1, essential: true); + $associations->associate(1, 2); + + $parsed = PropertyAssociationBox::fromBox($associations->toBox()); + $payload = $parsed->toBox()->payload; + + $this->assertSame([1, 2], $parsed->indexesFor(1)); + $this->assertSame(0x81, ord($payload[11]), 'The first property is marked as essential'); + $this->assertSame(0x02, ord($payload[12]), 'The second property is not'); + } + + public function testAssociationsAreParsedFromTheWiderVersion(): void + { + $payload = Box::fullBoxHeader(1) . Bytes::pack(1, 4) . Bytes::pack(70000, 4) . Bytes::pack(1, 1) . Bytes::pack(2, 1); + $box = PropertyAssociationBox::fromBox(new Box(PropertyAssociationBox::TYPE, $payload)); + + $this->assertSame([2], $box->indexesFor(70000)); + $this->assertSame(1, ord($box->toBox()->payload[0])); + } + + public function testWideIdentifiersRaiseTheEncodedVersion(): void + { + $box = new PropertyAssociationBox(); + $box->associate(70000, 1); + + $this->assertSame(1, ord($box->toBox()->payload[0])); + $this->assertSame([1], PropertyAssociationBox::fromBox($box->toBox())->indexesFor(70000)); + } + + #[DataProvider('wideIndexProvider')] + public function testIndexesAreStoredOnTwoBytesWhenNeeded(int $index, bool $wide): void + { + $box = new PropertyAssociationBox(); + $box->associate(1, $index); + + $this->assertSame($wide, (ord($box->toBox()->payload[3]) & 1) === 1); + $this->assertSame([$index], PropertyAssociationBox::fromBox($box->toBox())->indexesFor(1)); + } + + /** + * @return iterable + */ + public static function wideIndexProvider(): iterable + { + yield 'the highest narrow index' => [127, false]; + yield 'the lowest wide index' => [128, true]; + } + + public function testTheWideIndexFlagIsKeptOnceSet(): void + { + $payload = Box::fullBoxHeader(0, 1) . Bytes::pack(1, 4) . Bytes::pack(1, 2) . Bytes::pack(1, 1) . Bytes::pack(2, 2); + $box = PropertyAssociationBox::fromBox(new Box(PropertyAssociationBox::TYPE, $payload)); + + $this->assertSame([2], $box->indexesFor(1)); + $this->assertSame($payload, $box->toBox()->payload); + } + + public function testRemovingAPropertyFromAnItemWithoutAssociationsChangesNothing(): void + { + $box = new PropertyAssociationBox(); + $box->removeProperty(1); + + $this->assertSame([], $box->indexesFor(1)); + $this->assertSame(PropertyAssociationBox::TYPE, $box->toBox()->type); + } + + public function testEncodingKeepsThePropertiesUnchanged(): void + { + $box = $this->properties([new Box('ispe', 'size'), new Box('colr', 'profile')], [1 => [1, 2]]); + $reread = $this->reread($box); + + $this->assertSame(ItemPropertiesBox::TYPE, $box->toBox()->type); + $this->assertEquals($box->all(), $reread->all()); + $this->assertEquals($box->forItem(1), $reread->forItem(1)); + } + + /** + * Build an item properties box holding the given properties and associations + * + * @param list $properties + * @param array> $associations + */ + private function properties(array $properties, array $associations): ItemPropertiesBox + { + $container = new Box('ipco', Box::encodeAll($properties)); + $box = new PropertyAssociationBox(); + + foreach ($associations as $itemId => $indexes) { + foreach ($indexes as $index) { + $box->associate($itemId, $index); + } + } + + return ItemPropertiesBox::fromBox(new Box(ItemPropertiesBox::TYPE, Box::encodeAll([$container, $box]))); + } + + /** + * Encode a box and parse it again, to read it back the way a decoder would + */ + private function reread(ItemPropertiesBox $box): ItemPropertiesBox + { + return ItemPropertiesBox::fromBox($box->toBox()); + } +} diff --git a/tests/Unit/Images/Isobmff/ItemReferenceBoxTest.php b/tests/Unit/Images/Isobmff/ItemReferenceBoxTest.php new file mode 100644 index 000000000..8d669a580 --- /dev/null +++ b/tests/Unit/Images/Isobmff/ItemReferenceBoxTest.php @@ -0,0 +1,134 @@ +box(0, [['cdsc', 3, [1]], ['auxl', 2, [1]]])); + + $this->assertEquals([new ItemReference('cdsc', 3, [1]), new ItemReference('auxl', 2, [1])], $this->references($box)); + } + + public function testReferencesToSeveralItemsAreParsed(): void + { + $box = ItemReferenceBox::fromBox($this->box(0, [['thmb', 4, [1, 2, 3]]])); + + $this->assertSame([4, 1, 2, 3], $this->references($box)[0]->ids()); + } + + public function testReferencesAreParsedFromTheWiderVersion(): void + { + $box = ItemReferenceBox::fromBox($this->box(1, [['cdsc', 70000, [1]]])); + + $this->assertEquals([new ItemReference('cdsc', 70000, [1])], $this->references($box)); + } + + public function testReferencesAreAdded(): void + { + $box = new ItemReferenceBox(); + $box->add(ItemReferenceBox::CONTENT_DESCRIBES, 3, 1); + + $this->assertEquals([new ItemReference('cdsc', 3, [1])], $this->references($box)); + } + + public function testReferencesFromAnItemAreRemoved(): void + { + $box = ItemReferenceBox::fromBox($this->box(0, [['cdsc', 3, [1]], ['auxl', 2, [1]]])); + $box->removeItem(3); + + $this->assertEquals([new ItemReference('auxl', 2, [1])], $this->references($box)); + } + + public function testReferencesToAnItemAreRemoved(): void + { + $box = ItemReferenceBox::fromBox($this->box(0, [['cdsc', 3, [1]]])); + $box->removeItem(1); + + $this->assertSame([], $this->references($box)); + } + + public function testOnlyTheRemovedItemIsDroppedFromAReference(): void + { + $box = ItemReferenceBox::fromBox($this->box(0, [['thmb', 4, [1, 2]]])); + $box->removeItem(2); + + $this->assertEquals([new ItemReference('thmb', 4, [1])], $this->references($box)); + } + + public function testRemovingAnUnreferencedItemChangesNothing(): void + { + $box = ItemReferenceBox::fromBox($this->box(0, [['cdsc', 3, [1]]])); + $box->removeItem(9); + + $this->assertEquals([new ItemReference('cdsc', 3, [1])], $this->references($box)); + } + + public function testEncodingKeepsTheReferencesUnchanged(): void + { + $box = ItemReferenceBox::fromBox($this->box(0, [['cdsc', 3, [1]], ['thmb', 4, [1, 2]]])); + + $this->assertSame(ItemReferenceBox::TYPE, $box->toBox()->type); + $this->assertEquals($this->references($box), $this->references(ItemReferenceBox::fromBox($box->toBox()))); + } + + public function testWideIdentifiersRaiseTheEncodedVersion(): void + { + $box = new ItemReferenceBox(); + $box->add(ItemReferenceBox::CONTENT_DESCRIBES, 70000, 1); + + $this->assertSame(1, ord($box->toBox()->payload[0])); + $this->assertEquals($this->references($box), $this->references(ItemReferenceBox::fromBox($box->toBox()))); + } + + public function testEmptyBoxesAreEncodedWithTheFirstVersion(): void + { + $this->assertSame(0, ord((new ItemReferenceBox())->toBox()->payload[0])); + } + + /** + * Build an item reference box with an explicit layout + * + * @param list}> $references + */ + private function box(int $version, array $references): Box + { + $size = $version === 0 ? 2 : 4; + $children = []; + + foreach ($references as [$type, $from, $to]) { + $payload = Bytes::pack($from, $size) . Bytes::pack(count($to), 2); + + foreach ($to as $id) { + $payload .= Bytes::pack($id, $size); + } + + $children[] = new Box($type, $payload); + } + + return new Box(ItemReferenceBox::TYPE, Box::fullBoxHeader($version) . Box::encodeAll($children)); + } + + /** + * Read back the references of a box, which only exposes them once encoded + * + * @return list + */ + private function references(ItemReferenceBox $box): array + { + $size = ord($box->toBox()->payload[0]) === 0 ? 2 : 4; + $children = Box::parseAll(substr($box->toBox()->payload, 4)); + + return array_map(static fn(Box $child): ItemReference => ItemReference::fromBox($child, $size), $children); + } +} diff --git a/tests/Unit/Images/Isobmff/MetaBoxTest.php b/tests/Unit/Images/Isobmff/MetaBoxTest.php new file mode 100644 index 000000000..d61d94b77 --- /dev/null +++ b/tests/Unit/Images/Isobmff/MetaBoxTest.php @@ -0,0 +1,144 @@ +meta([ + new Box('hdlr', 'handler'), + (new ItemInfoBox())->toBox(), + (new ItemLocationBox())->toBox(), + new Box(ItemPropertiesBox::TYPE, Box::encodeAll([new Box('ipco', '')])), + (new ItemReferenceBox())->toBox(), + ]); + + $this->assertInstanceOf(ItemInfoBox::class, $meta->find(ItemInfoBox::class)); + $this->assertInstanceOf(ItemLocationBox::class, $meta->find(ItemLocationBox::class)); + $this->assertInstanceOf(ItemPropertiesBox::class, $meta->find(ItemPropertiesBox::class)); + $this->assertInstanceOf(ItemReferenceBox::class, $meta->find(ItemReferenceBox::class)); + } + + public function testMissingChildrenAreNotFound(): void + { + $this->assertNull($this->meta([])->find(ItemInfoBox::class)); + } + + /** + * @param Closure(MetaBox): mixed $require + */ + #[DataProvider('requiredChildProvider')] + public function testRequiredChildrenAreReported(Closure $require, string $type): void + { + $this->expectException(UnsupportedFeatureException::class); + $this->expectExceptionMessage(sprintf('AVIF file does not contain a required "%s" box', $type)); + + $require($this->meta([])); + } + + /** + * @return iterable + */ + public static function requiredChildProvider(): iterable + { + yield 'item information' => [static fn(MetaBox $meta) => $meta->require(ItemInfoBox::class), 'iinf']; + yield 'item location' => [static fn(MetaBox $meta) => $meta->require(ItemLocationBox::class), 'iloc']; + yield 'item properties' => [static fn(MetaBox $meta) => $meta->require(ItemPropertiesBox::class), 'iprp']; + } + + public function testChildrenAreAdded(): void + { + $meta = $this->meta([new Box('hdlr', 'handler')]); + $meta->add(new ItemReferenceBox()); + + $this->assertInstanceOf(ItemReferenceBox::class, $meta->find(ItemReferenceBox::class)); + $this->assertStringContainsString(ItemReferenceBox::TYPE, $meta->toBox()->payload); + } + + public function testThePrimaryItemIsRead(): void + { + $meta = $this->meta([new Box('pitm', Box::fullBoxHeader(0) . Bytes::pack(5, 2))]); + + $this->assertSame(5, $meta->primaryItemId()); + $this->assertSame(5, $meta->requirePrimaryItemId()); + } + + public function testTheWiderPrimaryItemIsRead(): void + { + $meta = $this->meta([new Box('pitm', Box::fullBoxHeader(1) . Bytes::pack(70000, 4))]); + + $this->assertSame(70000, $meta->primaryItemId()); + } + + public function testTheMissingPrimaryItemIsReported(): void + { + $this->assertNull($this->meta([])->primaryItemId()); + + $this->expectException(UnsupportedFeatureException::class); + $this->expectExceptionMessage('AVIF file does not define a primary item'); + + $this->meta([])->requirePrimaryItemId(); + } + + public function testTheItemDataIsRead(): void + { + $this->assertSame('data', $this->meta([new Box('idat', 'data')])->idat()); + $this->assertNull($this->meta([])->idat()); + } + + public function testTheItemLocationsAndTheItemDataAreReplaced(): void + { + $locations = new ItemLocationBox(0, 4, [1 => new ItemLocation(1, 0, 0, [new Extent(8, 4)])]); + $meta = $this->meta([new Box('hdlr', 'handler'), (new ItemLocationBox())->toBox(), new Box('idat', 'old')]); + + $replaced = $meta->withItemData($locations, 'new'); + + $this->assertSame([1], array_keys($replaced->require(ItemLocationBox::class)->items())); + $this->assertSame('new', $replaced->idat()); + $this->assertSame([], $meta->require(ItemLocationBox::class)->items()); + } + + public function testTheItemDataBoxIsDroppedWhenItBecomesEmpty(): void + { + $meta = $this->meta([(new ItemLocationBox())->toBox(), new Box('idat', 'old')]); + + $this->assertNull($meta->withItemData(new ItemLocationBox(), '')->idat()); + } + + public function testEncodingKeepsTheHeaderAndTheChildren(): void + { + $children = [new Box('hdlr', 'handler'), new Box('free', 'kept')]; + $box = $this->meta($children)->toBox(); + + $this->assertSame(MetaBox::TYPE, $box->type); + $this->assertSame(Box::fullBoxHeader(0), substr($box->payload, 0, 4)); + $this->assertEquals($children, Box::parseAll(substr($box->payload, 4))); + } + + /** + * Build a meta box holding the given children + * + * @param list $children + */ + private function meta(array $children): MetaBox + { + return MetaBox::fromBox(new Box(MetaBox::TYPE, Box::fullBoxHeader(0) . Box::encodeAll($children))); + } +} From 53be19a9d3477af732e73ab8cf60bd5b9d2d4d25 Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Sat, 31 Oct 2026 10:22:11 +0100 Subject: [PATCH 04/10] Make the PNG decoder and handler robust The decoder returned truncated chunks and kept reading after the end chunk. The handler duplicated the color profile and the Exif chunk when they were set on an image already holding one, and a corrupt profile raised PHP warnings. Read chunks through ByteReader, so that truncated data is rejected, and stop at IEND. Replace existing iCCP and eXIf chunks instead of adding new ones, give profiles without a name a valid one and report invalid compressed profiles with an exception. Add GdImages, which encodes small real images for the handler tests. Co-Authored-By: Claude Sonnet 5.5 --- formwork/src/Images/Decoder/PngDecoder.php | 39 ++- formwork/src/Images/Handler/PngHandler.php | 113 +++++---- tests/Unit/Images/Decoder/PngDecoderTest.php | 82 +++++++ tests/Unit/Images/Fixtures/GdImages.php | 95 +++++++ tests/Unit/Images/Handler/PngHandlerTest.php | 246 +++++++++++++++++++ 5 files changed, 510 insertions(+), 65 deletions(-) create mode 100644 tests/Unit/Images/Decoder/PngDecoderTest.php create mode 100644 tests/Unit/Images/Fixtures/GdImages.php create mode 100644 tests/Unit/Images/Handler/PngHandlerTest.php diff --git a/formwork/src/Images/Decoder/PngDecoder.php b/formwork/src/Images/Decoder/PngDecoder.php index 8d55cad99..8c9e33f2c 100644 --- a/formwork/src/Images/Decoder/PngDecoder.php +++ b/formwork/src/Images/Decoder/PngDecoder.php @@ -2,9 +2,9 @@ namespace Formwork\Images\Decoder; +use Formwork\Images\Binary\ByteReader; use Generator; use InvalidArgumentException; -use UnexpectedValueException; final class PngDecoder implements DecoderInterface { @@ -13,39 +13,38 @@ final class PngDecoder implements DecoderInterface */ private const string PNG_HEADER = "\x89PNG\x0d\x0a\x1a\x0a"; + /** + * Type of the last chunk of a PNG file + */ + private const string END_CHUNK = 'IEND'; + public function decode(string &$data): Generator { if (!str_starts_with($data, self::PNG_HEADER)) { throw new InvalidArgumentException('Invalid PNG data'); } - $position = strlen(self::PNG_HEADER); + $reader = new ByteReader($data, strlen(self::PNG_HEADER)); - while ($position < strlen($data)) { - $offset = $position; - $size = $this->unpack('N', $data, $position)[1]; - $position += 4; - $type = substr($data, $position, 4); - $position += $size + 4; - $checksum = $this->unpack('N', $data, $position)[1]; - $position += 4; + while ($reader->remaining() > 0) { + $offset = $reader->position(); + $size = $reader->uint(4); + $type = $reader->bytes(4); + $value = $reader->bytes($size); + $checksum = $reader->uint(4); yield [ 'offset' => $offset, 'size' => $size, 'type' => $type, - 'value' => substr($data, $offset + 8, $size), + 'value' => $value, 'checksum' => $checksum, - 'position' => &$position, + 'position' => $reader->position(), ]; - } - } - /** - * @return array - */ - private function unpack(string $format, string $string, int $offset = 0): array - { - return unpack($format, $string, $offset) ?: throw new UnexpectedValueException('Cannot unpack string'); + if ($type === self::END_CHUNK) { + return; + } + } } } diff --git a/formwork/src/Images/Handler/PngHandler.php b/formwork/src/Images/Handler/PngHandler.php index 9871090b3..cc52e0ee7 100644 --- a/formwork/src/Images/Handler/PngHandler.php +++ b/formwork/src/Images/Handler/PngHandler.php @@ -2,6 +2,8 @@ namespace Formwork\Images\Handler; +use Formwork\Images\Binary\ByteReader; +use Formwork\Images\Binary\Bytes; use Formwork\Images\ColorProfile\ColorProfile; use Formwork\Images\ColorProfile\ColorSpace; use Formwork\Images\Decoder\PngDecoder; @@ -13,6 +15,21 @@ final class PngHandler extends AbstractHandler { + /** + * Minimum size of the IHDR chunk + */ + private const int IHDR_SIZE = 13; + + /** + * Maximum length of the name of an embedded color profile + */ + private const int MAX_PROFILE_NAME_LENGTH = 79; + + /** + * Name used for embedded color profiles without a name + */ + private const string DEFAULT_PROFILE_NAME = 'ICC profile'; + public function getInfo(): ImageInfo { $info = [ @@ -30,10 +47,13 @@ public function getInfo(): ImageInfo foreach ($this->decoder->decode($this->data) as $chunk) { if ($chunk['type'] === 'IHDR') { - $info['width'] = $this->unpack('N', $chunk['value'], 0)[1]; - $info['height'] = $this->unpack('N', $chunk['value'], 4)[1]; - $info['colorDepth'] = ord($chunk['value'][8]); - [$info['colorSpace'], $info['hasAlphaChannel']] = $this->getColorSpaceAndAlpha(ord($chunk['value'][9])); + if ($chunk['size'] < self::IHDR_SIZE) { + throw new UnexpectedValueException('Invalid IHDR chunk'); + } + $info['width'] = Bytes::unpack($chunk['value'], 0, 4); + $info['height'] = Bytes::unpack($chunk['value'], 4, 4); + $info['colorDepth'] = Bytes::unpack($chunk['value'], 8, 1); + [$info['colorSpace'], $info['hasAlphaChannel']] = $this->getColorSpaceAndAlpha(Bytes::unpack($chunk['value'], 9, 1)); } if ($chunk['type'] === 'PLTE') { @@ -45,8 +65,8 @@ public function getInfo(): ImageInfo if ($chunk['type'] === 'acTL') { $info['isAnimation'] = true; - $info['animationFrames'] = $this->unpack('N', $chunk['value'], 0)[1]; - $info['animationRepeatCount'] = $this->unpack('N', $chunk['value'], 4)[1]; + $info['animationFrames'] = Bytes::unpack($chunk['value'], 0, 4); + $info['animationRepeatCount'] = Bytes::unpack($chunk['value'], 4, 4); } } @@ -88,23 +108,13 @@ public function getColorProfile(): ?ColorProfile public function setColorProfile(ColorProfile $colorProfile): void { - foreach ($this->decoder->decode($this->data) as $chunk) { - if ($chunk['type'] === 'IHDR') { - $iCCPChunk = $this->encodeChunk('iCCP', $this->encodeProfile($colorProfile->name(), $colorProfile->getData())); - $this->data = substr_replace($this->data, $iCCPChunk, $chunk['position'], 0); - break; - } - } + $chunk = $this->encodeChunk('iCCP', $this->encodeProfile($colorProfile->name(), $colorProfile->getData())); + $this->replaceChunks('iCCP', $chunk); } public function removeColorProfile(): void { - foreach ($this->decoder->decode($this->data) as $chunk) { - if ($chunk['type'] === 'iCCP') { - $this->data = substr_replace($this->data, '', $chunk['offset'], $chunk['position'] - $chunk['offset']); - break; - } - } + $this->replaceChunks('iCCP'); } public static function supportsExifData(): bool @@ -136,23 +146,12 @@ public function getExifData(): ?ExifData public function setExifData(ExifData $exifData): void { - foreach ($this->decoder->decode($this->data) as $chunk) { - if ($chunk['type'] === 'IHDR') { - $iCCPChunk = $this->encodeChunk('eXIf', $exifData->getData()); - $this->data = substr_replace($this->data, $iCCPChunk, $chunk['position'], 0); - break; - } - } + $this->replaceChunks('eXIf', $this->encodeChunk('eXIf', $exifData->getData())); } public function removeExifData(): void { - foreach ($this->decoder->decode($this->data) as $chunk) { - if ($chunk['type'] === 'eXIf') { - $this->data = substr_replace($this->data, '', $chunk['offset'], $chunk['position'] - $chunk['offset']); - break; - } - } + $this->replaceChunks('eXIf'); } protected function getDecoder(): PngDecoder @@ -198,6 +197,31 @@ private function encodeChunk(string $name, string $data): string return pack('N', strlen($data)) . $name . $data . pack('N', crc32($name . $data)); } + /** + * Replace all the chunks of a given type with a new one placed right after the header chunk + */ + private function replaceChunks(string $type, ?string $replacement = null): void + { + $replacements = []; + $headerEnd = null; + + foreach ($this->decoder->decode($this->data) as $chunk) { + if ($chunk['type'] === 'IHDR') { + $headerEnd = $chunk['position']; + } + + if ($chunk['type'] === $type) { + $replacements[] = [$chunk['offset'], $chunk['position'] - $chunk['offset'], '']; + } + } + + if ($replacement !== null) { + $replacements[] = [$headerEnd ?? throw new UnexpectedValueException('Missing IHDR chunk'), 0, $replacement]; + } + + $this->data = Bytes::replace($this->data, $replacements); + } + /** * Decode a PNG color profile chunk * @@ -209,9 +233,16 @@ private function decodeProfile(string $data): array throw new RuntimeException(sprintf('%s() requires the extension "zlib" to be enabled', __METHOD__)); } - $name = $this->unpack('Z*', $data)[1]; - $value = gzuncompress(substr($data, strlen($name) + 2)) - ?: throw new UnexpectedValueException('Invalid color profile string'); + $reader = new ByteReader($data); + $name = $reader->cString(); + $reader->skip(1); + + $value = @gzuncompress($reader->rest()); + + if ($value === false || $value === '') { + throw new UnexpectedValueException('Invalid color profile string'); + } + return ['name' => $name, 'value' => $value]; } @@ -224,16 +255,8 @@ private function encodeProfile(string $name, string $value): string throw new RuntimeException(sprintf('%s() requires the extension "zlib" to be enabled', __METHOD__)); } - return trim($name) . "\x0\x0" . gzcompress($value); - } + $name = substr(trim($name), 0, self::MAX_PROFILE_NAME_LENGTH); - /** - * Unpack data from binary string - * - * @return array - */ - private function unpack(string $format, string $string, int $offset = 0): array - { - return unpack($format, $string, $offset) ?: throw new UnexpectedValueException('Cannot unpack string'); + return ($name === '' ? self::DEFAULT_PROFILE_NAME : $name) . "\x00\x00" . gzcompress($value); } } diff --git a/tests/Unit/Images/Decoder/PngDecoderTest.php b/tests/Unit/Images/Decoder/PngDecoderTest.php new file mode 100644 index 000000000..cdbed145d --- /dev/null +++ b/tests/Unit/Images/Decoder/PngDecoderTest.php @@ -0,0 +1,82 @@ +decode($data), false); + + $this->assertSame(['IHDR', 'tEXt', 'IEND'], array_column($chunks, 'type')); + $this->assertSame(['header', 'ab', ''], array_column($chunks, 'value')); + $this->assertSame([8, 26, 40], array_column($chunks, 'offset')); + $this->assertSame([26, 40, 52], array_column($chunks, 'position')); + $this->assertSame(crc32('IHDRheader'), $chunks[0]['checksum']); + } + + public function testDataAfterTheEndChunkIsIgnored(): void + { + $data = self::HEADER . GdImages::pngChunk('IEND', '') . 'trailing garbage'; + + $this->assertCount(1, iterator_to_array((new PngDecoder())->decode($data))); + } + + public function testRealImagesAreDecoded(): void + { + $data = GdImages::png(); + + $types = array_column(iterator_to_array((new PngDecoder())->decode($data), false), 'type'); + + $this->assertSame('IHDR', $types[0]); + $this->assertSame('IEND', end($types)); + } + + public function testInvalidHeadersAreNotDecoded(): void + { + $data = 'not a PNG image'; + + $this->expectException(InvalidArgumentException::class); + + iterator_to_array((new PngDecoder())->decode($data)); + } + + public function testTruncatedChunkHeadersAreNotDecoded(): void + { + $data = self::HEADER . "\x00\x00"; + + $this->expectException(UnexpectedValueException::class); + + iterator_to_array((new PngDecoder())->decode($data)); + } + + public function testChunksLongerThanTheDataAreNotDecoded(): void + { + $data = self::HEADER . pack('N', 100) . 'tEXtshort'; + + $this->expectException(UnexpectedValueException::class); + + iterator_to_array((new PngDecoder())->decode($data)); + } + + public function testChunksWithoutChecksumAreNotDecoded(): void + { + $data = substr(self::HEADER . GdImages::pngChunk('tEXt', 'ab'), 0, -2); + + $this->expectException(UnexpectedValueException::class); + + iterator_to_array((new PngDecoder())->decode($data)); + } +} diff --git a/tests/Unit/Images/Fixtures/GdImages.php b/tests/Unit/Images/Fixtures/GdImages.php new file mode 100644 index 000000000..4e357a2b7 --- /dev/null +++ b/tests/Unit/Images/Fixtures/GdImages.php @@ -0,0 +1,95 @@ + imagepng($image), $width, $height); + } + + public static function jpeg(int $width = 40, int $height = 30): string + { + return self::encode(static fn (GdImage $image): bool => imagejpeg($image), $width, $height); + } + + public static function gif(int $width = 40, int $height = 30): string + { + return self::encode(static fn (GdImage $image): bool => imagegif($image), $width, $height); + } + + public static function webp(int $width = 40, int $height = 30): string + { + return self::encode(static fn (GdImage $image): bool => imagewebp($image), $width, $height); + } + + /** + * Encode a PNG chunk + */ + public static function pngChunk(string $type, string $data): string + { + return pack('N', strlen($data)) . $type . $data . pack('N', crc32($type . $data)); + } + + /** + * Encode a WebP chunk + */ + public static function webpChunk(string $type, string $data): string + { + return $type . pack('V', strlen($data)) . $data . (strlen($data) % 2 !== 0 ? "\x00" : ''); + } + + /** + * Encode a WebP file + */ + public static function webpFile(string ...$chunks): string + { + $body = 'WEBP' . implode('', $chunks); + + return 'RIFF' . pack('V', strlen($body)) . $body; + } + + /** + * Encode a JPEG segment + */ + public static function jpegSegment(int $type, string $data): string + { + return "\xff" . chr($type) . pack('n', strlen($data) + 2) . $data; + } + + /** + * @param callable(GdImage): bool $encoder + */ + private static function encode(callable $encoder, int $width, int $height): string + { + ob_start(); + + if (!$encoder(self::image($width, $height))) { + ob_end_clean(); + throw new RuntimeException('Cannot encode image'); + } + + return (string) ob_get_clean(); + } +} diff --git a/tests/Unit/Images/Handler/PngHandlerTest.php b/tests/Unit/Images/Handler/PngHandlerTest.php new file mode 100644 index 000000000..ebc7a7982 --- /dev/null +++ b/tests/Unit/Images/Handler/PngHandlerTest.php @@ -0,0 +1,246 @@ +getInfo(); + + $this->assertSame('image/png', $info->mimeType()); + $this->assertSame(40, $info->width()); + $this->assertSame(30, $info->height()); + $this->assertSame(ColorSpace::RGB, $info->colorSpace()); + $this->assertSame(8, $info->colorDepth()); + $this->assertTrue($info->hasAlphaChannel()); + $this->assertFalse($info->isAnimation()); + $this->assertNull($info->colorNumber()); + } + + public function testPaletteImagesHaveAColorNumber(): void + { + $image = imagecreate(8, 8); + imagecolorallocate($image, 0, 0, 0); + imagecolorallocate($image, 255, 0, 0); + imagecolorallocate($image, 0, 255, 0); + ob_start(); + imagepng($image); + $data = (string) ob_get_clean(); + + $info = (new PngHandler($data))->getInfo(); + + $this->assertSame(ColorSpace::Palette, $info->colorSpace()); + $this->assertSame(3, $info->colorNumber()); + } + + public function testAnimationsAreRecognized(): void + { + $data = $this->withChunk(GdImages::png(), 'acTL', pack('NN', 3, 2)); + + $handler = new PngHandler($data); + + $this->assertTrue($handler->getInfo()->isAnimation()); + $this->assertSame(3, $handler->getInfo()->animationFrames()); + $this->assertSame(2, $handler->getInfo()->animationRepeatCount()); + $this->assertFalse($handler->supportsTransforms()); + } + + public function testTruncatedHeaderChunksAreNotAccepted(): void + { + $handler = new PngHandler(self::HEADER . GdImages::pngChunk('IHDR', "\x00\x00\x00\x01") . GdImages::pngChunk('IEND', '')); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Invalid IHDR chunk'); + + $handler->getInfo(); + } + + public function testInvalidPalettesAreNotAccepted(): void + { + $handler = new PngHandler($this->withChunk(GdImages::png(), 'PLTE', "\x00\x00\x00\x00")); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Invalid palette size'); + + $handler->getInfo(); + } + + public function testColorProfilesAreSetAndRead(): void + { + $handler = new PngHandler(GdImages::png()); + + $this->assertFalse($handler->hasColorProfile()); + $this->assertNull($handler->getColorProfile()); + + $handler->setColorProfile($this->colorProfile()); + + $this->assertTrue($handler->hasColorProfile()); + $this->assertSame($this->colorProfile()->getData(), $handler->getColorProfile()?->getData()); + $this->assertSame('sRGB built-in', $handler->getColorProfile()->name()); + $this->assertDecodable($handler); + } + + public function testColorProfilesAreReplacedInsteadOfDuplicated(): void + { + $handler = new PngHandler(GdImages::png()); + + $handler->setColorProfile($this->colorProfile()); + $handler->setColorProfile($this->colorProfile()); + + $this->assertSame(1, $this->countChunks($handler->getData(), 'iCCP')); + } + + public function testColorProfilesAreRemoved(): void + { + $handler = new PngHandler(GdImages::png()); + $handler->setColorProfile($this->colorProfile()); + + $handler->removeColorProfile(); + + $this->assertFalse($handler->hasColorProfile()); + $this->assertSame(GdImages::png(), $handler->getData()); + } + + public function testRemovingAMissingColorProfileLeavesTheDataUntouched(): void + { + $handler = new PngHandler(GdImages::png()); + + $handler->removeColorProfile(); + + $this->assertSame(GdImages::png(), $handler->getData()); + } + + public function testInvalidColorProfilesAreNotAccepted(): void + { + $handler = new PngHandler($this->withChunk(GdImages::png(), 'iCCP', "name\x00\x00not compressed")); + + $this->assertTrue($handler->hasColorProfile()); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Invalid color profile string'); + + $handler->getColorProfile(); + } + + public function testUnterminatedColorProfileNamesAreNotAccepted(): void + { + $handler = new PngHandler($this->withChunk(GdImages::png(), 'iCCP', 'name')); + + $this->expectException(UnexpectedValueException::class); + + $handler->getColorProfile(); + } + + public function testExifDataIsSetAndRead(): void + { + $handler = new PngHandler(GdImages::png()); + + $this->assertFalse($handler->hasExifData()); + $this->assertNull($handler->getExifData()); + + $handler->setExifData($this->exifData()); + + $this->assertTrue($handler->hasExifData()); + $this->assertSame($this->exifData()->getData(), $handler->getExifData()?->getData()); + $this->assertDecodable($handler); + } + + public function testExifDataIsReplacedInsteadOfDuplicated(): void + { + $handler = new PngHandler(GdImages::png()); + + $handler->setExifData($this->exifData()); + $handler->setExifData($this->exifData()); + + $this->assertSame(1, $this->countChunks($handler->getData(), 'eXIf')); + } + + public function testExifDataIsRemoved(): void + { + $handler = new PngHandler(GdImages::png()); + $handler->setExifData($this->exifData()); + + $handler->removeExifData(); + + $this->assertFalse($handler->hasExifData()); + $this->assertSame(GdImages::png(), $handler->getData()); + } + + public function testMetadataIsPreservedWhenConvertingToAnotherFormat(): void + { + $handler = new PngHandler(GdImages::png()); + $handler->setColorProfile($this->colorProfile()); + $handler->setExifData($this->exifData()); + + $converted = $handler->process(null, JpegHandler::class); + + $this->assertInstanceOf(JpegHandler::class, $converted); + $this->assertTrue($converted->hasColorProfile()); + $this->assertTrue($converted->hasExifData()); + $this->assertSame(40, $converted->getInfo()->width()); + } + + public function testImagesAreCreatedFromGdImages(): void + { + $handler = PngHandler::fromGdImage(GdImages::image(16, 12)); + + $this->assertSame(16, $handler->getInfo()->width()); + $this->assertSame(12, $handler->getInfo()->height()); + $this->assertTrue($handler->getInfo()->hasAlphaChannel()); + } + + public function testDataFromAnotherFormatIsNotAccepted(): void + { + $handler = new PngHandler(GdImages::jpeg()); + + $this->expectException(\InvalidArgumentException::class); + + $handler->getInfo(); + } + + private function colorProfile(): ColorProfile + { + return new ColorProfile((string) file_get_contents(self::FIXTURES . '/srgb.icc')); + } + + private function exifData(): ExifData + { + return new ExifData((string) file_get_contents(self::FIXTURES . '/exif.tiff')); + } + + /** + * Insert a chunk right after the header chunk + */ + private function withChunk(string $png, string $type, string $data): string + { + $offset = strlen(self::HEADER) + 25; + + return substr($png, 0, $offset) . GdImages::pngChunk($type, $data) . substr($png, $offset); + } + + private function countChunks(string $png, string $type): int + { + return substr_count($png, $type); + } + + private function assertDecodable(PngHandler $handler): void + { + $this->assertNotFalse(@imagecreatefromstring($handler->getData())); + } +} From d027eb0f7493d5de3ea61fdbb3f4b3ae7d7d6461 Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Sat, 31 Oct 2026 10:22:12 +0100 Subject: [PATCH 05/10] Make the JPEG decoder and handler robust Removing a color profile or Exif data edited the image while iterating over its segments, so every segment following a removed one was skipped and profiles split in several segments were left half removed. Setting them added a second copy, before the JFIF segment, and Exif data longer than a segment was silently truncated. Require the start of image marker, skip marker padding, handle markers without length and stop at the end of image. Collect the changes while iterating and apply them at once, replacing existing segments, placing new ones after the JFIF segment and rejecting data that does not fit in a segment or in the 255 chunks of a profile. Recognize every start of frame marker and incomplete or inconsistent color profiles. Co-Authored-By: Claude Sonnet 5.5 --- formwork/src/Images/Decoder/JpegDecoder.php | 96 +++++-- formwork/src/Images/Handler/JpegHandler.php | 140 ++++++---- tests/Unit/Images/Decoder/JpegDecoderTest.php | 121 +++++++++ tests/Unit/Images/Handler/JpegHandlerTest.php | 251 ++++++++++++++++++ 4 files changed, 539 insertions(+), 69 deletions(-) create mode 100644 tests/Unit/Images/Decoder/JpegDecoderTest.php create mode 100644 tests/Unit/Images/Handler/JpegHandlerTest.php diff --git a/formwork/src/Images/Decoder/JpegDecoder.php b/formwork/src/Images/Decoder/JpegDecoder.php index ee6c122c6..2fb0274f0 100644 --- a/formwork/src/Images/Decoder/JpegDecoder.php +++ b/formwork/src/Images/Decoder/JpegDecoder.php @@ -2,6 +2,7 @@ namespace Formwork\Images\Decoder; +use Formwork\Images\Binary\Bytes; use Generator; use InvalidArgumentException; use UnexpectedValueException; @@ -10,9 +11,15 @@ final class JpegDecoder implements DecoderInterface { public function decode(string &$data): Generator { + $length = strlen($data); + + if (!str_starts_with($data, "\xff\xd8")) { + throw new InvalidArgumentException('Invalid JPEG data'); + } + $position = 0; - while ($position < strlen($data)) { + while ($position < $length) { $position = strpos($data, "\xff", $position); if ($position === false) { @@ -20,16 +27,43 @@ public function decode(string &$data): Generator } $offset = $position; - $position += 1; + $position++; + + // Any number of 0xff bytes can pad a marker + while ($position < $length && $data[$position] === "\xff") { + $position++; + } + + if ($position >= $length) { + throw new UnexpectedValueException('Unexpected end of data'); + } + $type = ord($data[$position]); - $position += 1; + $position++; - if (($type > 0x00 && $type < 0xd0) || ($type > 0xda && $type < 0xff)) { - $size = $this->unpack('n', $data, $position)[1]; - } elseif ($type === 0xda) { - $size = $this->seekSegmentEnd($data, $position) - $position; - } else { + // A stuffed zero byte is not a marker + if ($type === 0x00) { + continue; + } + + if ($this->hasNoLength($type)) { $size = 0; + $value = null; + } else { + $size = Bytes::unpack($data, $position, 2); + + if ($size < 2 || $position + $size > $length) { + throw new UnexpectedValueException('Invalid segment size'); + } + + $value = substr($data, $position + 2, $size - 2); + + // The entropy-coded data following the header of a scan belongs to the segment + if ($type === 0xda) { + $end = $this->seekSegmentEnd($data, $position + $size); + $value .= substr($data, $position + $size, $end - $position - $size); + $size = $end - $position; + } } $position += $size; @@ -38,38 +72,52 @@ public function decode(string &$data): Generator 'offset' => $offset, 'size' => $size, 'type' => $type, - 'value' => $size > 0 ? substr($data, $offset + 4, $size - 2) : null, - 'position' => &$position, + 'value' => $value, + 'position' => $position, ]; + + if ($type === 0xd9) { + return; + } } } + /** + * Whether a marker is not followed by a length field + */ + private function hasNoLength(int $type): bool + { + return $type === 0x01 || ($type >= 0xd0 && $type <= 0xd9); + } + /** * Seek the end of a segment */ private function seekSegmentEnd(string &$data, int $position): int { - while ($position < strlen($data)) { + $length = strlen($data); + + while ($position < $length) { $position = strpos($data, "\xff", $position); + if ($position === false) { - throw new InvalidArgumentException('Invalid JPEG data'); + break; + } + + $position++; + + if ($position >= $length) { + break; } - $position += 1; + $type = ord($data[$position]); - if (($type > 0x00 && $type < 0xd0) || $type > 0xd7) { + + // Stuffed zero bytes and restart markers are part of the scan + if ($type !== 0x00 && ($type < 0xd0 || $type > 0xd7)) { return $position - 1; } } - throw new UnexpectedValueException('Segment end not found'); - } - /** - * Unpack data from a binary string - * - * @return array - */ - private function unpack(string $format, string $string, int $offset = 0): array - { - return unpack($format, $string, $offset) ?: throw new UnexpectedValueException('Cannot unpack string'); + throw new UnexpectedValueException('Segment end not found'); } } diff --git a/formwork/src/Images/Handler/JpegHandler.php b/formwork/src/Images/Handler/JpegHandler.php index 8729ee4b4..1fe18ffcf 100644 --- a/formwork/src/Images/Handler/JpegHandler.php +++ b/formwork/src/Images/Handler/JpegHandler.php @@ -2,6 +2,8 @@ namespace Formwork\Images\Handler; +use Formwork\Images\Binary\ByteReader; +use Formwork\Images\Binary\Bytes; use Formwork\Images\ColorProfile\ColorProfile; use Formwork\Images\ColorProfile\ColorSpace; use Formwork\Images\Decoder\JpegDecoder; @@ -18,6 +20,21 @@ final class JpegHandler extends AbstractHandler */ private const int MAX_BYTES_IN_SEGMENT = 65533; + /** + * Minimum size of a start of frame segment + */ + private const int SOF_SIZE = 6; + + /** + * Maximum number of chunks of an ICC profile + */ + private const int MAX_PROFILE_CHUNKS = 255; + + /** + * JFIF header + */ + private const string JFIF_HEADER = "JFIF\x00"; + /** * Image EXIF header */ @@ -44,14 +61,14 @@ public function getInfo(): ImageInfo ]; foreach ($this->decoder->decode($this->data) as $segment) { - if ( - $segment['type'] > 0xbf && $segment['type'] < 0xc3 - || $segment['type'] > 0xc8 && $segment['type'] < 0xcc - ) { - $info['colorDepth'] = ord($segment['value'][0]); - $info['height'] = $this->unpack('n', $segment['value'], 1)[1]; - $info['width'] = $this->unpack('n', $segment['value'], 3)[1]; - $info['colorSpace'] = $this->getColorSpace(ord($segment['value'][5])); + if ($this->isStartOfFrame($segment['type'])) { + if ($segment['size'] - 2 < self::SOF_SIZE) { + throw new UnexpectedValueException('Invalid start of frame segment'); + } + $info['colorDepth'] = Bytes::unpack($segment['value'], 0, 1); + $info['height'] = Bytes::unpack($segment['value'], 1, 2); + $info['width'] = Bytes::unpack($segment['value'], 3, 2); + $info['colorSpace'] = $this->getColorSpace(Bytes::unpack($segment['value'], 5, 1)); break; } } @@ -86,14 +103,15 @@ public function hasColorProfile(): bool public function getColorProfile(): ?ColorProfile { - $headerLength = strlen(self::ICC_PROFILE_HEADER); $profileChunks = []; $chunkCount = 0; foreach ($this->decoder->decode($this->data) as $segment) { if ($segment['type'] === 0xe2 && str_starts_with($segment['value'], self::ICC_PROFILE_HEADER)) { - [$chunkNum, $chunkCount] = array_values($this->unpack('Cnum/Ccount', $segment['value'], $headerLength)); - $profileChunks[$chunkNum] = substr($segment['value'], $headerLength + 2); + $reader = new ByteReader($segment['value'], strlen(self::ICC_PROFILE_HEADER)); + $chunkNum = $reader->uint(1); + $chunkCount = $reader->uint(1); + $profileChunks[$chunkNum] = $reader->rest(); } } @@ -101,31 +119,23 @@ public function getColorProfile(): ?ColorProfile return null; } - if (count($profileChunks) !== $chunkCount) { + ksort($profileChunks); + + if (array_keys($profileChunks) !== range(1, $chunkCount)) { throw new UnexpectedValueException('Unexpected profile chunk count'); } - ksort($profileChunks); return new ColorProfile(implode('', $profileChunks)); } public function setColorProfile(ColorProfile $colorProfile): void { - foreach ($this->decoder->decode($this->data) as $segment) { - if ($segment['type'] === 0xd8) { - $this->data = substr_replace($this->data, $this->encodeColorProfile($colorProfile->getData()), $segment['position'], 0); - break; - } - } + $this->replaceSegments(0xe2, self::ICC_PROFILE_HEADER, $this->encodeColorProfile($colorProfile->getData())); } public function removeColorProfile(): void { - foreach ($this->decoder->decode($this->data) as $segment) { - if ($segment['type'] === 0xe2 && str_starts_with($segment['value'], self::ICC_PROFILE_HEADER)) { - $this->data = substr_replace($this->data, '', $segment['offset'], $segment['position'] - $segment['offset']); - } - } + $this->replaceSegments(0xe2, self::ICC_PROFILE_HEADER); } public static function supportsExifData(): bool @@ -163,21 +173,12 @@ public function getExifData(): ?ExifData public function setExifData(ExifData $exifData): void { - foreach ($this->decoder->decode($this->data) as $segment) { - if ($segment['type'] === 0xd8) { - $this->data = substr_replace($this->data, $this->encodeExifData($exifData->getData()), $segment['position'], 0); - break; - } - } + $this->replaceSegments(0xe1, self::EXIF_HEADER, $this->encodeExifData($exifData->getData())); } public function removeExifData(): void { - foreach ($this->decoder->decode($this->data) as $segment) { - if ($segment['type'] === 0xe1 && str_starts_with($segment['value'], self::EXIF_HEADER)) { - $this->data = substr_replace($this->data, '', $segment['offset'], $segment['position'] - $segment['offset']); - } - } + $this->replaceSegments(0xe1, self::EXIF_HEADER); } protected function getDecoder(): JpegDecoder @@ -198,6 +199,51 @@ protected function setDataFromGdImage(GdImage $gdImage): void $this->data = ob_get_clean() ?: throw new UnexpectedValueException('Unexpected empty image data'); } + /** + * Whether a marker is a start of frame (SOF) one + */ + private function isStartOfFrame(int $type): bool + { + return $type >= 0xc0 && $type <= 0xcf && !in_array($type, [0xc4, 0xc8, 0xcc], true); + } + + /** + * Replace all the segments of a given type and header with new ones + * + * New segments are placed right after the start of image marker, or after the JFIF + * segment if present, since it is required to immediately follow the former. + */ + private function replaceSegments(int $type, string $header, ?string $replacement = null): void + { + $replacements = []; + $insertPosition = null; + + foreach ($this->decoder->decode($this->data) as $segment) { + if ($segment['type'] === 0xd8) { + $insertPosition = $segment['position']; + continue; + } + + if ($insertPosition === null) { + throw new UnexpectedValueException('Missing start of image marker'); + } + + if ($segment['type'] === 0xe0 && $segment['offset'] === $insertPosition && str_starts_with($segment['value'], self::JFIF_HEADER)) { + $insertPosition = $segment['position']; + } + + if ($segment['type'] === $type && str_starts_with($segment['value'], $header)) { + $replacements[] = [$segment['offset'], $segment['position'] - $segment['offset'], '']; + } + } + + if ($replacement !== null) { + $replacements[] = [$insertPosition ?? throw new UnexpectedValueException('Missing start of image marker'), 0, $replacement]; + } + + $this->data = Bytes::replace($this->data, $replacements); + } + /** * Get color space from number of components */ @@ -220,30 +266,34 @@ private function encodeColorProfile(string $data): string $chunks = str_split($data, $maxChunkSize); $count = count($chunks); + if ($count > self::MAX_PROFILE_CHUNKS) { + throw new UnexpectedValueException('Color profile is too large to be embedded in a JPEG image'); + } + for ($i = 0; $i < $count; $i++) { - $value = self::ICC_PROFILE_HEADER . pack('CC', $i + 1, $count) . $chunks[$i]; - $chunks[$i] = "\xff\xe2" . pack('n', strlen($value) + 2) . $value; + $chunks[$i] = $this->encodeSegment(0xe2, self::ICC_PROFILE_HEADER . Bytes::pack($i + 1, 1) . Bytes::pack($count, 1) . $chunks[$i]); } return implode('', $chunks); } /** - * Encode EXIF data into JPEG segments + * Encode EXIF data into a JPEG segment */ private function encodeExifData(string $data): string { - $value = self::EXIF_HEADER . $data; - return "\xff\xe1" . pack('n', strlen($value) + 2) . $value; + return $this->encodeSegment(0xe1, self::EXIF_HEADER . $data); } /** - * Unpack data from binary string - * - * @return array + * Encode a JPEG segment */ - private function unpack(string $format, string $string, int $offset = 0): array + private function encodeSegment(int $type, string $value): string { - return unpack($format, $string, $offset) ?: throw new UnexpectedValueException('Cannot unpack string'); + if (strlen($value) > self::MAX_BYTES_IN_SEGMENT) { + throw new UnexpectedValueException('Data is too large to be embedded in a JPEG segment'); + } + + return "\xff" . chr($type) . Bytes::pack(strlen($value) + 2, 2) . $value; } } diff --git a/tests/Unit/Images/Decoder/JpegDecoderTest.php b/tests/Unit/Images/Decoder/JpegDecoderTest.php new file mode 100644 index 000000000..76c6cfdb9 --- /dev/null +++ b/tests/Unit/Images/Decoder/JpegDecoderTest.php @@ -0,0 +1,121 @@ +decode($data), false); + + $this->assertSame([0xd8, 0xe1, 0xd9], array_column($segments, 'type')); + $this->assertSame([null, 'abc', null], array_column($segments, 'value')); + $this->assertSame([0, 2, 9], array_column($segments, 'offset')); + $this->assertSame([2, 9, 11], array_column($segments, 'position')); + } + + public function testScansIncludeTheirEntropyCodedData(): void + { + $header = "\x01\x01\x00\x00\x3f\x00"; + $scan = "\x12\xff\x00\x34\xff\xd0\x56"; + $data = "\xff\xd8" . GdImages::jpegSegment(0xda, $header) . $scan . GdImages::jpegSegment(0xfe, 'comment') . "\xff\xd9"; + + $segments = iterator_to_array((new JpegDecoder())->decode($data), false); + + $this->assertSame([0xd8, 0xda, 0xfe, 0xd9], array_column($segments, 'type')); + $this->assertSame($header . $scan, $segments[1]['value']); + $this->assertSame($segments[2]['offset'], $segments[1]['position']); + } + + public function testPaddingBeforeMarkersIsSkipped(): void + { + $data = "\xff\xd8\xff\xff\xff" . chr(0xfe) . pack('n', 4) . 'ab' . "\xff\xd9"; + + $segments = iterator_to_array((new JpegDecoder())->decode($data), false); + + $this->assertSame([0xd8, 0xfe, 0xd9], array_column($segments, 'type')); + $this->assertSame('ab', $segments[1]['value']); + } + + public function testDataAfterTheEndOfImageMarkerIsIgnored(): void + { + $data = "\xff\xd8\xff\xd9" . 'trailing garbage'; + + $this->assertCount(2, iterator_to_array((new JpegDecoder())->decode($data))); + } + + public function testRealImagesAreDecoded(): void + { + $data = GdImages::jpeg(); + + $types = array_column(iterator_to_array((new JpegDecoder())->decode($data), false), 'type'); + + $this->assertSame(0xd8, $types[0]); + $this->assertContains(0xda, $types); + $this->assertSame(0xd9, end($types)); + } + + public function testDataWithoutStartOfImageMarkerIsNotDecoded(): void + { + $data = 'not a JPEG image'; + + $this->expectException(InvalidArgumentException::class); + + iterator_to_array((new JpegDecoder())->decode($data)); + } + + public function testTruncatedMarkersAreNotDecoded(): void + { + $data = "\xff\xd8\xff"; + + $this->expectException(UnexpectedValueException::class); + + iterator_to_array((new JpegDecoder())->decode($data)); + } + + public function testSegmentsLongerThanTheDataAreNotDecoded(): void + { + $data = "\xff\xd8\xff\xe1" . pack('n', 100) . 'short'; + + $this->expectException(UnexpectedValueException::class); + + iterator_to_array((new JpegDecoder())->decode($data)); + } + + public function testSegmentsWithAnInvalidSizeAreNotDecoded(): void + { + $data = "\xff\xd8\xff\xe1\x00\x01\x00"; + + $this->expectException(UnexpectedValueException::class); + + iterator_to_array((new JpegDecoder())->decode($data)); + } + + public function testUnterminatedScansAreNotDecoded(): void + { + $data = "\xff\xd8" . GdImages::jpegSegment(0xda, "\x01\x01") . "\x12\x34"; + + $this->expectException(UnexpectedValueException::class); + + iterator_to_array((new JpegDecoder())->decode($data)); + } + + public function testDataWithoutMarkersAfterTheStartIsNotDecoded(): void + { + $data = "\xff\xd8\x00\x00"; + + $this->expectException(InvalidArgumentException::class); + + iterator_to_array((new JpegDecoder())->decode($data)); + } +} diff --git a/tests/Unit/Images/Handler/JpegHandlerTest.php b/tests/Unit/Images/Handler/JpegHandlerTest.php new file mode 100644 index 000000000..fad5f8b44 --- /dev/null +++ b/tests/Unit/Images/Handler/JpegHandlerTest.php @@ -0,0 +1,251 @@ +getInfo(); + + $this->assertSame('image/jpeg', $info->mimeType()); + $this->assertSame(40, $info->width()); + $this->assertSame(30, $info->height()); + $this->assertSame(ColorSpace::RGB, $info->colorSpace()); + $this->assertSame(8, $info->colorDepth()); + $this->assertFalse($info->hasAlphaChannel()); + $this->assertFalse($info->isAnimation()); + } + + #[DataProvider('frameProvider')] + public function testInfoIsReadFromEveryKindOfFrame(int $marker, int $components, ColorSpace $colorSpace): void + { + $frame = "\x08" . pack('nn', 20, 10) . chr($components) . str_repeat("\x01\x11\x00", $components); + $handler = new JpegHandler("\xff\xd8" . GdImages::jpegSegment($marker, $frame) . "\xff\xd9"); + + $info = $handler->getInfo(); + + $this->assertSame(10, $info->width()); + $this->assertSame(20, $info->height()); + $this->assertSame($colorSpace, $info->colorSpace()); + } + + /** + * @return iterable + */ + public static function frameProvider(): iterable + { + yield 'baseline' => [0xc0, 3, ColorSpace::RGB]; + yield 'extended sequential' => [0xc1, 1, ColorSpace::Grayscale]; + yield 'progressive' => [0xc2, 4, ColorSpace::CMYK]; + yield 'lossless' => [0xc3, 3, ColorSpace::RGB]; + yield 'differential sequential' => [0xc5, 3, ColorSpace::RGB]; + yield 'arithmetic' => [0xc9, 3, ColorSpace::RGB]; + yield 'differential arithmetic' => [0xcd, 3, ColorSpace::RGB]; + } + + public function testTablesAreNotMistakenForFrames(): void + { + $handler = new JpegHandler("\xff\xd8" . GdImages::jpegSegment(0xc4, str_repeat("\x00", 20)) . "\xff\xd9"); + + $this->assertSame(0, $handler->getInfo()->width()); + } + + public function testTruncatedFramesAreNotAccepted(): void + { + $handler = new JpegHandler("\xff\xd8" . GdImages::jpegSegment(0xc0, "\x08\x00") . "\xff\xd9"); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Invalid start of frame segment'); + + $handler->getInfo(); + } + + public function testColorProfilesAreSetAndRead(): void + { + $handler = new JpegHandler(GdImages::jpeg()); + + $this->assertFalse($handler->hasColorProfile()); + $this->assertNull($handler->getColorProfile()); + + $handler->setColorProfile($this->colorProfile()); + + $this->assertTrue($handler->hasColorProfile()); + $this->assertSame($this->colorProfile()->getData(), $handler->getColorProfile()?->getData()); + $this->assertDecodable($handler); + } + + public function testLargeColorProfilesAreSplitAndJoined(): void + { + $profile = new ColorProfile($this->colorProfile()->getData() . random_bytes(150_000)); + $handler = new JpegHandler(GdImages::jpeg()); + + $handler->setColorProfile($profile); + + $this->assertGreaterThan(2, substr_count($handler->getData(), "ICC_PROFILE\x00")); + $this->assertSame($profile->getData(), $handler->getColorProfile()?->getData()); + $this->assertDecodable($handler); + } + + public function testAllTheChunksOfAColorProfileAreRemoved(): void + { + $profile = new ColorProfile($this->colorProfile()->getData() . random_bytes(150_000)); + $handler = new JpegHandler(GdImages::jpeg()); + $handler->setColorProfile($profile); + + $handler->removeColorProfile(); + + $this->assertFalse($handler->hasColorProfile()); + $this->assertSame(GdImages::jpeg(), $handler->getData()); + } + + public function testColorProfilesAreReplacedInsteadOfDuplicated(): void + { + $handler = new JpegHandler(GdImages::jpeg()); + + $handler->setColorProfile($this->colorProfile()); + $handler->setColorProfile($this->colorProfile()); + + $this->assertSame(1, substr_count($handler->getData(), "ICC_PROFILE\x00")); + } + + public function testIncompleteColorProfilesAreNotAccepted(): void + { + $segment = GdImages::jpegSegment(0xe2, "ICC_PROFILE\x00\x01\x02" . $this->colorProfile()->getData()); + $handler = new JpegHandler("\xff\xd8" . $segment . "\xff\xd9"); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Unexpected profile chunk count'); + + $handler->getColorProfile(); + } + + public function testTruncatedColorProfileSegmentsAreNotAccepted(): void + { + $handler = new JpegHandler("\xff\xd8" . GdImages::jpegSegment(0xe2, "ICC_PROFILE\x00\x01") . "\xff\xd9"); + + $this->expectException(UnexpectedValueException::class); + + $handler->getColorProfile(); + } + + public function testExifDataIsSetAndRead(): void + { + $handler = new JpegHandler(GdImages::jpeg()); + + $this->assertFalse($handler->hasExifData()); + $this->assertNull($handler->getExifData()); + + $handler->setExifData($this->exifData()); + + $this->assertTrue($handler->hasExifData()); + $this->assertSame($this->exifData()->getData(), $handler->getExifData()?->getData()); + $this->assertDecodable($handler); + } + + public function testExifDataIsReplacedInsteadOfDuplicated(): void + { + $handler = new JpegHandler(GdImages::jpeg()); + + $handler->setExifData($this->exifData()); + $handler->setExifData($this->exifData()); + + $this->assertSame(1, substr_count($handler->getData(), "Exif\x00\x00")); + } + + public function testExifDataIsRemoved(): void + { + $handler = new JpegHandler(GdImages::jpeg()); + $handler->setExifData($this->exifData()); + + $handler->removeExifData(); + + $this->assertFalse($handler->hasExifData()); + $this->assertSame(GdImages::jpeg(), $handler->getData()); + } + + public function testMetadataFollowsTheJfifSegment(): void + { + $handler = new JpegHandler(GdImages::jpeg()); + $handler->setExifData($this->exifData()); + $handler->setColorProfile($this->colorProfile()); + + $this->assertSame("\xff\xd8\xff\xe0", substr($handler->getData(), 0, 4)); + $this->assertSame('JFIF', substr($handler->getData(), 6, 4)); + } + + public function testMetadataFollowsTheStartOfImageMarkerWithoutJfifSegment(): void + { + $handler = new JpegHandler("\xff\xd8" . GdImages::jpegSegment(0xc0, "\x08\x00\x0a\x00\x14\x03") . "\xff\xd9"); + $handler->setExifData($this->exifData()); + + $this->assertSame("\xff\xd8\xff\xe1", substr($handler->getData(), 0, 4)); + $this->assertTrue($handler->hasExifData()); + $this->assertSame(20, $handler->getInfo()->width()); + } + + public function testRemovingMetadataKeepsOtherSegments(): void + { + $jpeg = "\xff\xd8" . GdImages::jpegSegment(0xe1, "Exif\x00\x00" . $this->exifData()->getData()) . GdImages::jpegSegment(0xe1, 'http://ns.adobe.com/xap/1.0/') . GdImages::jpegSegment(0xe2, 'other') . "\xff\xd9"; + $handler = new JpegHandler($jpeg); + + $handler->removeExifData(); + + $this->assertSame("\xff\xd8" . GdImages::jpegSegment(0xe1, 'http://ns.adobe.com/xap/1.0/') . GdImages::jpegSegment(0xe2, 'other') . "\xff\xd9", $handler->getData()); + } + + public function testMissingStartOfImageMarkersAreNotAccepted(): void + { + $handler = new JpegHandler('not a JPEG image'); + + $this->expectException(\InvalidArgumentException::class); + + $handler->setExifData($this->exifData()); + } + + public function testExifDataNotFittingInASegmentIsNotAccepted(): void + { + $handler = new JpegHandler(GdImages::jpeg()); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Data is too large to be embedded in a JPEG segment'); + + $handler->setExifData(new ExifData($this->exifData()->getData() . str_repeat("\x00", 70_000))); + } + + public function testImagesAreCreatedFromGdImages(): void + { + $handler = JpegHandler::fromGdImage(GdImages::image(16, 12)); + + $this->assertSame(16, $handler->getInfo()->width()); + $this->assertSame(12, $handler->getInfo()->height()); + } + + private function colorProfile(): ColorProfile + { + return new ColorProfile((string) file_get_contents(self::FIXTURES . '/srgb.icc')); + } + + private function exifData(): ExifData + { + return new ExifData((string) file_get_contents(self::FIXTURES . '/exif.tiff')); + } + + private function assertDecodable(JpegHandler $handler): void + { + $this->assertNotFalse(@imagecreatefromstring($handler->getData())); + } +} From 1555ab660380338a2b77a863e42dca11a19135b0 Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Sat, 31 Oct 2026 10:22:13 +0100 Subject: [PATCH 06/10] Make the GIF decoder and handler robust The decoder read blocks without checking their bounds and failed on data following the trailer. The handler took the delay of a single frame as a sign of animation, reported the color resolution as number of colors and allocated the transparent color on the source image instead of the one being encoded. Read blocks through ByteReader, stop at the trailer and count the frames to tell animations from still images. Report the size of the global color table as number of colors and allocate the transparent color on the destination image. Co-Authored-By: Claude Sonnet 5.5 --- formwork/src/Images/Decoder/GifDecoder.php | 203 +++++++++---------- formwork/src/Images/Handler/GifHandler.php | 46 +++-- tests/Unit/Images/Decoder/GifDecoderTest.php | 139 +++++++++++++ tests/Unit/Images/Handler/GifHandlerTest.php | 176 ++++++++++++++++ 4 files changed, 438 insertions(+), 126 deletions(-) create mode 100644 tests/Unit/Images/Decoder/GifDecoderTest.php create mode 100644 tests/Unit/Images/Handler/GifHandlerTest.php diff --git a/formwork/src/Images/Decoder/GifDecoder.php b/formwork/src/Images/Decoder/GifDecoder.php index 0eac98928..c326cf294 100644 --- a/formwork/src/Images/Decoder/GifDecoder.php +++ b/formwork/src/Images/Decoder/GifDecoder.php @@ -2,6 +2,8 @@ namespace Formwork\Images\Decoder; +use Formwork\Images\Binary\ByteReader; +use Formwork\Images\Binary\Endianness; use Generator; use InvalidArgumentException; use UnexpectedValueException; @@ -15,102 +17,95 @@ final class GifDecoder implements DecoderInterface */ private const array GIF_HEADERS = ['GIF87a', 'GIF89a']; + /** + * Block introducers + */ + private const string IMAGE_SEPARATOR = ','; + + private const string EXTENSION_INTRODUCER = '!'; + + private const string TRAILER = ';'; + public function decode(string &$data): Generator { if (!in_array(substr($data, 0, 6), self::GIF_HEADERS, true)) { throw new InvalidArgumentException('Invalid GIF data'); } - $position = 6; + $reader = new ByteReader($data, 6); - $desc = null; - $label = null; + $desc = $this->parseLogicalScreenDescriptor($reader); + + yield $this->block($data, 6, $reader->position(), 'LSD', $desc); + + if ($desc['gctflag'] === 1) { + $offset = $reader->position(); + $reader->skip($this->getColorTableSize($desc['gctsize'])); + + yield $this->block($data, $offset, $reader->position(), 'GCT'); + } - while ($position < strlen($data)) { - $offset = $position; + while ($reader->remaining() > 0) { + $offset = $reader->position(); + $separator = $reader->bytes(1); - switch (true) { - case $offset === 6: - $type = 'LSD'; - $size = 7; - $value = substr($data, $offset, 7); - $desc = $this->parseLogicalScreenDescriptor($value); - $position += $size; + switch ($separator) { + case self::IMAGE_SEPARATOR: + $desc = $this->parseImageDescriptor($reader); + if ($desc['lctflag'] === 1) { + $reader->skip($this->getColorTableSize($desc['lctsize'])); + } + $desc['lzwsize'] = $reader->uint(1); + $this->skipSubBlocks($reader); + + yield $this->block($data, $offset, $reader->position(), 'IMG', $desc); break; - case $offset === 13 && ($desc['gctflag'] ?? 0) === 1: - $type = 'GCT'; - $size = $this->getColorTableSize($desc['gctsize']); - $position += $size; + case self::EXTENSION_INTRODUCER: + $label = $reader->uint(1); + $this->skipSubBlocks($reader); + + yield $this->block($data, $offset, $reader->position(), 'EXT', null, $label); break; - default: - $separator = $data[$position]; - $position++; - - $desc = null; - $label = null; - - switch ($separator) { - case ',': - $type = 'IMG'; - $desc = $this->parseImageDescriptor(substr($data, $position, 9)); - $position += 9; - if ($desc['lctflag'] === 1) { - $lctSize = $this->getColorTableSize($desc['lctsize']); - $position += $lctSize; - } - $desc['lzwsize'] = ord($data[$position]); - $position++; - $position = $this->seekBlockEnd($data, $position); - $size = $position - $offset + 1; - break; - - case '!': - $type = 'EXT'; - $label = ord($data[$position]); - $position++; - $position = $this->seekBlockEnd($data, $position); - $size = $position - $offset + 1; - break; - - case ';': - $type = 'END'; - $size = 0; - break; - - default: - throw new UnexpectedValueException('Unexpected block introducer'); - } + case self::TRAILER: + yield $this->block($data, $offset, $reader->position(), 'END'); + return; - $position++; + default: + throw new UnexpectedValueException('Unexpected block introducer'); } - - yield [ - 'offset' => $offset, - 'size' => $size, - 'type' => $type, - 'label' => $label, - 'desc' => $desc, - 'value' => substr($data, $offset, $size), - 'position' => &$position, - ]; } } /** - * Seek the end of a block + * Build a decoded block + * + * @param ?array $desc + * + * @return array + */ + private function block(string &$data, int $offset, int $position, string $type, ?array $desc = null, ?int $label = null): array + { + return [ + 'offset' => $offset, + 'size' => $position - $offset, + 'type' => $type, + 'label' => $label, + 'desc' => $desc, + 'value' => substr($data, $offset, $position - $offset), + 'position' => $position, + ]; + } + + /** + * Skip the sub-blocks of a block, including the block terminator */ - private function seekBlockEnd(string &$data, int $position): int + private function skipSubBlocks(ByteReader $reader): void { - while ($position < strlen($data)) { - if ($data[$position] === "\x00") { - return $position; - } - $size = ord($data[$position]); - $position += $size + 1; + while (($size = $reader->uint(1)) > 0) { + $reader->skip($size); } - throw new UnexpectedValueException('Unexpected end of data'); } /** @@ -118,18 +113,22 @@ private function seekBlockEnd(string &$data, int $position): int * * @return array */ - private function parseLogicalScreenDescriptor(string $data): array + private function parseLogicalScreenDescriptor(ByteReader $reader): array { + $width = $reader->uint(2, Endianness::Little); + $height = $reader->uint(2, Endianness::Little); + $packed = $reader->uint(1); + return [ - 'width' => $this->unpack('v', $data, 0)[1], - 'height' => $this->unpack('v', $data, 2)[1], - 'packed' => ord($data[4]), - 'gctflag' => (ord($data[4]) & 0x80) >> 7, - 'colorres' => (ord($data[4]) & 0x70) >> 4, - 'sflag' => (ord($data[4]) & 0x08) >> 3, - 'gctsize' => (ord($data[4]) & 0x07), - 'bgindex' => ord($data[5]), - 'pxratio' => ord($data[6]), + 'width' => $width, + 'height' => $height, + 'packed' => $packed, + 'gctflag' => ($packed & 0x80) >> 7, + 'colorres' => ($packed & 0x70) >> 4, + 'sflag' => ($packed & 0x08) >> 3, + 'gctsize' => ($packed & 0x07), + 'bgindex' => $reader->uint(1), + 'pxratio' => $reader->uint(1), ]; } @@ -138,18 +137,24 @@ private function parseLogicalScreenDescriptor(string $data): array * * @return array */ - private function parseImageDescriptor(string $data): array + private function parseImageDescriptor(ByteReader $reader): array { + $left = $reader->uint(2, Endianness::Little); + $top = $reader->uint(2, Endianness::Little); + $width = $reader->uint(2, Endianness::Little); + $height = $reader->uint(2, Endianness::Little); + $packed = $reader->uint(1); + return [ - 'left' => $this->unpack('v', $data, 0)[1], - 'top' => $this->unpack('v', $data, 2)[1], - 'width' => $this->unpack('v', $data, 4)[1], - 'height' => $this->unpack('v', $data, 6)[1], - 'packed' => ord($data[8]), - 'lctflag' => (ord($data[8]) & 0x80) >> 7, - 'iflag' => (ord($data[8]) & 0x40) >> 6, - 'sflag' => (ord($data[8]) & 0x20) >> 5, - 'lctsize' => (ord($data[8]) & 0x07), + 'left' => $left, + 'top' => $top, + 'width' => $width, + 'height' => $height, + 'packed' => $packed, + 'lctflag' => ($packed & 0x80) >> 7, + 'iflag' => ($packed & 0x40) >> 6, + 'sflag' => ($packed & 0x20) >> 5, + 'lctsize' => ($packed & 0x07), ]; } @@ -160,14 +165,4 @@ private function getColorTableSize(int $ctsize): int { return 3 * 2 ** ($ctsize + 1); } - - /** - * Unpack data from a binary string - * - * @return array - */ - private function unpack(string $format, string $string, int $offset = 0): array - { - return unpack($format, $string, $offset) ?: throw new UnexpectedValueException('Cannot unpack string'); - } } diff --git a/formwork/src/Images/Handler/GifHandler.php b/formwork/src/Images/Handler/GifHandler.php index 4cb6bdf82..f5917276b 100644 --- a/formwork/src/Images/Handler/GifHandler.php +++ b/formwork/src/Images/Handler/GifHandler.php @@ -2,6 +2,8 @@ namespace Formwork\Images\Handler; +use Formwork\Images\Binary\Bytes; +use Formwork\Images\Binary\Endianness; use Formwork\Images\ColorProfile\ColorProfile; use Formwork\Images\ColorProfile\ColorSpace; use Formwork\Images\Decoder\GifDecoder; @@ -34,33 +36,43 @@ public function getInfo(): ImageInfo 'animationRepeatCount' => null, ]; + $frames = 0; + foreach ($this->decoder->decode($this->data) as $block) { if ($block['type'] === 'LSD') { $info['width'] = $block['desc']['width']; $info['height'] = $block['desc']['height']; - $info['colorNumber'] = 2 ** ($block['desc']['colorres'] + 1); + + if ($block['desc']['gctflag'] === 1) { + $info['colorNumber'] = 2 ** ($block['desc']['gctsize'] + 1); + } } if ($block['type'] === 'EXT' && $block['label'] === 0xf9) { - $info['hasAlphaChannel'] = (ord($block['value'][3]) & 0x01) === 1; - if (!$info['isAnimation']) { - $info['isAnimation'] = $this->unpack('v', $block['value'], 4)[1] > 0; + // The transparency flag is the lowest bit of the packed field of the graphic control extension + if ($info['hasAlphaChannel'] === false) { + $info['hasAlphaChannel'] = (Bytes::unpack($block['value'], 3, 1) & 0x01) === 1; } } if ($block['type'] === 'EXT' && str_starts_with($block['value'], self::NETSCAPE_EXT_HEADER)) { - $info['animationRepeatCount'] = $this->unpack('v', $block['value'], 16)[1]; + $info['animationRepeatCount'] = Bytes::unpack($block['value'], 16, 2, Endianness::Little); if ($info['animationRepeatCount'] > 0) { $info['animationRepeatCount']++; } } - if ($block['type'] !== 'IMG') { - continue; - } - if (!$info['isAnimation']) { - continue; + + if ($block['type'] === 'IMG') { + $frames++; } - $info['animationFrames']++; + } + + // An image is animated only if it has more than one frame + if ($frames > 1) { + $info['isAnimation'] = true; + $info['animationFrames'] = $frames; + } else { + $info['animationRepeatCount'] = null; } return new ImageInfo($info); @@ -137,7 +149,7 @@ protected function setDataFromGdImage(GdImage $gdImage): void throw new RuntimeException('Cannot create GIF image from GdImage'); } - if (($transparent = imagecolorallocatealpha($gdImage, 0, 0, 0, 127)) === false) { + if (($transparent = imagecolorallocatealpha($image, 0, 0, 0, 127)) === false) { throw new RuntimeException('Cannot allocate transparent color'); } @@ -157,14 +169,4 @@ protected function setDataFromGdImage(GdImage $gdImage): void $this->data = ob_get_clean() ?: throw new UnexpectedValueException('Unexpected empty image data'); } - - /** - * Unpack data from a binary string - * - * @return array - */ - private function unpack(string $format, string $string, int $offset = 0): array - { - return unpack($format, $string, $offset) ?: throw new UnexpectedValueException('Cannot unpack string'); - } } diff --git a/tests/Unit/Images/Decoder/GifDecoderTest.php b/tests/Unit/Images/Decoder/GifDecoderTest.php new file mode 100644 index 000000000..a2a2dbd6a --- /dev/null +++ b/tests/Unit/Images/Decoder/GifDecoderTest.php @@ -0,0 +1,139 @@ +decode($data), false); + + $this->assertSame(['LSD', 'GCT', 'EXT', 'IMG', 'END'], array_column($blocks, 'type')); + $this->assertSame([6, 13, 19, 27, 42], array_column($blocks, 'offset')); + $this->assertSame([7, 6, 8, 15, 1], array_column($blocks, 'size')); + $this->assertSame($extension, $blocks[2]['value']); + $this->assertSame(0xf9, $blocks[2]['label']); + $this->assertSame(self::IMAGE, $blocks[3]['value']); + $this->assertSame(43, $blocks[4]['position']); + } + + public function testScreenAndImageDescriptorsAreParsed(): void + { + $data = 'GIF87a' . self::SCREEN . self::COLOR_TABLE . self::IMAGE . ';'; + + $blocks = iterator_to_array((new GifDecoder())->decode($data), false); + + $this->assertSame(3, $blocks[0]['desc']['width']); + $this->assertSame(2, $blocks[0]['desc']['height']); + $this->assertSame(1, $blocks[0]['desc']['gctflag']); + $this->assertSame(0, $blocks[0]['desc']['gctsize']); + $this->assertSame(3, $blocks[2]['desc']['width']); + $this->assertSame(2, $blocks[2]['desc']['height']); + $this->assertSame(0, $blocks[2]['desc']['lctflag']); + $this->assertSame(2, $blocks[2]['desc']['lzwsize']); + } + + public function testImagesWithoutGlobalColorTableAreDecoded(): void + { + $data = "GIF89a\x03\x00\x02\x00\x00\x00\x00" . self::IMAGE . ';'; + + $blocks = iterator_to_array((new GifDecoder())->decode($data), false); + + $this->assertSame(['LSD', 'IMG', 'END'], array_column($blocks, 'type')); + } + + public function testLocalColorTablesAreSkipped(): void + { + $image = ",\x00\x00\x00\x00\x03\x00\x02\x00\x80\x00\x00\x00\xff\xff\xff\x02\x02\x44\x01\x00"; + $data = "GIF89a\x03\x00\x02\x00\x00\x00\x00" . $image . ';'; + + $blocks = iterator_to_array((new GifDecoder())->decode($data), false); + + $this->assertSame(['LSD', 'IMG', 'END'], array_column($blocks, 'type')); + $this->assertSame($image, $blocks[1]['value']); + } + + public function testDataAfterTheTrailerIsIgnored(): void + { + $data = "GIF89a\x03\x00\x02\x00\x00\x00\x00;trailing garbage"; + + $this->assertCount(2, iterator_to_array((new GifDecoder())->decode($data))); + } + + public function testRealImagesAreDecoded(): void + { + $data = GdImages::gif(); + + $types = array_column(iterator_to_array((new GifDecoder())->decode($data), false), 'type'); + + $this->assertSame('LSD', $types[0]); + $this->assertContains('IMG', $types); + $this->assertSame('END', end($types)); + } + + public function testInvalidHeadersAreNotDecoded(): void + { + $data = 'not a GIF image'; + + $this->expectException(InvalidArgumentException::class); + + iterator_to_array((new GifDecoder())->decode($data)); + } + + public function testTruncatedScreenDescriptorsAreNotDecoded(): void + { + $data = "GIF89a\x03\x00"; + + $this->expectException(UnexpectedValueException::class); + + iterator_to_array((new GifDecoder())->decode($data)); + } + + public function testTruncatedColorTablesAreNotDecoded(): void + { + $data = 'GIF89a' . self::SCREEN . "\x00\x00"; + + $this->expectException(UnexpectedValueException::class); + + iterator_to_array((new GifDecoder())->decode($data)); + } + + public function testTruncatedBlocksAreNotDecoded(): void + { + $data = "GIF89a\x03\x00\x02\x00\x00\x00\x00" . substr(self::IMAGE, 0, -2); + + $this->expectException(UnexpectedValueException::class); + + iterator_to_array((new GifDecoder())->decode($data)); + } + + public function testUnknownBlockIntroducersAreNotDecoded(): void + { + $data = "GIF89a\x03\x00\x02\x00\x00\x00\x00?"; + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Unexpected block introducer'); + + iterator_to_array((new GifDecoder())->decode($data)); + } +} diff --git a/tests/Unit/Images/Handler/GifHandlerTest.php b/tests/Unit/Images/Handler/GifHandlerTest.php new file mode 100644 index 000000000..286c913ce --- /dev/null +++ b/tests/Unit/Images/Handler/GifHandlerTest.php @@ -0,0 +1,176 @@ +getInfo(); + + $this->assertSame('image/gif', $info->mimeType()); + $this->assertSame(40, $info->width()); + $this->assertSame(30, $info->height()); + $this->assertSame(ColorSpace::Palette, $info->colorSpace()); + $this->assertFalse($info->isAnimation()); + $this->assertNull($info->animationFrames()); + } + + public function testEncodedGdImagesWithTransparencyHaveAnAlphaChannel(): void + { + $this->assertTrue(GifHandler::fromGdImage(GdImages::image())->getInfo()->hasAlphaChannel()); + } + + public function testTheNumberOfColorsIsTheSizeOfTheGlobalColorTable(): void + { + $this->assertSame(4, $this->handler("\x01\x00\x01\x00\xf1\x00\x00" . str_repeat("\x00", 12) . self::FRAME)->getInfo()->colorNumber()); + $this->assertSame(256, $this->handler("\x01\x00\x01\x00\xf7\x00\x00" . str_repeat("\x00", 768) . self::FRAME)->getInfo()->colorNumber()); + } + + public function testImagesWithoutGlobalColorTableHaveNoColorNumber(): void + { + $this->assertNull($this->handler("\x01\x00\x01\x00\x70\x00\x00" . self::FRAME)->getInfo()->colorNumber()); + } + + public function testSingleFramesWithADelayAreNotAnimations(): void + { + $extension = "!\xf9\x04\x00\x64\x00\x00\x00"; + + $handler = $this->handler("\x03\x00\x02\x00\x00\x00\x00" . $extension . self::FRAME); + + $this->assertFalse($handler->getInfo()->isAnimation()); + $this->assertNull($handler->getInfo()->animationFrames()); + $this->assertTrue($handler->supportsTransforms()); + } + + public function testAnimationsAreRecognized(): void + { + $loop = "!\xff\x0bNETSCAPE2.0\x03\x01\x04\x00\x00"; + + $handler = $this->handler("\x03\x00\x02\x00\x00\x00\x00" . $loop . self::FRAME . self::FRAME . self::FRAME); + + $info = $handler->getInfo(); + + $this->assertTrue($info->isAnimation()); + $this->assertSame(3, $info->animationFrames()); + $this->assertSame(5, $info->animationRepeatCount()); + $this->assertFalse($handler->supportsTransforms()); + } + + public function testTransparencyIsReadFromTheGraphicControlExtension(): void + { + $opaque = "!\xf9\x04\x00\x00\x00\x00\x00"; + $transparent = "!\xf9\x04\x01\x00\x00\x00\x00"; + + $this->assertFalse($this->handler("\x03\x00\x02\x00\x00\x00\x00" . $opaque . self::FRAME)->getInfo()->hasAlphaChannel()); + $this->assertTrue($this->handler("\x03\x00\x02\x00\x00\x00\x00" . $transparent . self::FRAME)->getInfo()->hasAlphaChannel()); + } + + public function testTruncatedImagesAreNotAccepted(): void + { + $handler = new GifHandler(substr(GdImages::gif(), 0, 30)); + + $this->expectException(UnexpectedValueException::class); + + $handler->getInfo(); + } + + public function testTransparencyIsPreservedWhenEncodingGdImages(): void + { + $handler = GifHandler::fromGdImage(GdImages::image(20, 20)); + + $image = imagecreatefromstring($handler->getData()); + + $this->assertNotFalse($image); + $this->assertGreaterThanOrEqual(0, imagecolortransparent($image)); + $this->assertSame(127, $this->alphaAt($image, 2, 2)); + $this->assertSame(0, $this->alphaAt($image, 15, 15)); + } + + public function testTransparencyIsPreservedWhenEncodingPaletteImages(): void + { + $palette = imagecreatefromstring(GifHandler::fromGdImage(GdImages::image(20, 20))->getData()); + $this->assertNotFalse($palette); + $this->assertFalse(imageistruecolor($palette)); + + $image = imagecreatefromstring(GifHandler::fromGdImage($palette)->getData()); + + $this->assertNotFalse($image); + $this->assertSame(127, $this->alphaAt($image, 2, 2)); + $this->assertSame(0, $this->alphaAt($image, 15, 15)); + } + + public function testTheNumberOfColorsCanBeLimited(): void + { + $handler = GifHandler::fromGdImage(GdImages::image(20, 20), ['gifColors' => 4]); + + $this->assertLessThanOrEqual(4, $handler->getInfo()->colorNumber()); + } + + public function testMetadataIsNotSupported(): void + { + $handler = new GifHandler(GdImages::gif()); + + $this->assertFalse(GifHandler::supportsColorProfile()); + $this->assertFalse(GifHandler::supportsExifData()); + $this->assertFalse($handler->hasColorProfile()); + $this->assertFalse($handler->hasExifData()); + } + + /** + * @param callable(GifHandler): mixed $operation + */ + #[DataProvider('unsupportedOperationProvider')] + public function testMetadataOperationsAreRejected(callable $operation): void + { + $this->expectException(UnsupportedFeatureException::class); + + $operation(new GifHandler(GdImages::gif())); + } + + /** + * @return iterable + */ + public static function unsupportedOperationProvider(): iterable + { + $profile = new ColorProfile((string) file_get_contents(self::FIXTURES . '/srgb.icc')); + $exif = new ExifData((string) file_get_contents(self::FIXTURES . '/exif.tiff')); + + yield 'get color profile' => [static fn(GifHandler $h): ?ColorProfile => $h->getColorProfile()]; + yield 'set color profile' => [static fn(GifHandler $h) => $h->setColorProfile($profile)]; + yield 'remove color profile' => [static fn(GifHandler $h) => $h->removeColorProfile()]; + yield 'get exif data' => [static fn(GifHandler $h): ?ExifData => $h->getExifData()]; + yield 'set exif data' => [static fn(GifHandler $h) => $h->setExifData($exif)]; + yield 'remove exif data' => [static fn(GifHandler $h) => $h->removeExifData()]; + } + + private function alphaAt(GdImage $image, int $x, int $y): int + { + return imagecolorsforindex($image, (int) imagecolorat($image, $x, $y))['alpha']; + } + + /** + * Create a handler from the data following the GIF header + */ + private function handler(string $body): GifHandler + { + return new GifHandler('GIF89a' . $body . ';'); + } +} From 988375a8b154736a7a6978ff147cde58c8d08397 Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Sat, 31 Oct 2026 10:22:14 +0100 Subject: [PATCH 07/10] Make the WebP decoder and handler robust Chunk values included their padding byte, so color profiles of odd size were corrupted, and chunks were read past the end of the RIFF container. Metadata could be set only on files that already had a VP8X chunk, and setting it twice added a second copy. Read chunks through ByteReader, bound them to the RIFF container and leave padding out of the values. Edit images as a list of chunks that is serialized in one step, so that the RIFF size and the VP8X flags always match their content, extending simple files with a VP8X chunk when needed, replacing existing metadata and placing Exif data before XMP. Co-Authored-By: Claude Sonnet 5.5 --- formwork/src/Images/Decoder/WebpDecoder.php | 48 ++- formwork/src/Images/Handler/WebpHandler.php | 181 ++++++----- tests/Unit/Images/Decoder/WebpDecoderTest.php | 88 ++++++ tests/Unit/Images/Handler/WebpHandlerTest.php | 286 ++++++++++++++++++ 4 files changed, 503 insertions(+), 100 deletions(-) create mode 100644 tests/Unit/Images/Decoder/WebpDecoderTest.php create mode 100644 tests/Unit/Images/Handler/WebpHandlerTest.php diff --git a/formwork/src/Images/Decoder/WebpDecoder.php b/formwork/src/Images/Decoder/WebpDecoder.php index 8dd4e2e83..ed416c223 100644 --- a/formwork/src/Images/Decoder/WebpDecoder.php +++ b/formwork/src/Images/Decoder/WebpDecoder.php @@ -2,9 +2,10 @@ namespace Formwork\Images\Decoder; +use Formwork\Images\Binary\ByteReader; +use Formwork\Images\Binary\Endianness; use Generator; use InvalidArgumentException; -use UnexpectedValueException; final class WebpDecoder implements DecoderInterface { @@ -18,47 +19,42 @@ final class WebpDecoder implements DecoderInterface */ private const string WEBP_HEADER = 'WEBP'; + /** + * Size of the RIFF header, including the size field + */ + private const int RIFF_HEADER_SIZE = 8; + public function decode(string &$data): Generator { - if (!str_starts_with($data, self::RIFF_HEADER)) { + if (!str_starts_with($data, self::RIFF_HEADER) || substr($data, 8, 4) !== self::WEBP_HEADER) { throw new InvalidArgumentException('Invalid WebP data'); } - $position = strlen(self::RIFF_HEADER) + 4; + $reader = new ByteReader($data, 4); - if (strpos($data, self::WEBP_HEADER, $position) !== $position) { - throw new InvalidArgumentException('Invalid WebP data'); - } + // Ignore any data after the end of the RIFF container + $end = min(strlen($data), $reader->uint(4, Endianness::Little) + self::RIFF_HEADER_SIZE); - $position += strlen(self::WEBP_HEADER); + $reader->skip(strlen(self::WEBP_HEADER)); - while ($position < strlen($data)) { - $offset = $position; - $type = substr($data, $position, 4); - $position += 4; - $size = $this->unpack('V', $data, $position)[1]; + while ($reader->position() + self::RIFF_HEADER_SIZE <= $end) { + $offset = $reader->position(); + $type = $reader->bytes(4); + $size = $reader->uint(4, Endianness::Little); + $value = $reader->bytes($size); - if ($size % 2 !== 0) { - $size++; + // Chunks are padded to an even size, but the last padding byte may be missing + if ($size % 2 !== 0 && $reader->remaining() > 0) { + $reader->skip(1); } - $position += $size + 4; - yield [ 'offset' => $offset, 'size' => $size, 'type' => $type, - 'value' => substr($data, $offset + 8, $size), - 'position' => &$position, + 'value' => $value, + 'position' => $reader->position(), ]; } } - - /** - * @return array - */ - private function unpack(string $format, string $string, int $offset = 0): array - { - return unpack($format, $string, $offset) ?: throw new UnexpectedValueException('Cannot unpack string'); - } } diff --git a/formwork/src/Images/Handler/WebpHandler.php b/formwork/src/Images/Handler/WebpHandler.php index e06a94f08..c68104a7c 100644 --- a/formwork/src/Images/Handler/WebpHandler.php +++ b/formwork/src/Images/Handler/WebpHandler.php @@ -2,13 +2,14 @@ namespace Formwork\Images\Handler; +use Formwork\Images\Binary\Bytes; +use Formwork\Images\Binary\Endianness; use Formwork\Images\ColorProfile\ColorProfile; use Formwork\Images\ColorProfile\ColorSpace; use Formwork\Images\Decoder\WebpDecoder; use Formwork\Images\Exif\ExifData; use Formwork\Images\ImageInfo; use GdImage; -use InvalidArgumentException; use RuntimeException; use UnexpectedValueException; @@ -19,6 +20,16 @@ final class WebpHandler extends AbstractHandler */ private const string RIFF_HEADER = 'RIFF'; + /** + * WebP file header + */ + private const string WEBP_HEADER = 'WEBP'; + + /** + * Size of the VP8X chunk + */ + private const int VP8X_SIZE = 10; + /** * VP8X no flags */ @@ -58,20 +69,23 @@ public function getInfo(): ImageInfo foreach ($this->decoder->decode($this->data) as $chunk) { if (!$isVP8ChunkParsed && $chunk['type'] === 'VP8X') { - $info['hasAlphaChannel'] = ((ord($chunk['value'][0]) >> 4) & 0x01) === 1; - $info['width'] = $this->unpack('V', substr($chunk['value'], 4, 3) . "\x00")[1] + 1; - $info['height'] = $this->unpack('V', substr($chunk['value'], 7, 3) . "\x00")[1] + 1; + if ($chunk['size'] < self::VP8X_SIZE) { + throw new UnexpectedValueException('Invalid VP8X chunk'); + } + $info['hasAlphaChannel'] = (Bytes::unpack($chunk['value'], 0, 1) & self::ALPHA_FLAG) !== 0; + $info['width'] = Bytes::unpack($chunk['value'], 4, 3, Endianness::Little) + 1; + $info['height'] = Bytes::unpack($chunk['value'], 7, 3, Endianness::Little) + 1; $isVP8ChunkParsed = true; } if (!$isVP8ChunkParsed && $chunk['type'] === 'VP8 ') { - $info['width'] = $this->unpack('v', $chunk['value'], 6)[1] & 0x3fff; - $info['height'] = $this->unpack('v', $chunk['value'], 8)[1] & 0x3fff; + $info['width'] = Bytes::unpack($chunk['value'], 6, 2, Endianness::Little) & 0x3fff; + $info['height'] = Bytes::unpack($chunk['value'], 8, 2, Endianness::Little) & 0x3fff; $isVP8ChunkParsed = true; } if (!$isVP8ChunkParsed && $chunk['type'] === 'VP8L') { - $bits = $this->unpack('V', $chunk['value'], 1)[1]; + $bits = Bytes::unpack($chunk['value'], 1, 4, Endianness::Little); $info['width'] = ($bits & 0x3fff) + 1; $info['height'] = (($bits >> 14) & 0x3fff) + 1; $info['hasAlphaChannel'] = (($bits >> 28) & 0x01) === 1; @@ -84,8 +98,9 @@ public function getInfo(): ImageInfo if ($chunk['type'] === 'ANIM') { $info['isAnimation'] = true; - $info['animationRepeatCount'] = $this->unpack('v', $chunk['value'], 4)[1]; + $info['animationRepeatCount'] = Bytes::unpack($chunk['value'], 4, 2, Endianness::Little); } + if (!$info['isAnimation']) { continue; } @@ -132,31 +147,17 @@ public function getColorProfile(): ?ColorProfile public function setColorProfile(ColorProfile $colorProfile): void { - foreach ($this->decoder->decode($this->data) as $chunk) { - if ($chunk['type'] === 'VP8X') { - $VP8XFlags = ord($chunk['value'][0]) | self::ICC_FLAG; - $this->data = substr_replace($this->data, chr($VP8XFlags), $chunk['offset'] + 8, 1); - $ICCPChunk = $this->encodeChunk('ICCP', $colorProfile->getData()); - $this->data = substr_replace($this->data, $ICCPChunk, $chunk['position'], 0); - $this->updateRIFFHeader(); - break; - } - } + $chunks = $this->withoutChunks($this->extendedChunks(), 'ICCP'); + + // The color profile must immediately follow the VP8X chunk + array_splice($chunks, 1, 0, [['ICCP', $colorProfile->getData()]]); + + $this->setChunks($chunks); } public function removeColorProfile(): void { - foreach ($this->decoder->decode($this->data) as $chunk) { - if ($chunk['type'] === 'ICCP') { - $this->data = substr_replace($this->data, '', $chunk['offset'], $chunk['position'] - $chunk['offset']); - $this->updateRIFFHeader(); - } - - if ($chunk['type'] === 'VP8X') { - $VP8XFlags = ord($chunk['value'][0]) & ~self::ICC_FLAG; - $this->data = substr_replace($this->data, chr($VP8XFlags), $chunk['offset'] + 8, 1); - } - } + $this->setChunks($this->withoutChunks($this->chunks(), 'ICCP')); } public static function supportsExifData(): bool @@ -188,33 +189,26 @@ public function getExifData(): ?ExifData public function setExifData(ExifData $exifData): void { - foreach ($this->decoder->decode($this->data) as $chunk) { - if ($chunk['type'] === 'VP8X') { - $VP8XFlags = ord($chunk['value'][0]) | self::EXIF_FLAG; - $this->data = substr_replace($this->data, chr($VP8XFlags), $chunk['offset'] + 8, 1); - } + $chunks = $this->withoutChunks($this->extendedChunks(), 'EXIF'); - if (in_array($chunk['type'], ['VP8 ', 'VP8L'], true)) { - $ExifChunk = $this->encodeChunk('EXIF', $exifData->getData()); - $this->data = substr_replace($this->data, $ExifChunk, $chunk['position'], 0); - $this->updateRIFFHeader(); + // EXIF metadata follows the image data and precedes XMP metadata + $position = count($chunks); + + foreach ($chunks as $index => [$type]) { + if ($type === 'XMP ') { + $position = $index; + break; } } + + array_splice($chunks, $position, 0, [['EXIF', $exifData->getData()]]); + + $this->setChunks($chunks); } public function removeExifData(): void { - foreach ($this->decoder->decode($this->data) as $chunk) { - if ($chunk['type'] === 'EXIF') { - $this->data = substr_replace($this->data, '', $chunk['offset'], $chunk['position'] - $chunk['offset']); - $this->updateRIFFHeader(); - } - - if ($chunk['type'] === 'VP8X') { - $VP8XFlags = ord($chunk['value'][0]) & ~self::EXIF_FLAG; - $this->data = substr_replace($this->data, chr($VP8XFlags), $chunk['offset'] + 8, 1); - } - } + $this->setChunks($this->withoutChunks($this->chunks(), 'EXIF')); } protected function getDecoder(): WebpDecoder @@ -238,52 +232,91 @@ protected function setDataFromGdImage(GdImage $gdImage): void $this->data = ob_get_clean() ?: throw new UnexpectedValueException('Unexpected empty image data'); - $this->setVP8XChunk(); + $this->setChunks($this->extendedChunks()); + } + + /** + * Get the chunks of the image + * + * @return list + */ + private function chunks(): array + { + $chunks = []; + + foreach ($this->decoder->decode($this->data) as $chunk) { + $chunks[] = [$chunk['type'], $chunk['value']]; + } + + return $chunks; } /** - * Update RIFF header + * Get the chunks of the image, ensuring that the first one is a VP8X chunk + * + * @return list */ - private function updateRIFFHeader(): void + private function extendedChunks(): array { - if (!str_starts_with($this->data, self::RIFF_HEADER)) { - throw new InvalidArgumentException('Invalid WebP data'); + $chunks = $this->chunks(); + + if (($chunks[0][0] ?? null) === 'VP8X') { + return $chunks; } - $this->data = substr_replace($this->data, pack('V', strlen($this->data) - 8), 4, 4); + + $info = $this->getInfo(); + + $flags = $info->hasAlphaChannel() ? self::ALPHA_FLAG : self::NO_FLAG; + + $canvas = Bytes::pack($info->width() - 1, 3, Endianness::Little) . Bytes::pack($info->height() - 1, 3, Endianness::Little); + + return [['VP8X', Bytes::pack($flags, 1) . "\x00\x00\x00" . $canvas], ...$chunks]; } /** - * Encode a WebP chunk + * Remove all the chunks of a given type + * + * @param list $chunks + * + * @return list */ - private function encodeChunk(string $type, string $data): string + private function withoutChunks(array $chunks, string $type): array { - $length = strlen($data); - $data = $length % 2 !== 0 ? $data . "\x00" : $data; - return $type . pack('V', $length) . $data; + return array_values(array_filter($chunks, static fn(array $chunk): bool => $chunk[0] !== $type)); } /** - * Set VP8X chunk + * Set the chunks of the image, updating the flags of the VP8X chunk accordingly + * + * @param list $chunks */ - private function setVP8XChunk(): void + private function setChunks(array $chunks): void { - if (!str_contains(substr($this->data, 12), 'VP8X')) { - $info = $this->getInfo(); - $VP8XFlags = $info->hasAlphaChannel() ? self::ALPHA_FLAG : self::NO_FLAG; - $data = chr($VP8XFlags) . "\x0\x0\x0" . substr(pack('V', $info->width() - 1), 0, 3) . substr(pack('V', $info->height() - 1), 0, 3); - $chunk = $this->encodeChunk('VP8X', $data); - $this->data = substr_replace($this->data, $chunk, 12, 0); - $this->updateRIFFHeader(); + $types = array_column($chunks, 0); + + if (($chunks[0][0] ?? null) === 'VP8X') { + $flags = Bytes::unpack($chunks[0][1], 0, 1) & ~(self::ICC_FLAG | self::EXIF_FLAG); + $flags |= in_array('ICCP', $types, true) ? self::ICC_FLAG : self::NO_FLAG; + $flags |= in_array('EXIF', $types, true) ? self::EXIF_FLAG : self::NO_FLAG; + $chunks[0][1] = Bytes::pack($flags, 1) . substr($chunks[0][1], 1); + } + + $data = self::WEBP_HEADER; + + foreach ($chunks as [$type, $value]) { + $data .= $this->encodeChunk($type, $value); } + + $this->data = self::RIFF_HEADER . Bytes::pack(strlen($data), 4, Endianness::Little) . $data; } /** - * Unpack data from a binary string - * - * @return array + * Encode a WebP chunk */ - private function unpack(string $format, string $string, int $offset = 0): array + private function encodeChunk(string $type, string $data): string { - return unpack($format, $string, $offset) ?: throw new UnexpectedValueException('Cannot unpack string'); + $length = strlen($data); + $data = $length % 2 !== 0 ? $data . "\x00" : $data; + return $type . pack('V', $length) . $data; } } diff --git a/tests/Unit/Images/Decoder/WebpDecoderTest.php b/tests/Unit/Images/Decoder/WebpDecoderTest.php new file mode 100644 index 000000000..c1f4f8b77 --- /dev/null +++ b/tests/Unit/Images/Decoder/WebpDecoderTest.php @@ -0,0 +1,88 @@ +decode($data), false); + + $this->assertSame(['VP8X', 'EXIF'], array_column($chunks, 'type')); + $this->assertSame([12, 30], array_column($chunks, 'offset')); + $this->assertSame([10, 3], array_column($chunks, 'size')); + $this->assertSame([30, 42], array_column($chunks, 'position')); + } + + public function testPaddingIsNotPartOfTheValue(): void + { + $data = GdImages::webpFile(GdImages::webpChunk('ICCP', 'abc'), GdImages::webpChunk('EXIF', 'de')); + + $chunks = iterator_to_array((new WebpDecoder())->decode($data), false); + + $this->assertSame(['abc', 'de'], array_column($chunks, 'value')); + $this->assertSame(24, $chunks[0]['position']); + } + + public function testMissingPaddingOfTheLastChunkIsTolerated(): void + { + $data = substr(GdImages::webpFile(GdImages::webpChunk('EXIF', 'abc')), 0, -1); + + $chunks = iterator_to_array((new WebpDecoder())->decode($data), false); + + $this->assertSame(['abc'], array_column($chunks, 'value')); + } + + public function testDataAfterTheRiffContainerIsIgnored(): void + { + $data = GdImages::webpFile(GdImages::webpChunk('EXIF', 'ab')) . GdImages::webpChunk('EXIF', 'cd'); + + $this->assertCount(1, iterator_to_array((new WebpDecoder())->decode($data))); + } + + public function testRealImagesAreDecoded(): void + { + $data = GdImages::webp(); + + $types = array_column(iterator_to_array((new WebpDecoder())->decode($data), false), 'type'); + + $this->assertNotEmpty(array_intersect($types, ['VP8 ', 'VP8L'])); + } + + public function testInvalidHeadersAreNotDecoded(): void + { + $data = 'not a WebP image'; + + $this->expectException(InvalidArgumentException::class); + + iterator_to_array((new WebpDecoder())->decode($data)); + } + + public function testRiffFilesOfAnotherTypeAreNotDecoded(): void + { + $data = 'RIFF' . pack('V', 4) . 'WAVE'; + + $this->expectException(InvalidArgumentException::class); + + iterator_to_array((new WebpDecoder())->decode($data)); + } + + public function testChunksLongerThanTheDataAreNotDecoded(): void + { + $data = 'RIFF' . pack('V', 20) . 'WEBP' . 'EXIF' . pack('V', 100) . 'short'; + + $this->expectException(UnexpectedValueException::class); + + iterator_to_array((new WebpDecoder())->decode($data)); + } +} diff --git a/tests/Unit/Images/Handler/WebpHandlerTest.php b/tests/Unit/Images/Handler/WebpHandlerTest.php new file mode 100644 index 000000000..1acfe1ce3 --- /dev/null +++ b/tests/Unit/Images/Handler/WebpHandlerTest.php @@ -0,0 +1,286 @@ +getInfo(); + + $this->assertSame('image/webp', $info->mimeType()); + $this->assertSame(40, $info->width()); + $this->assertSame(30, $info->height()); + $this->assertSame(ColorSpace::RGB, $info->colorSpace()); + $this->assertSame(8, $info->colorDepth()); + $this->assertFalse($info->isAnimation()); + } + + public function testInfoIsReadFromLossyImages(): void + { + $frame = "\x10\x00\x00\x9d\x01\x2a" . pack('vv', 0xc000 | 123, 0x4000 | 45); + $handler = new WebpHandler(GdImages::webpFile(GdImages::webpChunk('VP8 ', $frame))); + + $this->assertSame(123, $handler->getInfo()->width()); + $this->assertSame(45, $handler->getInfo()->height()); + $this->assertFalse($handler->getInfo()->hasAlphaChannel()); + } + + public function testInfoIsReadFromLosslessImages(): void + { + $bits = (320 - 1) | ((200 - 1) << 14) | (1 << 28); + $handler = new WebpHandler(GdImages::webpFile(GdImages::webpChunk('VP8L', "\x2f" . pack('V', $bits)))); + + $this->assertSame(320, $handler->getInfo()->width()); + $this->assertSame(200, $handler->getInfo()->height()); + $this->assertTrue($handler->getInfo()->hasAlphaChannel()); + } + + public function testInfoIsReadFromExtendedImages(): void + { + $handler = new WebpHandler(GdImages::webpFile(GdImages::webpChunk('VP8X', "\x10\x00\x00\x00" . substr(pack('V', 999), 0, 3) . substr(pack('V', 499), 0, 3)))); + + $this->assertSame(1000, $handler->getInfo()->width()); + $this->assertSame(500, $handler->getInfo()->height()); + $this->assertTrue($handler->getInfo()->hasAlphaChannel()); + } + + public function testAlphaChannelsAreRecognizedFromTheirChunk(): void + { + $frame = "\x10\x00\x00\x9d\x01\x2a" . pack('vv', 8, 8); + $handler = new WebpHandler(GdImages::webpFile(GdImages::webpChunk('ALPH', "\x00"), GdImages::webpChunk('VP8 ', $frame))); + + $this->assertTrue($handler->getInfo()->hasAlphaChannel()); + } + + public function testAnimationsAreRecognized(): void + { + $handler = new WebpHandler(GdImages::webpFile( + GdImages::webpChunk('VP8X', "\x02\x00\x00\x00\x09\x00\x00\x09\x00\x00"), + GdImages::webpChunk('ANIM', "\x00\x00\x00\x00" . pack('v', 3)), + GdImages::webpChunk('ANMF', str_repeat("\x00", 16)), + GdImages::webpChunk('ANMF', str_repeat("\x00", 16)), + )); + + $info = $handler->getInfo(); + + $this->assertTrue($info->isAnimation()); + $this->assertSame(2, $info->animationFrames()); + $this->assertSame(3, $info->animationRepeatCount()); + $this->assertFalse($handler->supportsTransforms()); + } + + public function testTruncatedChunksAreNotAccepted(): void + { + $handler = new WebpHandler(GdImages::webpFile(GdImages::webpChunk('VP8L', "\x2f\x00"))); + + $this->expectException(UnexpectedValueException::class); + + $handler->getInfo(); + } + + public function testInvalidExtendedHeadersAreNotAccepted(): void + { + $handler = new WebpHandler(GdImages::webpFile(GdImages::webpChunk('VP8X', "\x00\x00"))); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Invalid VP8X chunk'); + + $handler->getInfo(); + } + + public function testColorProfilesAreSetAndRead(): void + { + $handler = new WebpHandler(GdImages::webp()); + + $this->assertFalse($handler->hasColorProfile()); + $this->assertNull($handler->getColorProfile()); + + $handler->setColorProfile($this->colorProfile()); + + $this->assertTrue($handler->hasColorProfile()); + $this->assertSame($this->colorProfile()->getData(), $handler->getColorProfile()?->getData()); + $this->assertSame(40, $handler->getInfo()->width()); + $this->assertWellFormed($handler); + } + + public function testColorProfilesOfOddSizeAreNotPadded(): void + { + $profile = new ColorProfile($this->colorProfile()->getData() . 'x'); + $handler = new WebpHandler(GdImages::webp()); + + $handler->setColorProfile($profile); + + $this->assertSame($profile->getData(), $handler->getColorProfile()?->getData()); + $this->assertWellFormed($handler); + } + + public function testColorProfilesAreReplacedInsteadOfDuplicated(): void + { + $handler = new WebpHandler(GdImages::webp()); + + $handler->setColorProfile($this->colorProfile()); + $handler->setColorProfile($this->colorProfile()); + + $this->assertSame(1, $this->countChunks($handler, 'ICCP')); + } + + public function testColorProfilesFollowTheExtendedHeader(): void + { + $handler = new WebpHandler(GdImages::webp()); + $handler->setExifData($this->exifData()); + + $handler->setColorProfile($this->colorProfile()); + + $this->assertSame(['VP8X', 'ICCP'], array_slice($this->chunkTypes($handler), 0, 2)); + } + + public function testColorProfilesAreRemoved(): void + { + $handler = new WebpHandler(GdImages::webp()); + $handler->setColorProfile($this->colorProfile()); + + $handler->removeColorProfile(); + + $this->assertFalse($handler->hasColorProfile()); + $this->assertSame(0, $this->flags($handler) & 0b00100000); + $this->assertWellFormed($handler); + } + + public function testExifDataIsSetAndRead(): void + { + $handler = new WebpHandler(GdImages::webp()); + + $this->assertFalse($handler->hasExifData()); + $this->assertNull($handler->getExifData()); + + $handler->setExifData($this->exifData()); + + $this->assertTrue($handler->hasExifData()); + $this->assertSame($this->exifData()->getData(), $handler->getExifData()?->getData()); + $this->assertSame(0b00001000, $this->flags($handler) & 0b00001000); + $this->assertWellFormed($handler); + } + + public function testExifDataIsReplacedInsteadOfDuplicated(): void + { + $handler = new WebpHandler(GdImages::webp()); + + $handler->setExifData($this->exifData()); + $handler->setExifData($this->exifData()); + + $this->assertSame(1, $this->countChunks($handler, 'EXIF')); + } + + public function testExifDataPrecedesXmpMetadata(): void + { + $frame = "\x10\x00\x00\x9d\x01\x2a" . pack('vv', 8, 8); + $handler = new WebpHandler(GdImages::webpFile(GdImages::webpChunk('VP8 ', $frame), GdImages::webpChunk('XMP ', ''))); + + $handler->setExifData($this->exifData()); + + $this->assertSame(['VP8X', 'VP8 ', 'EXIF', 'XMP '], $this->chunkTypes($handler)); + } + + public function testExifDataIsRemoved(): void + { + $handler = new WebpHandler(GdImages::webp()); + $handler->setExifData($this->exifData()); + $handler->setColorProfile($this->colorProfile()); + + $handler->removeExifData(); + + $this->assertFalse($handler->hasExifData()); + $this->assertTrue($handler->hasColorProfile()); + $this->assertSame(0, $this->flags($handler) & 0b00001000); + $this->assertSame(0b00100000, $this->flags($handler) & 0b00100000); + $this->assertWellFormed($handler); + } + + public function testRemovingMissingMetadataLeavesTheImageDecodable(): void + { + $handler = new WebpHandler(GdImages::webp()); + + $handler->removeColorProfile(); + $handler->removeExifData(); + + $this->assertWellFormed($handler); + $this->assertSame(40, $handler->getInfo()->width()); + } + + public function testEncodedImagesCanHoldMetadata(): void + { + $handler = WebpHandler::fromGdImage(GdImages::image(16, 12)); + + $this->assertSame('VP8X', $this->chunkTypes($handler)[0]); + $this->assertSame(16, $handler->getInfo()->width()); + $this->assertSame(12, $handler->getInfo()->height()); + $this->assertTrue($handler->getInfo()->hasAlphaChannel()); + + $handler->setExifData($this->exifData()); + + $this->assertWellFormed($handler); + } + + private function colorProfile(): ColorProfile + { + return new ColorProfile((string) file_get_contents(self::FIXTURES . '/srgb.icc')); + } + + private function exifData(): ExifData + { + return new ExifData((string) file_get_contents(self::FIXTURES . '/exif.tiff')); + } + + /** + * @return list<string> + */ + private function chunkTypes(WebpHandler $handler): array + { + $types = []; + $data = $handler->getData(); + $position = 12; + + while ($position < strlen($data)) { + $types[] = substr($data, $position, 4); + $size = Bytes::unpack($data, $position + 4, 4, Endianness::Little); + $position += 8 + $size + $size % 2; + } + + return $types; + } + + private function countChunks(WebpHandler $handler, string $type): int + { + return count(array_keys($this->chunkTypes($handler), $type, true)); + } + + private function flags(WebpHandler $handler): int + { + return ord($handler->getData()[20]); + } + + private function assertWellFormed(WebpHandler $handler): void + { + $data = $handler->getData(); + + $this->assertSame('RIFF', substr($data, 0, 4)); + $this->assertSame(strlen($data) - 8, Bytes::unpack($data, 4, 4, Endianness::Little)); + $this->assertSame(0, strlen($data) % 2); + $this->assertNotFalse(@imagecreatefromstring($data)); + } +} From 2d2658a6d4450fa4edbc4ad249e4a2ae13cc1a76 Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Sat, 31 Oct 2026 10:22:15 +0100 Subject: [PATCH 08/10] Read SVG dimensions with units and view box The dimensions were cast to integers as they were, so relative or suffixed values gave wrong sizes, such as 100 for "100%" and 0 for documents sized only by their view box, and prefixed root elements were rejected. Convert absolute lengths to pixels, fall back to the view box for missing or relative sizes and match the root element by its local name. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- formwork/src/Images/Decoder/SvgDecoder.php | 64 ++++++++++++-- tests/Unit/Images/Decoder/SvgDecoderTest.php | 79 +++++++++++++++++ tests/Unit/Images/Handler/SvgHandlerTest.php | 91 ++++++++++++++++++++ 3 files changed, 226 insertions(+), 8 deletions(-) create mode 100644 tests/Unit/Images/Decoder/SvgDecoderTest.php create mode 100644 tests/Unit/Images/Handler/SvgHandlerTest.php diff --git a/formwork/src/Images/Decoder/SvgDecoder.php b/formwork/src/Images/Decoder/SvgDecoder.php index 841575f6c..62663b380 100644 --- a/formwork/src/Images/Decoder/SvgDecoder.php +++ b/formwork/src/Images/Decoder/SvgDecoder.php @@ -29,21 +29,69 @@ public function decode(string &$data): Generator $root = $domDocument->documentElement; - if (!$root instanceof DOMElement || $root->nodeName !== 'svg') { + if (!$root instanceof DOMElement || $root->localName !== 'svg') { throw new InvalidArgumentException('Invalid SVG data'); } - if (!$root->hasAttribute('width')) { - return; - } + $width = $this->parseLength($root->getAttribute('width')); + $height = $this->parseLength($root->getAttribute('height')); + + // Fall back to the view box when the size is missing or relative + if ($width === null || $height === null) { + $viewBox = $this->parseViewBox($root->getAttribute('viewBox')); + + if ($viewBox === null) { + return; + } + + [$boxWidth, $boxHeight] = $viewBox; - if (!$root->hasAttribute('height')) { - return; + $width ??= $height !== null && $boxHeight > 0 ? $height * $boxWidth / $boxHeight : $boxWidth; + $height ??= $boxWidth > 0 ? $width * $boxHeight / $boxWidth : $boxHeight; } yield [ - 'width' => $root->getAttribute('width'), - 'height' => $root->getAttribute('height'), + 'width' => (int) round($width), + 'height' => (int) round($height), ]; } + + /** + * Parse an absolute length and convert it to pixels + * + * Relative lengths (percentages and font-relative units) cannot be resolved without rendering the image. + */ + private function parseLength(string $length): ?float + { + if (preg_match('/^\s*(\d+(?:\.\d+)?|\.\d+)(px|pt|pc|mm|cm|in)?\s*$/i', $length, $matches) !== 1) { + return null; + } + + $value = (float) $matches[1]; + + return $value * match (strtolower($matches[2] ?? '')) { + 'pt' => 96 / 72, + 'pc' => 16, + 'mm' => 96 / 25.4, + 'cm' => 96 / 2.54, + 'in' => 96, + default => 1, + }; + } + + /** + * Parse the width and the height of a view box + * + * @return ?array{float, float} + */ + private function parseViewBox(string $viewBox): ?array + { + $values = preg_split('/[\s,]+/', trim($viewBox), -1, PREG_SPLIT_NO_EMPTY) ?: []; + + if (count($values) !== 4 || !is_numeric($values[2]) || !is_numeric($values[3])) { + return null; + } + + return [max(0.0, (float) $values[2]), max(0.0, (float) $values[3])]; + } } diff --git a/tests/Unit/Images/Decoder/SvgDecoderTest.php b/tests/Unit/Images/Decoder/SvgDecoderTest.php new file mode 100644 index 000000000..cbb1ce0df --- /dev/null +++ b/tests/Unit/Images/Decoder/SvgDecoderTest.php @@ -0,0 +1,79 @@ +<?php + +namespace Formwork\Tests\Unit\Images\Decoder; + +use Formwork\Images\Decoder\SvgDecoder; +use Formwork\Tests\TestCase; +use InvalidArgumentException; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; + +#[CoversClass(SvgDecoder::class)] +final class SvgDecoderTest extends TestCase +{ + /** + * @param ?array{width: int, height: int} $expected + */ + #[DataProvider('dimensionsProvider')] + public function testDimensionsAreDecoded(string $attributes, ?array $expected): void + { + $data = sprintf('<svg xmlns="http://www.w3.org/2000/svg" %s></svg>', $attributes); + + $decoded = iterator_to_array((new SvgDecoder())->decode($data), false); + + $this->assertSame($expected === null ? [] : [$expected], $decoded); + } + + /** + * @return iterable<string, array{string, ?array{width: int, height: int}}> + */ + public static function dimensionsProvider(): iterable + { + yield 'unitless' => ['width="100" height="50"', ['width' => 100, 'height' => 50]]; + yield 'pixels' => ['width="100px" height="50.4px"', ['width' => 100, 'height' => 50]]; + yield 'points' => ['width="72pt" height="36pt"', ['width' => 96, 'height' => 48]]; + yield 'inches' => ['width="2in" height="1in"', ['width' => 192, 'height' => 96]]; + yield 'view box' => ['viewBox="0 0 640 480"', ['width' => 640, 'height' => 480]]; + yield 'view box with commas' => ['viewBox="0,0,64,48"', ['width' => 64, 'height' => 48]]; + yield 'percentages' => ['width="100%" height="100%" viewBox="0 0 20 10"', ['width' => 20, 'height' => 10]]; + yield 'width only' => ['width="200" viewBox="0 0 20 10"', ['width' => 200, 'height' => 100]]; + yield 'height only' => ['height="100" viewBox="0 0 20 10"', ['width' => 200, 'height' => 100]]; + yield 'no dimensions' => ['', null]; + yield 'invalid view box' => ['viewBox="0 0 a b"', null]; + yield 'relative units' => ['width="10em" height="5em"', null]; + } + + public function testPrefixedRootElementsAreDecoded(): void + { + $data = '<s:svg xmlns:s="http://www.w3.org/2000/svg" width="10" height="20"/>'; + + $this->assertSame([['width' => 10, 'height' => 20]], iterator_to_array((new SvgDecoder())->decode($data), false)); + } + + public function testMalformedXmlIsNotDecodedAndRaisesNoWarnings(): void + { + $data = '<svg width="10"'; + + $this->expectException(InvalidArgumentException::class); + + iterator_to_array((new SvgDecoder())->decode($data)); + } + + #[DataProvider('invalidDataProvider')] + public function testInvalidDataIsNotDecoded(string $data): void + { + $this->expectException(InvalidArgumentException::class); + + iterator_to_array((new SvgDecoder())->decode($data)); + } + + /** + * @return iterable<string, array{string}> + */ + public static function invalidDataProvider(): iterable + { + yield 'empty' => ['']; + yield 'another root element' => ['<html width="10" height="10"/>']; + yield 'not xml' => ['not an SVG image']; + } +} diff --git a/tests/Unit/Images/Handler/SvgHandlerTest.php b/tests/Unit/Images/Handler/SvgHandlerTest.php new file mode 100644 index 000000000..9b39d2509 --- /dev/null +++ b/tests/Unit/Images/Handler/SvgHandlerTest.php @@ -0,0 +1,91 @@ +<?php + +namespace Formwork\Tests\Unit\Images\Handler; + +use Formwork\Images\ColorProfile\ColorProfile; +use Formwork\Images\ColorProfile\ColorSpace; +use Formwork\Images\Exif\ExifData; +use Formwork\Images\Handler\Exceptions\UnsupportedFeatureException; +use Formwork\Images\Handler\SvgHandler; +use Formwork\Tests\TestCase; +use Formwork\Tests\Unit\Images\Fixtures\GdImages; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; + +#[CoversClass(SvgHandler::class)] +final class SvgHandlerTest extends TestCase +{ + private const string FIXTURES = __DIR__ . '/../Fixtures'; + + public function testInfoIsReadFromTheDimensions(): void + { + $info = $this->handler('<svg xmlns="http://www.w3.org/2000/svg" width="120" height="80"/>')->getInfo(); + + $this->assertSame('image/svg+xml', $info->mimeType()); + $this->assertSame(120, $info->width()); + $this->assertSame(80, $info->height()); + $this->assertSame(ColorSpace::RGB, $info->colorSpace()); + $this->assertTrue($info->hasAlphaChannel()); + $this->assertFalse($info->isAnimation()); + } + + public function testInfoIsReadFromTheViewBoxWhenTheSizeIsRelative(): void + { + $info = $this->handler('<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 48 24"/>')->getInfo(); + + $this->assertSame(48, $info->width()); + $this->assertSame(24, $info->height()); + } + + public function testImagesWithoutDimensionsHaveNoSize(): void + { + $info = $this->handler('<svg xmlns="http://www.w3.org/2000/svg"/>')->getInfo(); + + $this->assertSame(0, $info->width()); + $this->assertSame(0, $info->height()); + } + + public function testTransformsAndMetadataAreNotSupported(): void + { + $handler = $this->handler('<svg xmlns="http://www.w3.org/2000/svg"/>'); + + $this->assertFalse($handler->supportsTransforms()); + $this->assertFalse(SvgHandler::supportsColorProfile()); + $this->assertFalse(SvgHandler::supportsExifData()); + $this->assertFalse($handler->hasColorProfile()); + $this->assertFalse($handler->hasExifData()); + } + + /** + * @param callable(SvgHandler): mixed $operation + */ + #[DataProvider('unsupportedOperationProvider')] + public function testUnsupportedOperationsAreRejected(callable $operation): void + { + $this->expectException(UnsupportedFeatureException::class); + + $operation($this->handler('<svg xmlns="http://www.w3.org/2000/svg"/>')); + } + + /** + * @return iterable<string, array{callable(SvgHandler): mixed}> + */ + public static function unsupportedOperationProvider(): iterable + { + $profile = new ColorProfile((string) file_get_contents(self::FIXTURES . '/srgb.icc')); + $exif = new ExifData((string) file_get_contents(self::FIXTURES . '/exif.tiff')); + + yield 'get color profile' => [static fn(SvgHandler $h): ?ColorProfile => $h->getColorProfile()]; + yield 'set color profile' => [static fn(SvgHandler $h) => $h->setColorProfile($profile)]; + yield 'remove color profile' => [static fn(SvgHandler $h) => $h->removeColorProfile()]; + yield 'get exif data' => [static fn(SvgHandler $h): ?ExifData => $h->getExifData()]; + yield 'set exif data' => [static fn(SvgHandler $h) => $h->setExifData($exif)]; + yield 'remove exif data' => [static fn(SvgHandler $h) => $h->removeExifData()]; + yield 'from gd image' => [static fn(SvgHandler $h) => SvgHandler::fromGdImage(GdImages::image())]; + } + + private function handler(string $data): SvgHandler + { + return new SvgHandler($data); + } +} From 3f67150aef48a6c8a032c50c8f1e4ca9de08871c Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Sat, 31 Oct 2026 10:22:16 +0100 Subject: [PATCH 09/10] Read ICC profiles through ByteReader A truncated tag table, a tag outside the profile or a string with invalid bounds raised PHP warnings or generic errors, and the signature was looked up in the whole profile. Check the signature at its offset, read the tag table and the string tags through ByteReader and reject profiles whose tag table is invalid with an InvalidArgumentException. Malformed tag values fall back to the default value instead of failing. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- .../src/Images/ColorProfile/ColorProfile.php | 112 +++++++----- .../Images/ColorProfile/ColorProfileTest.php | 172 ++++++++++++++++++ 2 files changed, 241 insertions(+), 43 deletions(-) create mode 100644 tests/Unit/Images/ColorProfile/ColorProfileTest.php diff --git a/formwork/src/Images/ColorProfile/ColorProfile.php b/formwork/src/Images/ColorProfile/ColorProfile.php index f8ac1d5dd..1eb384711 100644 --- a/formwork/src/Images/ColorProfile/ColorProfile.php +++ b/formwork/src/Images/ColorProfile/ColorProfile.php @@ -2,6 +2,8 @@ namespace Formwork\Images\ColorProfile; +use Formwork\Images\Binary\ByteReader; +use Formwork\Images\Binary\Bytes; use Formwork\Utils\FileSystem; use InvalidArgumentException; use UnexpectedValueException; @@ -31,11 +33,15 @@ class ColorProfile public function __construct( protected string $data, ) { - if (strpos($this->data, self::ICC_PROFILE_SIGNATURE) !== self::ICC_PROFILE_SIGNATURE_OFFSET) { + if (substr($this->data, self::ICC_PROFILE_SIGNATURE_OFFSET, strlen(self::ICC_PROFILE_SIGNATURE)) !== self::ICC_PROFILE_SIGNATURE) { throw new InvalidArgumentException('Invalid ICC profile data'); } - $this->tags = $this->getTags(); + try { + $this->tags = $this->getTags(); + } catch (UnexpectedValueException $unexpectedValueException) { + throw new InvalidArgumentException('Invalid ICC profile data', $unexpectedValueException->getCode(), previous: $unexpectedValueException); + } } /** @@ -144,7 +150,7 @@ public function primaryPlatform(): string */ public function renderingIntent(): RenderingIntent { - $renderingIntent = $this->unpack('N', $this->data, 64)[1]; + $renderingIntent = Bytes::unpack($this->data, 64, 4); return match ($renderingIntent) { 0 => RenderingIntent::Perceptual, 1 => RenderingIntent::MediaRelative, @@ -181,21 +187,32 @@ public static function fromFile(string $path): ColorProfile /** * Get the color profile tags * - * @return array<string, mixed> + * @throws UnexpectedValueException If the tag table is truncated or a tag is outside the profile + * + * @return array<string, array{offset: int, length: int}> */ protected function getTags(): array { $tags = []; - $position = 128; - $count = $this->unpack('N', $this->data, $position)[1]; - $position += 4; + $reader = new ByteReader($this->data, 128); + $count = $reader->uint(4); + + if ($count * 12 > $reader->remaining()) { + throw new UnexpectedValueException('Unexpected end of data'); + } + for ($i = 0; $i < $count; $i++) { - $info = $this->unpack('Z4tag/Noffset/Nlength', $this->data, $position); - /** @var string */ - $tag = array_shift($info); - $tags[$tag] = $info; - $position += 12; + $tag = $reader->bytes(4); + $offset = $reader->uint(4); + $length = $reader->uint(4); + + if ($offset + $length > strlen($this->data)) { + throw new UnexpectedValueException(sprintf('Tag "%s" is outside the profile', $tag)); + } + + $tags[$tag] = ['offset' => $offset, 'length' => $length]; } + return $tags; } @@ -209,40 +226,60 @@ protected function getTagValue(string $name, ?string $default = null): mixed } ['offset' => $offset, 'length' => $length] = $this->tags[$name]; $value = substr($this->data, $offset, $length); - $type = substr($value, 0, 4); - return match ($type) { - 'text' => substr($value, 8), - 'desc' => $this->unpack('Z*', $value, 12)[1], - 'mluc' => array_values($this->parseMlucString($value))[0] ?? $default, - default => $default, - }; + try { + return match (substr($value, 0, 4)) { + 'text' => rtrim(substr($value, 8), "\x00"), + 'desc' => $this->parseDescString($value), + 'mluc' => array_values($this->parseMlucString($value))[0] ?? $default, + default => $default, + }; + } catch (UnexpectedValueException) { + return $default; + } + } + + /** + * Parse a textDescriptionType string + * + * @throws UnexpectedValueException If the string is truncated + */ + protected function parseDescString(string $data): string + { + $reader = new ByteReader($data, 8); + $length = $reader->uint(4); + + return rtrim($reader->bytes(min($length, $reader->remaining())), "\x00"); } /** * Parse a multi-lingual Unicode string * * @throws InvalidArgumentException If the mluc tag is invalid - * @throws UnexpectedValueException If the mluc string cannot be converted to UTF-8 + * @throws UnexpectedValueException If the mluc string is truncated or cannot be converted to UTF-8 * * @return array<string, string> */ protected function parseMlucString(string $data): array { $result = []; - $position = 0; - $type = substr($data, 0, 4); - if ($type !== 'mluc') { + + if (!str_starts_with($data, 'mluc')) { throw new InvalidArgumentException('Invalid mluc tag'); } - $position += 8; - $records = $this->unpack('N', $data, $position)[1]; - $position += 8; + + $reader = new ByteReader($data, 8); + $records = $reader->uint(4); + $reader->skip(4); + for ($i = 0; $i < $records; $i++) { - $langCode = substr($data, $position, 4); - $position += 4; - $stringLength = $this->unpack('N', $data, $position)[1]; - $position += 4; - $stringOffset = $this->unpack('N', $data, $position)[1]; + $langCode = $reader->bytes(4); + $stringLength = $reader->uint(4); + $stringOffset = $reader->uint(4); + + if ($stringOffset + $stringLength > strlen($data)) { + throw new UnexpectedValueException('Unexpected end of data'); + } + $value = mb_convert_encoding(substr($data, $stringOffset, $stringLength), 'UTF-8', 'UTF-16BE'); // @phpstan-ignore identical.alwaysFalse if ($value === false) { @@ -250,18 +287,7 @@ protected function parseMlucString(string $data): array } $result[$langCode] = $value; } - return $result; - } - /** - * Unpack data from a binary string - * - * @throws UnexpectedValueException If the string cannot be unpacked - * - * @return array<int|string, mixed> - */ - private function unpack(string $format, string $string, int $offset = 0): array - { - return unpack($format, $string, $offset) ?: throw new UnexpectedValueException('Cannot unpack string'); + return $result; } } diff --git a/tests/Unit/Images/ColorProfile/ColorProfileTest.php b/tests/Unit/Images/ColorProfile/ColorProfileTest.php new file mode 100644 index 000000000..f4be189f6 --- /dev/null +++ b/tests/Unit/Images/ColorProfile/ColorProfileTest.php @@ -0,0 +1,172 @@ +<?php + +namespace Formwork\Tests\Unit\Images\ColorProfile; + +use Formwork\Images\ColorProfile\ColorProfile; +use Formwork\Images\ColorProfile\ColorSpace; +use Formwork\Images\ColorProfile\DeviceClass; +use Formwork\Images\ColorProfile\RenderingIntent; +use Formwork\Tests\TestCase; +use InvalidArgumentException; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use UnexpectedValueException; + +#[CoversClass(ColorProfile::class)] +final class ColorProfileTest extends TestCase +{ + private const string FIXTURES = __DIR__ . '/../Fixtures'; + + public function testProfilesAreReadFromAFile(): void + { + $profile = ColorProfile::fromFile(self::FIXTURES . '/srgb.icc'); + + $this->assertSame('sRGB built-in', $profile->name()); + $this->assertSame('No copyright, use freely', $profile->copyright()); + $this->assertSame('4.4.0', $profile->profileVersion()); + $this->assertSame(DeviceClass::Display, $profile->deviceClass()); + $this->assertSame(ColorSpace::RGB, $profile->colorSpace()); + $this->assertSame('XYZ', $profile->connectionSpace()); + $this->assertSame('APPL', $profile->primaryPlatform()); + $this->assertSame(RenderingIntent::Perceptual, $profile->renderingIntent()); + $this->assertSame(file_get_contents(self::FIXTURES . '/srgb.icc'), $profile->getData()); + } + + public function testProfilesInAnotherColorSpaceAreRead(): void + { + $profile = ColorProfile::fromFile(self::FIXTURES . '/lab.icc'); + + $this->assertSame('Lab identity built-in', $profile->name()); + $this->assertSame('2.1.0', $profile->profileVersion()); + $this->assertSame(DeviceClass::AbstractProfile, $profile->deviceClass()); + $this->assertSame(ColorSpace::LAB, $profile->colorSpace()); + $this->assertSame('Lab', $profile->connectionSpace()); + } + + public function testProfilesAreExported(): void + { + $path = sys_get_temp_dir() . '/formwork-profile-' . bin2hex(random_bytes(4)) . '.icc'; + $profile = ColorProfile::fromFile(self::FIXTURES . '/srgb.icc'); + + try { + $profile->export($path); + $this->assertSame($profile->getData(), file_get_contents($path)); + } finally { + @unlink($path); + } + } + + public function testLegacyDescriptionsAndTextTagsAreRead(): void + { + $desc = "desc\x00\x00\x00\x00" . pack('N', 7) . "Legacy\x00" . str_repeat("\x00", 20); + $text = "text\x00\x00\x00\x00" . "Copyright\x00"; + + $profile = new ColorProfile($this->profile(['desc' => $desc, 'cprt' => $text])); + + $this->assertSame('Legacy', $profile->name()); + $this->assertSame('Copyright', $profile->copyright()); + } + + public function testMultiLocalizedStringsAreRead(): void + { + $string = mb_convert_encoding('Naïve', 'UTF-16BE', 'UTF-8'); + $mluc = "mluc\x00\x00\x00\x00" . pack('NN', 1, 12) . 'enUS' . pack('NN', strlen($string), 28) . $string; + + $profile = new ColorProfile($this->profile(['desc' => $mluc])); + + $this->assertSame('Naïve', $profile->name()); + } + + public function testMissingAndUnknownTagsHaveDefaults(): void + { + $profile = new ColorProfile($this->profile(['desc' => 'xxxxxxxx'])); + + $this->assertSame('', $profile->name()); + $this->assertSame('', $profile->copyright()); + } + + public function testMalformedTagValuesHaveDefaults(): void + { + $truncatedDesc = "desc\x00\x00\x00\x00\x00\x00"; + $outOfRangeMluc = "mluc\x00\x00\x00\x00" . pack('NN', 1, 12) . 'enUS' . pack('NN', 100, 28); + $truncatedMluc = "mluc\x00\x00\x00\x00" . pack('NN', 5, 12); + + foreach ([$truncatedDesc, $outOfRangeMluc, $truncatedMluc] as $value) { + $this->assertSame('', (new ColorProfile($this->profile(['desc' => $value])))->name()); + } + } + + public function testUnrestrictedDescriptionLengthsAreLimitedToTheTag(): void + { + $desc = "desc\x00\x00\x00\x00" . pack('N', 1000) . "Short\x00"; + + $this->assertSame('Short', (new ColorProfile($this->profile(['desc' => $desc])))->name()); + } + + #[DataProvider('invalidProfileProvider')] + public function testInvalidProfilesAreNotAccepted(string $data): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid ICC profile data'); + + new ColorProfile($data); + } + + /** + * @return iterable<string, array{string}> + */ + public static function invalidProfileProvider(): iterable + { + yield 'empty' => ['']; + yield 'missing signature' => [str_repeat("\x00", 200)]; + yield 'signature at the wrong offset' => [str_repeat("\x00", 40) . 'acsp' . str_repeat("\x00", 200)]; + yield 'missing tag table' => [substr(str_repeat("\x00", 36) . 'acsp', 0, 40)]; + yield 'truncated tag count' => [str_repeat("\x00", 36) . 'acsp' . str_repeat("\x00", 88) . "\x00\x00"]; + yield 'tag table longer than the data' => [self::header() . pack('N', 5) . 'desc' . pack('NN', 144, 4)]; + yield 'huge tag count' => [self::header() . pack('N', 0xffffffff)]; + yield 'tag outside the profile' => [self::header() . pack('N', 1) . 'desc' . pack('NN', 1000, 20)]; + } + + #[DataProvider('unexpectedValueProvider')] + public function testUnexpectedHeaderValuesAreNotAccepted(int $offset, string $value, string $method, string $message): void + { + $profile = new ColorProfile(substr_replace($this->profile([]), $value, $offset, strlen($value))); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage($message); + + $profile->{$method}(); + } + + /** + * @return iterable<string, array{int, string, string, string}> + */ + public static function unexpectedValueProvider(): iterable + { + yield 'device class' => [12, 'zzzz', 'deviceClass', 'Unexpected device class']; + yield 'color space' => [16, 'zzzz', 'colorSpace', 'Unexpected color space']; + yield 'rendering intent' => [64, pack('N', 9), 'renderingIntent', 'Unexpected rendering intent']; + } + + /** + * @param array<string, string> $tags + */ + private function profile(array $tags): string + { + $table = pack('N', count($tags)); + $body = ''; + $offset = 128 + 4 + 12 * count($tags); + + foreach ($tags as $signature => $value) { + $table .= $signature . pack('NN', $offset + strlen($body), strlen($value)); + $body .= $value; + } + + return self::header() . $table . $body; + } + + private static function header(): string + { + return str_pad(str_repeat("\x00", 36) . 'acsp', 128, "\x00"); + } +} From 457aeebbd827ba08ee5cfcc41f46c3cae64a1e21 Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Sun, 1 Nov 2026 10:15:00 +0100 Subject: [PATCH 10/10] Drop color profiles that do not match the processed RGB data Processing always produces RGB pixels through GD, so carrying over a CMYK or grayscale profile made the converted image inconsistent, and some decoders (e.g. AVIF in browsers) refused to display it. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- .../src/Images/Handler/AbstractHandler.php | 18 ++++- .../Images/Handler/AbstractHandlerTest.php | 68 +++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/Images/Handler/AbstractHandlerTest.php diff --git a/formwork/src/Images/Handler/AbstractHandler.php b/formwork/src/Images/Handler/AbstractHandler.php index 8fb23c5a4..036763644 100644 --- a/formwork/src/Images/Handler/AbstractHandler.php +++ b/formwork/src/Images/Handler/AbstractHandler.php @@ -2,6 +2,8 @@ namespace Formwork\Images\Handler; +use Formwork\Images\ColorProfile\ColorProfile; +use Formwork\Images\ColorProfile\ColorSpace; use Formwork\Images\Decoder\DecoderInterface; use Formwork\Images\Transform\TransformCollection; use Formwork\Utils\FileSystem; @@ -90,7 +92,7 @@ public function process(?TransformCollection $transformCollection = null, ?strin $imageInfo = $this->getInfo(); if ($this->options['preserveColorProfile'] && $this->hasColorProfile() && $handler::supportsColorProfile()) { - $colorProfile = $this->getColorProfile(); + $colorProfile = $this->getRgbColorProfile(); } if ($this->options['preserveExifData'] && $this->hasExifData() && $handler::supportsExifData()) { @@ -147,4 +149,18 @@ protected function toGdImage(): GdImage return $image; } + + /** + * Get the color profile only if it describes RGB data, as processed images are always RGB + */ + private function getRgbColorProfile(): ?ColorProfile + { + $colorProfile = $this->getColorProfile(); + + try { + return $colorProfile?->colorSpace() === ColorSpace::RGB ? $colorProfile : null; + } catch (UnexpectedValueException) { + return null; + } + } } diff --git a/tests/Unit/Images/Handler/AbstractHandlerTest.php b/tests/Unit/Images/Handler/AbstractHandlerTest.php new file mode 100644 index 000000000..e301d157e --- /dev/null +++ b/tests/Unit/Images/Handler/AbstractHandlerTest.php @@ -0,0 +1,68 @@ +<?php + +namespace Formwork\Tests\Unit\Images\Handler; + +use Formwork\Images\ColorProfile\ColorProfile; +use Formwork\Images\ColorProfile\ColorSpace; +use Formwork\Images\Handler\AbstractHandler; +use Formwork\Images\Handler\AvifHandler; +use Formwork\Images\Handler\JpegHandler; +use Formwork\Images\Handler\PngHandler; +use Formwork\Images\Handler\WebpHandler; +use Formwork\Tests\TestCase; +use Formwork\Tests\Unit\Images\Fixtures\GdImages; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; + +#[CoversClass(AbstractHandler::class)] +final class AbstractHandlerTest extends TestCase +{ + private const string FIXTURES = __DIR__ . '/../Fixtures'; + + /** + * @return iterable<string, array{class-string<AbstractHandler>}> + */ + public static function targetHandlers(): iterable + { + yield 'WebP' => [WebpHandler::class]; + yield 'JPEG' => [JpegHandler::class]; + yield 'AVIF' => [AvifHandler::class]; + } + + /** + * @param class-string<AbstractHandler> $target + */ + #[DataProvider('targetHandlers')] + public function testRgbColorProfilesArePreservedWhenProcessing(string $target): void + { + $handler = new PngHandler(GdImages::png()); + $handler->setColorProfile($this->colorProfile('RGB ')); + + $processed = $handler->process(null, $target); + + $this->assertSame(ColorSpace::RGB, $processed->getColorProfile()?->colorSpace()); + } + + /** + * @param class-string<AbstractHandler> $target + */ + #[DataProvider('targetHandlers')] + public function testColorProfilesOfOtherColorSpacesAreDroppedWhenProcessing(string $target): void + { + foreach (['CMYK', 'GRAY', 'Lab ', 'ABCD'] as $signature) { + $handler = new PngHandler(GdImages::png()); + $handler->setColorProfile($this->colorProfile($signature)); + + $processed = $handler->process(null, $target); + + $this->assertFalse($processed->hasColorProfile(), $signature); + } + } + + private function colorProfile(string $colorSpace): ColorProfile + { + $data = (string) file_get_contents(self::FIXTURES . '/srgb.icc'); + + return new ColorProfile(substr_replace($data, $colorSpace, 16, 4)); + } +}