Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/Pdf/Png/PhpPngHeaderUnpacker.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

// SPDX-FileCopyrightText: 2026 LibreSign
// SPDX-License-Identifier: AGPL-3.0-or-later

declare(strict_types=1);

namespace LibreSign\XObjectTemplate\Pdf\Png;

/** @internal */
final class PhpPngHeaderUnpacker implements PngHeaderUnpackerInterface
{
public function unpack(string $data): array|false
{
return unpack(
'Nwidth/Nheight/CbitDepth/CcolorType/Ccompression/Cfilter/Cinterlace',
$data,
);
}
}
17 changes: 17 additions & 0 deletions src/Pdf/Png/PhpPngScanlineCompressor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

// SPDX-FileCopyrightText: 2026 LibreSign
// SPDX-License-Identifier: AGPL-3.0-or-later

declare(strict_types=1);

namespace LibreSign\XObjectTemplate\Pdf\Png;

/** @internal */
final class PhpPngScanlineCompressor implements PngScanlineCompressorInterface
{
public function compress(string $scanlines): string|false
{
return gzcompress($scanlines);
}
}
25 changes: 25 additions & 0 deletions src/Pdf/Png/PngHeaderUnpackerInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

// SPDX-FileCopyrightText: 2026 LibreSign
// SPDX-License-Identifier: AGPL-3.0-or-later

declare(strict_types=1);

namespace LibreSign\XObjectTemplate\Pdf\Png;

/** @internal */
interface PngHeaderUnpackerInterface
{
/**
* @return array{
* width: int,
* height: int,
* bitDepth: int,
* colorType: int,
* compression: int,
* filter: int,
* interlace: int
* }|false
*/
public function unpack(string $data): array|false;
}
12 changes: 8 additions & 4 deletions src/Pdf/Png/PngParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@
/** @internal */
final readonly class PngParser implements PngParserInterface
{
private PngHeaderUnpackerInterface $headerUnpacker;

public function __construct(?PngHeaderUnpackerInterface $headerUnpacker = null)
{
$this->headerUnpacker = $headerUnpacker ?? new PhpPngHeaderUnpacker();
}

public function parse(string $contents): ParsedPngImage
{
$this->assertPngSignature($contents);
Expand Down Expand Up @@ -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.');
}
Expand Down
5 changes: 4 additions & 1 deletion src/Pdf/Png/PngPdfImageFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.');
}
Expand Down
14 changes: 14 additions & 0 deletions src/Pdf/Png/PngScanlineCompressorInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

// SPDX-FileCopyrightText: 2026 LibreSign
// SPDX-License-Identifier: AGPL-3.0-or-later

declare(strict_types=1);

namespace LibreSign\XObjectTemplate\Pdf\Png;

/** @internal */
interface PngScanlineCompressorInterface
{
public function compress(string $scanlines): string|false;
}
16 changes: 16 additions & 0 deletions tests/Unit/Pdf/FilesystemImageSourceReaderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,22 @@ public function run(callable $operation, string $message): mixed
self::assertSame('converted-contents', $reader->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();
Expand Down
32 changes: 32 additions & 0 deletions tests/Unit/Pdf/Png/PhpPngHeaderUnpackerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

// SPDX-FileCopyrightText: 2026 LibreSign
// SPDX-License-Identifier: AGPL-3.0-or-later

declare(strict_types=1);

namespace LibreSign\XObjectTemplate\Tests\Unit\Pdf\Png;

use LibreSign\XObjectTemplate\Pdf\Png\PhpPngHeaderUnpacker;
use PHPUnit\Framework\TestCase;

final class PhpPngHeaderUnpackerTest extends TestCase
{
public function testUnpackReturnsStructuredHeaderFields(): void
{
$unpacker = new PhpPngHeaderUnpacker();

self::assertSame(
[
'width' => 3,
'height' => 2,
'bitDepth' => 8,
'colorType' => 6,
'compression' => 0,
'filter' => 0,
'interlace' => 0,
],
$unpacker->unpack(pack('NNCCCCC', 3, 2, 8, 6, 0, 0, 0)),
);
}
}
25 changes: 25 additions & 0 deletions tests/Unit/Pdf/Png/PhpPngScanlineCompressorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

// SPDX-FileCopyrightText: 2026 LibreSign
// SPDX-License-Identifier: AGPL-3.0-or-later

declare(strict_types=1);

namespace LibreSign\XObjectTemplate\Tests\Unit\Pdf\Png;

use LibreSign\XObjectTemplate\Pdf\Png\PhpPngScanlineCompressor;
use PHPUnit\Framework\TestCase;

final class PhpPngScanlineCompressorTest extends TestCase
{
public function testCompressReturnsRoundTrippableCompressedScanlines(): void
{
$compressor = new PhpPngScanlineCompressor();
$scanlines = "\x00\xff\x00\x00\x00\x00\xff\x00";

$compressed = $compressor->compress($scanlines);

self::assertIsString($compressed);
self::assertSame($scanlines, gzuncompress($compressed));
}
}
16 changes: 16 additions & 0 deletions tests/Unit/Pdf/Png/PngParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
53 changes: 53 additions & 0 deletions tests/Unit/Pdf/Png/PngPdfImageFactoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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');
}
}
15 changes: 15 additions & 0 deletions tests/Unit/Pdf/Png/PngScanlineUnfiltererTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading