Skip to content

Commit 0b4f60c

Browse files
committed
Support the simple file format of WEBP
1 parent bc56efd commit 0b4f60c

1 file changed

Lines changed: 157 additions & 13 deletions

File tree

src/Metadata/WebpContainer.php

Lines changed: 157 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
namespace Contao\Image\Metadata;
1414

1515
use Contao\Image\Exception\InvalidImageContainerException;
16+
use Contao\Image\Exception\RuntimeException;
1617

1718
class WebpContainer extends AbstractContainer
1819
{
@@ -24,34 +25,97 @@ public function getMagicBytes(): string
2425
public function apply($inputStream, $outputStream, ImageMetadata $metadata, array $preserveKeysByFormat): void
2526
{
2627
$head = fread($inputStream, 12);
27-
$size = unpack('V', substr($head, 4, 4))[1];
28+
$outputSize = $size = unpack('V', substr($head, 4, 4))[1];
2829

2930
if ($size % 2 || !str_starts_with($head, 'RIFF') || 'WEBP' !== substr($head, 8)) {
3031
throw new InvalidImageContainerException('Invalid WEBP head');
3132
}
3233

33-
$xmpChunk = '';
34+
$vp8xMetaFlags = 0;
3435
$exifChunk = '';
36+
$xmpChunk = '';
37+
38+
if ($exif = $this->metadataReaderWriter->serializeFormat(ExifFormat::NAME, $metadata, $preserveKeysByFormat[ExifFormat::NAME] ?? [])) {
39+
$exifChunk = $this->buildChunk('EXIF', $exif);
40+
// Exif metadata (E)
41+
$vp8xMetaFlags |= 0b00001000;
42+
$outputSize += \strlen($exifChunk);
43+
}
3544

3645
if ($xmp = $this->metadataReaderWriter->serializeFormat(XmpFormat::NAME, $metadata, $preserveKeysByFormat[XmpFormat::NAME] ?? [])) {
3746
$xmpChunk = $this->buildChunk('XMP ', $xmp);
47+
// XMP metadata (X)
48+
$vp8xMetaFlags |= 0b00000100;
49+
$outputSize += \strlen($xmpChunk);
3850
}
3951

40-
if ($exif = $this->metadataReaderWriter->serializeFormat(ExifFormat::NAME, $metadata, $preserveKeysByFormat[ExifFormat::NAME] ?? [])) {
41-
$exifChunk = $this->buildChunk('EXIF', $exif);
52+
// Keep the original file if there are no metadata chunks to add
53+
if (!$exifChunk && !$xmpChunk) {
54+
fwrite($outputStream, $head);
55+
stream_copy_to_stream($inputStream, $outputStream);
56+
57+
return;
4258
}
4359

44-
// Update file size field
45-
$head = pack('A4VA4', 'RIFF', $size + \strlen($xmpChunk) + \strlen($exifChunk), 'WEBP');
60+
$size -= 4;
61+
$firstChunk = true;
62+
63+
while ($size >= 8) {
64+
$chunkFourcc = fread($inputStream, 4);
65+
$chunkSize = unpack('V', fread($inputStream, 4))[1];
66+
$size -= 8 + $chunkSize;
67+
68+
if ($firstChunk) {
69+
$firstChunk = false;
70+
71+
if ('VP8X' !== $chunkFourcc) {
72+
$outputSize += 18;
73+
}
74+
75+
fwrite($outputStream, pack('A4VA4', 'RIFF', $outputSize, 'WEBP'));
76+
77+
if ('VP8 ' === $chunkFourcc) {
78+
$vp8Header = fread($inputStream, 10);
79+
fwrite($outputStream, $this->buildVp8xChunkFromVp8($vp8Header, $vp8xMetaFlags));
80+
fwrite($outputStream, pack('A4V', $chunkFourcc, $chunkSize));
81+
fwrite($outputStream, $vp8Header);
82+
stream_copy_to_stream($inputStream, $outputStream, $chunkSize - 10);
83+
} elseif ('VP8L' === $chunkFourcc) {
84+
$vp8lHeader = fread($inputStream, 5);
85+
fwrite($outputStream, $this->buildVp8xChunkFromVp8l($vp8lHeader, $vp8xMetaFlags));
86+
fwrite($outputStream, pack('A4V', $chunkFourcc, $chunkSize));
87+
fwrite($outputStream, $vp8lHeader);
88+
stream_copy_to_stream($inputStream, $outputStream, $chunkSize - 5);
89+
} elseif ('VP8X' === $chunkFourcc) {
90+
fwrite($outputStream, pack('A4V', $chunkFourcc, $chunkSize));
91+
$vp8xFlags = \ord(fread($inputStream, 1)) | $vp8xMetaFlags;
92+
fwrite($outputStream, \chr($vp8xFlags));
93+
stream_copy_to_stream($inputStream, $outputStream, $chunkSize - 1);
94+
} else {
95+
throw new InvalidImageContainerException('Invalid WEBP chunk at start position');
96+
}
97+
} else {
98+
if (\in_array($chunkFourcc, ['EXIF', 'XMP ', "XMP\x00"], true)) {
99+
throw new RuntimeException('Overwriting existing metadata chunks in WEBP is not supported');
100+
}
46101

47-
fwrite($outputStream, $head);
102+
fwrite($outputStream, pack('A4V', $chunkFourcc, $chunkSize));
103+
stream_copy_to_stream($inputStream, $outputStream, $chunkSize);
104+
}
48105

49-
stream_copy_to_stream($inputStream, $outputStream, $size - 4);
106+
// RIFF chunks are padded to an even number
107+
if ($chunkSize % 2) {
108+
stream_copy_to_stream($inputStream, $outputStream, 1);
109+
--$size;
110+
}
111+
}
50112

51-
fwrite($outputStream, $xmpChunk);
52-
fwrite($outputStream, $exifChunk);
113+
if (0 !== $size) {
114+
throw new InvalidImageContainerException('Invalid WEBP chunks');
115+
}
53116

54-
stream_copy_to_stream($inputStream, $outputStream);
117+
fwrite($outputStream, $exifChunk);
118+
fwrite($outputStream, $xmpChunk);
55119
}
56120

57121
public function parse($stream): array
@@ -77,8 +141,19 @@ public function parse($stream): array
77141
$size = unpack('V', substr($marker, 4, 4))[1];
78142

79143
if ('EXIF' === $type) {
80-
$metadata[ExifFormat::NAME] = $this->parseFormat(ExifFormat::NAME, fread($stream, $size));
81-
} elseif ('XMP ' === $type) {
144+
$exif = fread($stream, $size);
145+
146+
// Older ImageMagick versions incorrectly included the JPEG APP1
147+
// Exif identifier string 45 78 69 66 00 00
148+
// see https://github.com/ImageMagick/ImageMagick/issues/3140
149+
if (str_starts_with($exif, "Exif\x00\x00")) {
150+
$exif = substr($exif, 6);
151+
}
152+
153+
$metadata[ExifFormat::NAME] = $this->parseFormat(ExifFormat::NAME, $exif);
154+
} elseif ('XMP ' === $type || "XMP\x00" === $type) {
155+
// Older ImageMagick versions incorrectly used XMP followed by a null byte: 58 4D 50 00
156+
// see https://github.com/ImageMagick/ImageMagick/commit/da99bea66a4ad0bcf6149170eda81e6dcc229af0
82157
$metadata[XmpFormat::NAME] = $this->parseFormat(XmpFormat::NAME, fread($stream, $size));
83158
} else {
84159
// Skip to the next chunk
@@ -94,6 +169,75 @@ public function parse($stream): array
94169
return $metadata;
95170
}
96171

172+
private function buildVp8xChunkFromVp8(string $header, int $vp8xMetaFlags): string
173+
{
174+
if (\ord($header[0]) & 0b00000001) {
175+
throw new InvalidImageContainerException('Expected VP8 keyframe');
176+
}
177+
178+
if ("\x9D\x01\x2A" !== substr($header, 3, 3)) {
179+
throw new InvalidImageContainerException('Invalid VP8 keyframe sync code');
180+
}
181+
182+
$width = unpack('v', substr($header, 6, 2))[1] & 0b0011111111111111;
183+
$height = unpack('v', substr($header, 8, 2))[1] & 0b0011111111111111;
184+
185+
return $this->buildVp8xChunk(
186+
0b00000000 | $vp8xMetaFlags,
187+
$width,
188+
$height,
189+
);
190+
}
191+
192+
private function buildVp8xChunkFromVp8l(string $header, int $vp8xMetaFlags): string
193+
{
194+
if ("\x2F" !== $header[0]) {
195+
throw new InvalidImageContainerException('Invalid VP8L signature');
196+
}
197+
198+
$data = unpack('V', substr($header, 1, 4))[1];
199+
200+
if (0 !== $data >> 29) {
201+
throw new InvalidImageContainerException('Invalid VP8L version');
202+
}
203+
204+
$width = ($data & 0b0011111111111111) + 1;
205+
$height = ($data >> 14 & 0b0011111111111111) + 1;
206+
207+
// Alpha (L)
208+
if ($data >> 28 & 0b01) {
209+
$vp8xMetaFlags |= 0b00010000;
210+
}
211+
212+
return $this->buildVp8xChunk(
213+
0b00000000 | $vp8xMetaFlags,
214+
$width,
215+
$height,
216+
);
217+
}
218+
219+
private function buildVp8xChunk(int $vp8xFlags, int $width, int $height): string
220+
{
221+
--$width;
222+
--$height;
223+
224+
$vp8x = pack(
225+
'C*',
226+
$vp8xFlags, // Bit flags: |R|R|I|L|E|X|A|R|
227+
0,
228+
0,
229+
0,
230+
$width & 0xFF,
231+
$width >> 8 & 0xFF,
232+
$width >> 16 & 0xFF,
233+
$height & 0xFF,
234+
$height >> 8 & 0xFF,
235+
$height >> 16 & 0xFF
236+
);
237+
238+
return $this->buildChunk('VP8X', $vp8x);
239+
}
240+
97241
private function buildChunk($type, $content): string
98242
{
99243
$size = \strlen($content);

0 commit comments

Comments
 (0)