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 @@ +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 + * @throws UnexpectedValueException If the tag table is truncated or a tag is outside the profile + * + * @return array */ 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 */ 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 - */ - 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/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/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/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/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/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/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/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/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/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/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/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/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/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 { 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/Binary/ByteReaderTest.php b/tests/Unit/Images/Binary/ByteReaderTest.php new file mode 100644 index 000000000..ccba80e8d --- /dev/null +++ b/tests/Unit/Images/Binary/ByteReaderTest.php @@ -0,0 +1,195 @@ +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']]]; + } +} 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 @@ +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 + */ + 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 + */ + 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 $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"); + } +} 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/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/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/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/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 @@ +', $attributes); + + $decoded = iterator_to_array((new SvgDecoder())->decode($data), false); + + $this->assertSame($expected === null ? [] : [$expected], $decoded); + } + + /** + * @return iterable + */ + 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 = ''; + + $this->assertSame([['width' => 10, 'height' => 20]], iterator_to_array((new SvgDecoder())->decode($data), false)); + } + + public function testMalformedXmlIsNotDecodedAndRaisesNoWarnings(): void + { + $data = '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 + */ + public static function invalidDataProvider(): iterable + { + yield 'empty' => ['']; + yield 'another root element' => ['']; + yield 'not xml' => ['not an SVG image']; + } +} 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/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/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/Fixtures/alternate.tiff b/tests/Unit/Images/Fixtures/alternate.tiff new file mode 100644 index 000000000..2ca5b0a80 Binary files /dev/null and b/tests/Unit/Images/Fixtures/alternate.tiff differ diff --git a/tests/Unit/Images/Fixtures/exif.tiff b/tests/Unit/Images/Fixtures/exif.tiff new file mode 100644 index 000000000..d9aa7003a Binary files /dev/null and b/tests/Unit/Images/Fixtures/exif.tiff differ diff --git a/tests/Unit/Images/Fixtures/image.avif b/tests/Unit/Images/Fixtures/image.avif new file mode 100644 index 000000000..4c782a430 Binary files /dev/null and b/tests/Unit/Images/Fixtures/image.avif differ diff --git a/tests/Unit/Images/Fixtures/lab.icc b/tests/Unit/Images/Fixtures/lab.icc new file mode 100644 index 000000000..2a29fc8c7 Binary files /dev/null and b/tests/Unit/Images/Fixtures/lab.icc differ diff --git a/tests/Unit/Images/Fixtures/srgb.icc b/tests/Unit/Images/Fixtures/srgb.icc new file mode 100644 index 000000000..88ae44dc7 Binary files /dev/null and b/tests/Unit/Images/Fixtures/srgb.icc differ 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 @@ +}> + */ + public static function targetHandlers(): iterable + { + yield 'WebP' => [WebpHandler::class]; + yield 'JPEG' => [JpegHandler::class]; + yield 'AVIF' => [AvifHandler::class]; + } + + /** + * @param class-string $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 $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)); + } +} 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/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 . ';'); + } +} 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())); + } +} 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())); + } +} 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 @@ +handler('')->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('')->getInfo(); + + $this->assertSame(48, $info->width()); + $this->assertSame(24, $info->height()); + } + + public function testImagesWithoutDimensionsHaveNoSize(): void + { + $info = $this->handler('')->getInfo(); + + $this->assertSame(0, $info->width()); + $this->assertSame(0, $info->height()); + } + + public function testTransformsAndMetadataAreNotSupported(): void + { + $handler = $this->handler(''); + + $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('')); + } + + /** + * @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(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); + } +} 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 + */ + 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)); + } +} 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))); + } +}