diff --git a/src/Pdf/Png/PhpPngHeaderUnpacker.php b/src/Pdf/Png/PhpPngHeaderUnpacker.php new file mode 100644 index 0000000..09c128d --- /dev/null +++ b/src/Pdf/Png/PhpPngHeaderUnpacker.php @@ -0,0 +1,20 @@ +headerUnpacker = $headerUnpacker ?? new PhpPngHeaderUnpacker(); + } + public function parse(string $contents): ParsedPngImage { $this->assertPngSignature($contents); @@ -107,10 +114,7 @@ public function parseHeader(string $data): array throw new InvalidArgumentException('Unable to parse the PNG IHDR chunk.'); } - $header = unpack( - 'Nwidth/Nheight/CbitDepth/CcolorType/Ccompression/Cfilter/Cinterlace', - $data, - ); + $header = $this->headerUnpacker->unpack($data); if (!is_array($header)) { throw new InvalidArgumentException('Unable to parse the PNG IHDR chunk.'); } diff --git a/src/Pdf/Png/PngPdfImageFactory.php b/src/Pdf/Png/PngPdfImageFactory.php index 1ed1c22..313fd4c 100644 --- a/src/Pdf/Png/PngPdfImageFactory.php +++ b/src/Pdf/Png/PngPdfImageFactory.php @@ -22,13 +22,16 @@ { private PngParserInterface $parser; private PngScanlineUnfiltererInterface $scanlineUnfilterer; + private PngScanlineCompressorInterface $scanlineCompressor; public function __construct( ?PngParserInterface $parser = null, ?PngScanlineUnfiltererInterface $scanlineUnfilterer = null, + ?PngScanlineCompressorInterface $scanlineCompressor = null, ) { $this->parser = $parser ?? new PngParser(); $this->scanlineUnfilterer = $scanlineUnfilterer ?? new PngScanlineUnfilterer(); + $this->scanlineCompressor = $scanlineCompressor ?? new PhpPngScanlineCompressor(); } public function create(string $contents): EmbeddedPdfImage @@ -136,7 +139,7 @@ private function createImageDictionary(int $width, int $height, string $colorSpa private function compressScanlines(string $scanlines): string { - $compressed = gzcompress($scanlines); + $compressed = $this->scanlineCompressor->compress($scanlines); if (!is_string($compressed)) { throw new InvalidArgumentException('PNG scanlines could not be compressed.'); } diff --git a/src/Pdf/Png/PngScanlineCompressorInterface.php b/src/Pdf/Png/PngScanlineCompressorInterface.php new file mode 100644 index 0000000..050f735 --- /dev/null +++ b/src/Pdf/Png/PngScanlineCompressorInterface.php @@ -0,0 +1,14 @@ +read($path)); } + public function testReadRejectsNonStringWarningConverterResult(): void + { + $reader = new FilesystemImageSourceReader(new class implements WarningToExceptionConverterInterface { + public function run(callable $operation, string $message): mixed + { + return false; + } + }); + $path = $this->createTemporaryFile('png', 'disk-contents'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf('Failed to read image source "%s".', $path)); + + $reader->read($path); + } + public function testReadRejectsMissingSources(): void { $reader = new FilesystemImageSourceReader(); diff --git a/tests/Unit/Pdf/Png/PhpPngHeaderUnpackerTest.php b/tests/Unit/Pdf/Png/PhpPngHeaderUnpackerTest.php new file mode 100644 index 0000000..c1912fb --- /dev/null +++ b/tests/Unit/Pdf/Png/PhpPngHeaderUnpackerTest.php @@ -0,0 +1,32 @@ + 3, + 'height' => 2, + 'bitDepth' => 8, + 'colorType' => 6, + 'compression' => 0, + 'filter' => 0, + 'interlace' => 0, + ], + $unpacker->unpack(pack('NNCCCCC', 3, 2, 8, 6, 0, 0, 0)), + ); + } +} diff --git a/tests/Unit/Pdf/Png/PhpPngScanlineCompressorTest.php b/tests/Unit/Pdf/Png/PhpPngScanlineCompressorTest.php new file mode 100644 index 0000000..06aa237 --- /dev/null +++ b/tests/Unit/Pdf/Png/PhpPngScanlineCompressorTest.php @@ -0,0 +1,25 @@ +compress($scanlines); + + self::assertIsString($compressed); + self::assertSame($scanlines, gzuncompress($compressed)); + } +} diff --git a/tests/Unit/Pdf/Png/PngParserTest.php b/tests/Unit/Pdf/Png/PngParserTest.php index 3ca591e..5184267 100644 --- a/tests/Unit/Pdf/Png/PngParserTest.php +++ b/tests/Unit/Pdf/Png/PngParserTest.php @@ -7,6 +7,7 @@ namespace LibreSign\XObjectTemplate\Tests\Unit\Pdf\Png; +use LibreSign\XObjectTemplate\Pdf\Png\PngHeaderUnpackerInterface; use LibreSign\XObjectTemplate\Pdf\Png\PngParser; use LibreSign\XObjectTemplate\Tests\Support\PngFixtureFactory; use PHPUnit\Framework\TestCase; @@ -157,6 +158,21 @@ public function testParseHeaderRejectsUnexpectedHeaderLength(): void $parser->parseHeader('short-header'); } + public function testParseHeaderRejectsUnpackFailures(): void + { + $parser = new PngParser(new class implements PngHeaderUnpackerInterface { + public function unpack(string $data): array|false + { + return false; + } + }); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unable to parse the PNG IHDR chunk.'); + + $parser->parseHeader(pack('NNCCCCC', 1, 1, 8, 2, 0, 0, 0)); + } + public function testParseRejectsMissingTrailerChunkWhenTrailingBytesAreTooShort(): void { $parser = new PngParser(); diff --git a/tests/Unit/Pdf/Png/PngPdfImageFactoryTest.php b/tests/Unit/Pdf/Png/PngPdfImageFactoryTest.php index f767cfa..f4c0d9b 100644 --- a/tests/Unit/Pdf/Png/PngPdfImageFactoryTest.php +++ b/tests/Unit/Pdf/Png/PngPdfImageFactoryTest.php @@ -10,6 +10,7 @@ use LibreSign\XObjectTemplate\Pdf\Png\ParsedPngImage; use LibreSign\XObjectTemplate\Pdf\Png\PngParserInterface; use LibreSign\XObjectTemplate\Pdf\Png\PngPdfImageFactory; +use LibreSign\XObjectTemplate\Pdf\Png\PngScanlineCompressorInterface; use LibreSign\XObjectTemplate\Pdf\Png\PngScanlineUnfiltererInterface; use PHPUnit\Framework\TestCase; @@ -85,4 +86,56 @@ public function unfilter(string $idat, int $height, int $rowLength, int $bytesPe $factory->create('not-a-real-png'); } + + public function testCreateRejectsTruncatedAlphaPixelData(): void + { + $factory = new PngPdfImageFactory( + new class implements PngParserInterface { + public function parse(string $contents): ParsedPngImage + { + return new ParsedPngImage(1, 1, 6, 'ignored-compressed-idat'); + } + }, + new class implements PngScanlineUnfiltererInterface { + public function unfilter(string $idat, int $height, int $rowLength, int $bytesPerPixel): array + { + return ["\xff\x00\x00"]; + } + }, + ); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('PNG row data is truncated.'); + + $factory->create('not-a-real-png'); + } + + public function testCreateRejectsCompressionFailuresForAlphaImages(): void + { + $factory = new PngPdfImageFactory( + new class implements PngParserInterface { + public function parse(string $contents): ParsedPngImage + { + return new ParsedPngImage(1, 1, 6, 'ignored-compressed-idat'); + } + }, + new class implements PngScanlineUnfiltererInterface { + public function unfilter(string $idat, int $height, int $rowLength, int $bytesPerPixel): array + { + return ["\xff\x00\x00\x80"]; + } + }, + new class implements PngScanlineCompressorInterface { + public function compress(string $scanlines): string|false + { + return false; + } + }, + ); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('PNG scanlines could not be compressed.'); + + $factory->create('not-a-real-png'); + } } diff --git a/tests/Unit/Pdf/Png/PngScanlineUnfiltererTest.php b/tests/Unit/Pdf/Png/PngScanlineUnfiltererTest.php index 111ea11..7dc0d5f 100644 --- a/tests/Unit/Pdf/Png/PngScanlineUnfiltererTest.php +++ b/tests/Unit/Pdf/Png/PngScanlineUnfiltererTest.php @@ -35,6 +35,21 @@ public function run(callable $operation, string $message): mixed self::assertSame(["\x7f"], $unfilterer->unfilter('ignored-idat', 1, 1, 1)); } + public function testUnfilterRejectsNonStringWarningConverterResult(): void + { + $unfilterer = new PngScanlineUnfilterer(new class implements WarningToExceptionConverterInterface { + public function run(callable $operation, string $message): mixed + { + return false; + } + }); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('PNG image data could not be decompressed.'); + + $unfilterer->unfilter('ignored-idat', 1, 1, 1); + } + public function testUnfilterRejectsMissingRowFilterBytes(): void { $unfilterer = new PngScanlineUnfilterer();