diff --git a/src/Buffer/ImageMetadataBuffer.php b/src/Buffer/ImageMetadataBuffer.php new file mode 100644 index 0000000..217f377 --- /dev/null +++ b/src/Buffer/ImageMetadataBuffer.php @@ -0,0 +1,336 @@ += 12 + && substr($peek, 0, 4) === self::RIFF_SIGNATURE + && substr($peek, 8, 4) === self::WEBP_SIGNATURE + ) { + fclose($stream); + $stream = @fopen($url, 'rb'); + if (! $stream) { + return null; + } + $buf = self::bufferWebpUpToXmp($stream); + fclose($stream); + return $buf; + } + + // Unknown format: just read entire file + fclose($stream); + $stream = @fopen($url, 'rb'); + if (! $stream) { + return null; + } + $all = stream_get_contents($stream); + if ($all === false) { + fclose($stream); + return null; + } + fclose($stream); + return $all === '' ? null : $all; + } + + /** + * Buffer a PNG chunk‐by‐chunk until we fully read an iTXt whose + * data begins with "XML:com.adobe.xmp\x00", and then read through + * the IEND chunk before stopping. + * + * @param resource $stream Opened PNG stream in binary mode + * @return string|null A byte‐buffer containing: signature → iTXt(XMP) → IEND (or null on error) + */ + private static function bufferPngUpToXmp($stream) + { + // 1) Read and verify the 8‐byte PNG signature + $sig = fread($stream, 8); + if ($sig === false || strlen($sig) < 8) { + return null; + } + if ($sig !== self::PNG_SIGNATURE) { + return null; + } + + $buffer = $sig; + $foundXmp = false; + + while (true) { + // 2) Read the next chunk's length+type (8 bytes) + $hdr = fread($stream, 8); + if ($hdr === false || strlen($hdr) < 8) { + // EOF or truncated; return what we have so far + break; + } + $buffer .= $hdr; + + // Parse length (4 bytes BE) and chunk type (4 bytes ASCII) + $u = @unpack('Nlength/a4type', $hdr); + if ($u === false || ! isset($u['length'], $u['type'])) { + // Invalid header, bail + break; + } + $length = (int) $u['length']; + $type = $u['type']; + + // 3) If this is IEND, read its 4‐byte CRC, append, and stop + if ($type === 'IEND') { + $crc = fread($stream, 4); + if ($crc !== false && strlen($crc) === 4) { + $buffer .= $crc; + } + break; + } + + // 4) Otherwise, read payload + 4‐byte CRC + $toRead = $length + 4; + if ($toRead > 0) { + $chunkData = fread($stream, $toRead); + if ($chunkData === false || strlen($chunkData) < $toRead) { + // Truncated payload; append whatever we got and bail + $buffer .= ($chunkData ?: ''); + break; + } + $buffer .= $chunkData; + } else { + $chunkData = ''; + } + + // 5) If this is an iTXt chunk and it begins with the XMP keyword, mark $foundXmp + if ($type === 'iTXt') { + $data = substr($chunkData, 0, $length); + if (strpos($data, self::PNG_ITXT_XMP_KEYWORD) === 0) { + $foundXmp = true; + // Do NOT break yet – we still need to read through IEND + } + } + + // 6) If we've seen XMP‐tagged iTXt, continue looping until we hit IEND above + } + + return $buffer; + } + + /** + * Buffer a JPEG segment‐by‐segment until we have: + * 1) Fully read the APP1-XMP chunk (so $foundXmp = true), + * 2) Fully read the first SOF segment (so $sawSOF = true), + * 3) Then append an EOI marker (0xFFD9) and break. + * + * If we encounter SOS (0xFFDA) or EOI (0xFFD9) before capturing both, + * we break anyway, because no more headers exist. + * + * @param resource $stream + * @return string|null + */ + private static function bufferJpegUpToXmp($stream) + { + // 1) Read SOI (2 bytes). Must be 0xFFD8. + $soi = fread($stream, 2); + if ($soi === false || strlen($soi) < 2 || $soi !== self::JPEG_SOI) { + return null; + } + + $buffer = $soi; + $foundXmp = false; + $sawSOF = false; + + while (true) { + $marker = fread($stream, 2); + if ($marker === false || strlen($marker) < 2) { + // EOF or truncated + break; + } + $buffer .= $marker; + + // If SOS (0xFFDA) or EOI (0xFFD9) appear before we've captured both flags, + // break anyway (no more headers). + if ($marker === self::JPEG_SOS) { + if (! ($foundXmp && $sawSOF)) { + break; + } + // Both flags true, we’ll append EOI and stop. + break; + } + if ($marker === self::JPEG_EOI) { + break; + } + + $lenBytes = fread($stream, 2); + if ($lenBytes === false || strlen($lenBytes) < 2) { + break; + } + $buffer .= $lenBytes; + + $un = @unpack('nsegmentLength', $lenBytes); + if ($un === false || ! isset($un['segmentLength'])) { + break; + } + $segLen = (int) $un['segmentLength']; + $payloadLen = $segLen - 2; + + if ($payloadLen > 0) { + $payload = fread($stream, $payloadLen); + if ($payload === false || strlen($payload) < $payloadLen) { + $buffer .= ($payload ?: ''); + break; + } + $buffer .= $payload; + } else { + $payload = ''; + } + + // If this marker is APP1 (0xFFE1), check for XMP header + if ($marker === self::JPEG_APP1_MARKER) { + $xmpHeader = self::JPEG_APP1_XMP_HEADER; + if (strncmp($payload, $xmpHeader, strlen($xmpHeader)) === 0) { + $foundXmp = true; + } + } + + // Check if this is a SOF marker (0xFFC0,0xFFC1,0xFFC2,…) + $secondByte = ord($marker[1]); + $isSOF = in_array($secondByte, [ + 0xC0, 0xC1, 0xC2, 0xC3, + 0xC5, 0xC6, 0xC7, + 0xC9, 0xCA, 0xCB, + 0xCD, 0xCE, 0xCF, + ], true); + if ($isSOF) { + $sawSOF = true; + } + + // If we now have both APP1-XMP and SOF, stop reading further segments. + if ($foundXmp && $sawSOF) { + break; + } + } + + // We've captured XMP and SOF (if present). + // Append EOI (0xFFD9) so that fromStream() won't unpack an empty marker. + $buffer .= self::JPEG_EOI; + + return $buffer; + } + + /** + * Buffer a WebP RIFF sub‐chunk by sub‐chunk until we find "XMP " or "EXIF", + * then stop. If none, buffer until EOF. + * + * @param resource $stream + * @return string|null + */ + private static function bufferWebpUpToXmp($stream) + { + $riffHdr = fread($stream, 12); + if ($riffHdr === false || strlen($riffHdr) < 12) { + return null; + } + if (substr($riffHdr, 0, 4) !== self::RIFF_SIGNATURE || substr($riffHdr, 8, 4) !== self::WEBP_SIGNATURE) { + return null; + } + + $buffer = $riffHdr; + + while (true) { + $hdr = fread($stream, 8); + if ($hdr === false || strlen($hdr) < 8) { + break; + } + $buffer .= $hdr; + + $type = substr($hdr, 0, 4); + $sizeLE = substr($hdr, 4, 4); + + $un = @unpack('VchunkSize', $sizeLE); + if ($un === false || ! isset($un['chunkSize'])) { + break; + } + $chunkSize = (int) $un['chunkSize']; + + if ($chunkSize > 0) { + $data = fread($stream, $chunkSize); + if ($data === false || strlen($data) < $chunkSize) { + $buffer .= ($data ?: ''); + break; + } + $buffer .= $data; + + if ($chunkSize % 2 !== 0) { + $pad = fread($stream, 1); + if ($pad !== false && strlen($pad) === 1) { + $buffer .= $pad; + } + } + } + + if ($type === 'XMP ' || $type === 'EXIF') { + break; + } + } + + return $buffer; + } +} diff --git a/tests/Buffer/ImageMetadataBufferTest.php b/tests/Buffer/ImageMetadataBufferTest.php new file mode 100644 index 0000000..96f7b3e --- /dev/null +++ b/tests/Buffer/ImageMetadataBufferTest.php @@ -0,0 +1,170 @@ +assertIsString($buf, "bufferUpThroughXmp returned null or non‐string for $filename"); + $this->assertStringContainsString($expectedSubstring, $buf, "Buffer for $filename did not contain expected XMP marker"); + } + + public function providerFormatsWithXmp(): array + { + return [ + // JPEG with embedded XMP + ['frameright.jpg', 'http://ns.adobe.com/xap/1.0/'], + // PNG with an iTXt chunk holding XML:com.adobe.xmp + ['frameright.png', 'XML:com.adobe.xmp'], + // WebP with an XMP chunk + ['meta.webp', 'XMP '], + ]; + } + + /** + * If an image has no XMP at all, bufferUpThroughXmp should still return + * the full file contents (not null), and getimagesizefromstring should work + * later on. We simply assert that it’s non‐null and that its length is + * “reasonably” larger than zero. + * + * @dataProvider providerFormatsWithoutXmp + */ + public function testBufferUpThroughXmp_NoXmpStillReturnsEntireFile(string $filename) + { + $path = __DIR__ . '/../Fixtures/' . $filename; + $buf = ImageMetadataBuffer::bufferUpThroughXmp($path); + + $this->assertIsString($buf, "Expected a buffer, got null for $filename"); + $this->assertGreaterThan(0, strlen($buf), "Empty buffer for $filename"); + } + + public function providerFormatsWithoutXmp(): array + { + return [ + ['nometa.jpg'], + ['nometa.png'], + ['exif.webp'], // a WebP with EXIF but no XMP (still buffer as full file) + ]; + } + + /** + * If fopen() fails (for instance, point at a non‐existent file), we expect null. + */ + public function testBufferUpThroughXmp_NonexistentReturnsNull() + { + $buf = ImageMetadataBuffer::bufferUpThroughXmp('/this/path/does/not/exist.jpg'); + $this->assertNull($buf); + } + + /** + * Now, testing each private helper via reflection (optional): + * - bufferPngUpToXmp + * - bufferJpegUpToXmp + * - bufferWebpUpToXmp + * + * We can invoke them via ReflectionMethod, passing in a local php://temp stream + * that we manually write a minimal header+XMP chunk into. Below is an example for JPEG. + */ + public function testBufferJpegUpToXmp_AppendsEOIAfterXmpAndSOF() + { + // Construct a minimal JPEG header with: + // 0xFFD8 (SOI) + // 0xFFE1 (APP1), length=??, payload="http://ns.adobe.com/xap/1.0/\0" + // 0xFFC0 (SOF0), length=… (we don’t care about real dimensions) + // 0xFFDA (SOS) to simulate “start of scan” + // + // After we feed this to bufferJpegUpToXmp, the returned string must end in 0xFFD9 (EOI). + $fakeXmpPrefix = "http://ns.adobe.com/xap/1.0/\x00"; + $app1Length = pack('n', strlen($fakeXmpPrefix) + 2); + $jpegBytes = "\xFF\xD8"; // SOI + $jpegBytes .= "\xFF\xE1" . $app1Length . $fakeXmpPrefix; + $jpegBytes .= "\xFF\xC0" . "\x00\x04" . "\x00\x01\x00\x01"; // SOF0 with length=4, dummy dims + $jpegBytes .= "\xFF\xDA"; // SOS + // No actual compressed data, so fromStream would break; our buffer helper should append EOI. + + // Write into a php://temp stream + $stream = fopen('php://temp', 'r+'); + fwrite($stream, $jpegBytes); + rewind($stream); + + $rm = new \ReflectionMethod(ImageMetadataBuffer::class, 'bufferJpegUpToXmp'); + $rm->setAccessible(true); + $buffered = $rm->invoke(null, $stream); + fclose($stream); + + // Should contain the XMP header + $this->assertStringContainsString("http://ns.adobe.com/xap/1.0/\x00", $buffered); + // Should end with EOI (\xFF\xD9) + $this->assertStringEndsWith("\xFF\xD9", $buffered); + } + + public function testBufferPngUpToXmp_StopsAtIENDOriTXt() + { + // Build a minimal PNG: + // Signature (0x89 50 4E 47 0D 0A 1A 0A) + // iTXt chunk: length, type='iTXt', data="XML:com.adobe.xmp\0", CRC + // IEND chunk: length=0, type='IEND', CRC + $sig = "\x89PNG\x0D\x0A\x1A\x0A"; + $xmpTxt = "XML:com.adobe.xmp\x00"; + $iTXtLen = pack('N', strlen($xmpTxt)); + $iTXtType = 'iTXt'; + $fakeCRC = "\x00\x00\x00\x00"; + $iTXtChunk = $iTXtLen . $iTXtType . $xmpTxt . $fakeCRC; + $iENDChunk = pack('N', 0) . 'IEND' . $fakeCRC; + + $pngBytes = $sig . $iTXtChunk . $iENDChunk; + + $stream = fopen('php://temp', 'r+'); + fwrite($stream, $pngBytes); + rewind($stream); + + $rm = new \ReflectionMethod(ImageMetadataBuffer::class, 'bufferPngUpToXmp'); + $rm->setAccessible(true); + $buffered = $rm->invoke(null, $stream); + fclose($stream); + + // The returned buffer should begin with the PNG signature + $this->assertSame(substr($buffered, 0, 8), ImageMetadataBuffer::PNG_SIGNATURE); + // It should contain our “XML:com.adobe.xmp” keyword and stop after that + $this->assertStringContainsString("XML:com.adobe.xmp\x00", $buffered); + // It should also contain the IEND chunk + $this->assertStringContainsString('IEND', $buffered); + } + + public function testBufferWebpUpToXmp_StopsWhenXmpTypeIsFound() + { + // Minimal WebP RIFF header: + // 'RIFF' + [4‐byte size] + 'WEBP' + // Then a chunk with type='XMP ' + 4 bytes size + payload '' + // We don’t need to append padding, because our code will break immediately when it sees 'XMP '. + $riff = 'RIFF' . pack('V', 8 + 4 + 6) . 'WEBP'; + $xmpType = 'XMP '; + $xmpData = ''; + $sizeLE = pack('V', strlen($xmpData)); + $webpBytes = $riff . $xmpType . $sizeLE . $xmpData; + $stream = fopen('php://temp', 'r+'); + fwrite($stream, $webpBytes); + rewind($stream); + + $rm = new \ReflectionMethod(ImageMetadataBuffer::class, 'bufferWebpUpToXmp'); + $rm->setAccessible(true); + $buffered = $rm->invoke(null, $stream); + fclose($stream); + + $this->assertStringContainsString('WEBP', $buffered); + $this->assertStringContainsString('XMP ', $buffered); + // As soon as it sees 'XMP ', it should stop reading further sub‐chunks. + } +} diff --git a/tests/Fixtures/frameright.png b/tests/Fixtures/frameright.png new file mode 100644 index 0000000..3744a54 Binary files /dev/null and b/tests/Fixtures/frameright.png differ