From 58180602e6703753bf7bda16a5ddef1a9b08a39b Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 29 May 2026 12:50:31 -0300 Subject: [PATCH 01/44] test(pdf): fix exporter assertion and normalize CI history Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/Support/PngFixtureFactory.php | 50 +++++- tests/Unit/Layout/TextLineBreakerTest.php | 90 ++++++++++ .../Unit/Layout/TextOverflowTruncatorTest.php | 63 +++++++ .../Pdf/FilesystemPdfImageEmbedderTest.php | 165 ++++++++++++++++++ tests/Unit/Pdf/SinglePagePdfExporterTest.php | 59 +++++++ tests/Unit/Pdf/StandardFontMetricsTest.php | 37 ++++ 6 files changed, 460 insertions(+), 4 deletions(-) create mode 100644 tests/Unit/Layout/TextLineBreakerTest.php create mode 100644 tests/Unit/Layout/TextOverflowTruncatorTest.php diff --git a/tests/Support/PngFixtureFactory.php b/tests/Support/PngFixtureFactory.php index 50d96fb..7ca8bc0 100644 --- a/tests/Support/PngFixtureFactory.php +++ b/tests/Support/PngFixtureFactory.php @@ -18,19 +18,61 @@ public static function createPng( string $scanlines, int $bitDepth = 8, int $interlace = 0, + int $compression = 0, + int $filter = 0, ): string { - $ihdr = pack('NNCCCCC', $width, $height, $bitDepth, $colorType, 0, 0, $interlace); + $ihdr = pack('NNCCCCC', $width, $height, $bitDepth, $colorType, $compression, $filter, $interlace); $idat = gzcompress($scanlines); if ($idat === false) { throw new InvalidArgumentException('Failed to compress PNG scanlines.'); } - return "\x89PNG\r\n\x1a\n" - . self::createChunk('IHDR', $ihdr) - . self::createChunk('IDAT', $idat) + return self::createPngFromCompressedIdatChunks( + $width, + $height, + $colorType, + [$idat], + $bitDepth, + $interlace, + $compression, + $filter, + ); + } + + /** + * @param list $idatChunks + */ + public static function createPngFromCompressedIdatChunks( + int $width, + int $height, + int $colorType, + array $idatChunks, + int $bitDepth = 8, + int $interlace = 0, + int $compression = 0, + int $filter = 0, + ): string { + $ihdr = pack('NNCCCCC', $width, $height, $bitDepth, $colorType, $compression, $filter, $interlace); + $png = "\x89PNG\r\n\x1a\n" . self::createChunk('IHDR', $ihdr); + + foreach ($idatChunks as $idatChunk) { + $png .= self::createChunk('IDAT', $idatChunk); + } + + return $png . self::createChunk('IEND', ''); } + public static function compressScanlines(string $scanlines): string + { + $idat = gzcompress($scanlines); + if ($idat === false) { + throw new InvalidArgumentException('Failed to compress PNG scanlines.'); + } + + return $idat; + } + /** * @param callable(int, int, int, int): array{0: int|float, 1: int|float, 2: int|float, 3: int|float} $pixelRenderer */ diff --git a/tests/Unit/Layout/TextLineBreakerTest.php b/tests/Unit/Layout/TextLineBreakerTest.php new file mode 100644 index 0000000..1171b5d --- /dev/null +++ b/tests/Unit/Layout/TextLineBreakerTest.php @@ -0,0 +1,90 @@ +wrap('alpha beta', 0.0, 'F1', 10.0, 'auto', 'normal')); + self::assertSame(['alpha beta'], $breaker->wrap('alpha beta', 100.0, 'F1', 10.0, 'auto', 'nowrap')); + } + + public function testWrapPreservesWhitespaceOnlyInput(): void + { + $breaker = new TextLineBreaker(new StandardFontMetrics()); + + self::assertSame([" \t "], $breaker->wrap(" \t ", 20.0, 'F1', 10.0, 'auto', 'normal')); + } + + public function testWrapKeepsWordsOnTheSameLineWhenTheyStillFit(): void + { + $breaker = new TextLineBreaker(new StandardFontMetrics()); + + self::assertSame(['a b'], $breaker->wrap('a b', 20.0, 'F5', 10.0, 'none', 'normal')); + } + + public function testWrapKeepsAppendingAfterManualHyphenBreaks(): void + { + $metrics = new StandardFontMetrics(); + $breaker = new TextLineBreaker($metrics); + $maxWidth = max( + $metrics->measureString('F1', 10.0, 'hyphen-'), + $metrics->measureString('F1', 10.0, 'ation test'), + ); + + self::assertSame( + ['hyphen-', 'ation test'], + $breaker->wrap("hyphen\u{00AD}ation test", $maxWidth, 'F1', 10.0, 'manual', 'normal'), + ); + } + + public function testWrapContinuesUsingTheLastBrokenSegmentAsTheCurrentLine(): void + { + $breaker = new TextLineBreaker(new StandardFontMetrics()); + + self::assertSame( + ['abcd-', 'ef gh'], + $breaker->wrap('abcdef gh', 30.0, 'F5', 10.0, 'auto', 'normal'), + ); + } + + public function testWrapAutomaticallyHyphenatesFixedWidthWords(): void + { + $breaker = new TextLineBreaker(new StandardFontMetrics()); + + self::assertSame( + ['abc-', 'def'], + $breaker->wrap('abcdef', 24.0, 'F5', 10.0, 'auto', 'normal'), + ); + } + + public function testWrapFallsBackToSingleCharactersWhenTheFirstAutoSegmentDoesNotFit(): void + { + $breaker = new TextLineBreaker(new StandardFontMetrics()); + + self::assertSame( + ['a-', 'b'], + $breaker->wrap('ab', 5.0, 'F1', 10.0, 'auto', 'normal'), + ); + } + + public function testWrapReturnsInvalidUtf8WordsUnchangedWhenCharacterSplittingFails(): void + { + $breaker = new TextLineBreaker(new StandardFontMetrics()); + $invalidUtf8 = "\xc3\x28"; + + self::assertSame([$invalidUtf8], $breaker->wrap($invalidUtf8, 1.0, 'F1', 10.0, 'auto', 'normal')); + } +} diff --git a/tests/Unit/Layout/TextOverflowTruncatorTest.php b/tests/Unit/Layout/TextOverflowTruncatorTest.php new file mode 100644 index 0000000..4f79290 --- /dev/null +++ b/tests/Unit/Layout/TextOverflowTruncatorTest.php @@ -0,0 +1,63 @@ +truncateWithEllipsis($text, $metrics->measureString('F1', 10.0, $text), 'F1', 10.0), + ); + } + + public function testTruncateWithEllipsisShortensUntilTheCandidateFits(): void + { + $metrics = new StandardFontMetrics(); + $truncator = new TextOverflowTruncator($metrics); + + self::assertSame( + 'ab...', + $truncator->truncateWithEllipsis('abcdef', $metrics->measureString('F5', 10.0, 'ab...'), 'F5', 10.0), + ); + } + + public function testForceEllipsisTrimsTrailingWhitespaceBeforeAppendingTheMarker(): void + { + $metrics = new StandardFontMetrics(); + $truncator = new TextOverflowTruncator($metrics); + + self::assertSame( + 'word...', + $truncator->forceEllipsis('word ', $metrics->measureString('F1', 10.0, 'word ...'), 'F1', 10.0), + ); + } + + public function testForceEllipsisFallsBackToOnlyTheMarkerWhenNothingFits(): void + { + $truncator = new TextOverflowTruncator(new StandardFontMetrics()); + + self::assertSame('...', $truncator->forceEllipsis('text', 1.0, 'F1', 10.0)); + } + + public function testForceEllipsisReturnsOnlyTheMarkerForInvalidUtf8Input(): void + { + $truncator = new TextOverflowTruncator(new StandardFontMetrics()); + + self::assertSame('...', $truncator->forceEllipsis("\xc3\x28", 10.0, 'F1', 10.0)); + } +} diff --git a/tests/Unit/Pdf/FilesystemPdfImageEmbedderTest.php b/tests/Unit/Pdf/FilesystemPdfImageEmbedderTest.php index eda85dc..ae3af95 100644 --- a/tests/Unit/Pdf/FilesystemPdfImageEmbedderTest.php +++ b/tests/Unit/Pdf/FilesystemPdfImageEmbedderTest.php @@ -78,6 +78,41 @@ public function testEmbedCreatesSoftMaskForRgbaPng(): void self::assertSame('/FlateDecode', $image->softMask->dictionary['Filter']); } + public function testEmbedSupportsOpaqueGrayscalePng(): void + { + $embedder = new FilesystemPdfImageEmbedder(); + $pngPath = $this->createTemporaryFile('png', PngFixtureFactory::createPng( + width: 1, + height: 1, + colorType: 0, + scanlines: "\x00\x80", + )); + + $image = $embedder->embed($pngPath); + + self::assertSame('/DeviceGray', $image->dictionary['ColorSpace']); + self::assertNull($image->softMask); + self::assertSame("\x00\x80", gzuncompress($image->stream)); + } + + public function testEmbedCreatesSoftMaskForGrayAlphaPng(): void + { + $embedder = new FilesystemPdfImageEmbedder(); + $pngPath = $this->createTemporaryFile('png', PngFixtureFactory::createPng( + width: 1, + height: 1, + colorType: 4, + scanlines: "\x00\x80\x40", + )); + + $image = $embedder->embed($pngPath); + + self::assertSame('/DeviceGray', $image->dictionary['ColorSpace']); + self::assertNotNull($image->softMask); + self::assertSame("\x00\x80", gzuncompress($image->stream)); + self::assertSame("\x00\x40", gzuncompress($image->softMask->stream)); + } + #[DataProvider('rgbaFilterProvider')] public function testEmbedSupportsAllRgbaPredictorFilters(int $filterType): void { @@ -159,6 +194,33 @@ public function testEmbedSupportsJpegStreams(): void self::assertNull($image->softMask); } + #[DataProvider('jpegChannelProvider')] + public function testEmbedJpegMapsChannelMetadataToPdfColorSpaces( + int|string $channels, + string $expectedColorSpace, + ): void { + $embedder = new FilesystemPdfImageEmbedder(); + $method = new ReflectionMethod($embedder, 'embedJpeg'); + + $image = $method->invoke($embedder, 'jpeg-binary', [0 => 4, 1 => 3, 'channels' => $channels]); + + self::assertSame($expectedColorSpace, $image->dictionary['ColorSpace']); + self::assertSame(4, $image->dictionary['Width']); + self::assertSame(3, $image->dictionary['Height']); + self::assertSame('jpeg-binary', $image->stream); + } + + public function testEmbedJpegRejectsMissingDimensions(): void + { + $embedder = new FilesystemPdfImageEmbedder(); + $method = new ReflectionMethod($embedder, 'embedJpeg'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('JPEG metadata must expose width and height.'); + + $method->invoke($embedder, 'jpeg-binary', ['channels' => 3]); + } + public function testEmbedRejectsUnreadableFiles(): void { $embedder = new FilesystemPdfImageEmbedder(); @@ -196,6 +258,98 @@ public function testEmbedRejectsUnsupportedRowFilters(): void $embedder->embed($pngPath); } + public function testEmbedRejectsUnsupportedPngCompressionAndFilterMethods(): void + { + $embedder = new FilesystemPdfImageEmbedder(); + $compressionPath = $this->createTemporaryFile('png', PngFixtureFactory::createPng( + width: 1, + height: 1, + colorType: 2, + scanlines: "\x00\xff\x00\x00", + compression: 1, + )); + + try { + $embedder->embed($compressionPath); + self::fail('Expected unsupported compression to be rejected.'); + } catch (\InvalidArgumentException $exception) { + self::assertSame('Unsupported PNG compression or filter method.', $exception->getMessage()); + } + + $filterPath = $this->createTemporaryFile('png', PngFixtureFactory::createPng( + width: 1, + height: 1, + colorType: 2, + scanlines: "\x00\xff\x00\x00", + filter: 1, + )); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported PNG compression or filter method.'); + + $embedder->embed($filterPath); + } + + public function testEmbedRejectsUnsupportedPngColorTypes(): void + { + $embedder = new FilesystemPdfImageEmbedder(); + $pngPath = $this->createTemporaryFile('png', PngFixtureFactory::createPng( + width: 1, + height: 1, + colorType: 3, + scanlines: "\x00\x00", + )); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported PNG color type 3.'); + + $embedder->embed($pngPath); + } + + public function testEmbedConcatenatesMultipleIdatChunks(): void + { + $embedder = new FilesystemPdfImageEmbedder(); + $compressed = PngFixtureFactory::compressScanlines("\x00\xff\x00\x00"); + $splitAt = intdiv(strlen($compressed), 2); + $pngPath = $this->createTemporaryFile('png', PngFixtureFactory::createPngFromCompressedIdatChunks( + width: 1, + height: 1, + colorType: 2, + idatChunks: [ + substr($compressed, 0, $splitAt), + substr($compressed, $splitAt), + ], + )); + + $image = $embedder->embed($pngPath); + + self::assertSame($compressed, $image->stream); + } + + public function testEmbedSeparatesMultiRowRgbaPixelsIntoColorAndAlphaStreams(): void + { + $embedder = new FilesystemPdfImageEmbedder(); + $pngPath = $this->createTemporaryFile('png', PngFixtureFactory::createRgbaPngFromPixelRenderer( + width: 2, + height: 2, + pixelRenderer: static fn (int $x, int $y): array => match ([$x, $y]) { + [0, 0] => [255, 0, 0, 64], + [1, 0] => [0, 255, 0, 128], + [0, 1] => [0, 0, 255, 192], + default => [255, 255, 255, 255], + }, + )); + + $image = $embedder->embed($pngPath); + + self::assertNotNull($image->softMask); + self::assertSame( + "\x00\xff\x00\x00\x00\xff\x00\x00\x00\x00\xff\xff\xff\xff", + gzuncompress($image->stream), + ); + self::assertSame("\x00\x40\x80\x00\xc0\xff", gzuncompress($image->softMask->stream)); + } + public function testUnfilterPngRowSupportsAverageFilterWithMultiPixelContext(): void { $embedder = new FilesystemPdfImageEmbedder(); @@ -250,6 +404,17 @@ public static function rgbaFilterProvider(): iterable yield 'paeth filter' => ['filterType' => 4]; } + /** + * @return iterable + */ + public static function jpegChannelProvider(): iterable + { + yield 'grayscale' => ['channels' => 1, 'expectedColorSpace' => '/DeviceGray']; + yield 'rgb default' => ['channels' => 3, 'expectedColorSpace' => '/DeviceRGB']; + yield 'cmyk' => ['channels' => 4, 'expectedColorSpace' => '/DeviceCMYK']; + yield 'invalid channel metadata defaults to rgb' => ['channels' => '4', 'expectedColorSpace' => '/DeviceRGB']; + } + /** * @return iterable */ diff --git a/tests/Unit/Pdf/SinglePagePdfExporterTest.php b/tests/Unit/Pdf/SinglePagePdfExporterTest.php index 25226ac..b3388fb 100644 --- a/tests/Unit/Pdf/SinglePagePdfExporterTest.php +++ b/tests/Unit/Pdf/SinglePagePdfExporterTest.php @@ -13,6 +13,7 @@ use LibreSign\XObjectTemplate\Pdf\SinglePagePdfExporter; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use ReflectionMethod; final class SinglePagePdfExporterTest extends TestCase { @@ -308,6 +309,45 @@ public function embed(string $source): EmbeddedPdfImage self::assertStringContainsString($expectedFormStreamFragment, $pdf); } + public function testSerializeValueFormatsNumbersListsAndRawPdfValues(): void + { + $exporter = new SinglePagePdfExporter(); + + self::assertSame('12.5', $this->invokeExporterMethod($exporter, 'serializeValue', 12.5)); + self::assertSame( + '[/Name 2 0 R (text) true 3]', + $this->invokeExporterMethod($exporter, 'serializeValue', ['/Name', '2 0 R', 'text', true, 3]), + ); + } + + public function testRenderDocumentSortsObjectsAndRejectsReservedGaps(): void + { + $exporter = new SinglePagePdfExporter(); + $rendered = $this->invokeExporterMethod($exporter, 'renderDocument', [ + 2 => '<< /Type /Pages /Count 0 >>', + 1 => '<< /Type /Catalog /Pages 2 0 R >>', + ], 1); + + self::assertStringContainsString( + "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj", + (string) $rendered, + ); + self::assertStringContainsString("xref\n0 3\n", $rendered); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('PDF object 2 was reserved but not written.'); + + $this->invokeExporterMethod($exporter, 'renderDocument', [1 => '<< /Type /Catalog >>', 2 => null], 1); + } + + #[DataProvider('rawPdfValueProvider')] + public function testIsRawPdfValueMatchesOnlyPdfNamesAndReferences(string $value, bool $expected): void + { + $exporter = new SinglePagePdfExporter(); + + self::assertSame($expected, $this->invokeExporterMethod($exporter, 'isRawPdfValue', $value)); + } + #[DataProvider('invalidCompileResultProvider')] public function testExportRejectsInvalidCompileResults(CompileResult $result, string $expectedMessage): void { @@ -433,4 +473,23 @@ public static function invalidCompileResultProvider(): iterable 'expectedMessage' => 'Image resource "Im0" must expose a non-empty Source.', ]; } + + /** + * @return iterable + */ + public static function rawPdfValueProvider(): iterable + { + yield 'name' => ['value' => '/Helvetica', 'expected' => true]; + yield 'reference' => ['value' => '12 0 R', 'expected' => true]; + yield 'missing start anchor' => ['value' => 'prefix 12 0 R', 'expected' => false]; + yield 'missing end anchor' => ['value' => '12 0 R suffix', 'expected' => false]; + yield 'literal string' => ['value' => 'hello', 'expected' => false]; + } + + private function invokeExporterMethod(SinglePagePdfExporter $exporter, string $method, mixed ...$arguments): mixed + { + $reflectionMethod = new ReflectionMethod($exporter, $method); + + return $reflectionMethod->invoke($exporter, ...$arguments); + } } diff --git a/tests/Unit/Pdf/StandardFontMetricsTest.php b/tests/Unit/Pdf/StandardFontMetricsTest.php index 57b845c..726e3b0 100644 --- a/tests/Unit/Pdf/StandardFontMetricsTest.php +++ b/tests/Unit/Pdf/StandardFontMetricsTest.php @@ -12,6 +12,15 @@ final class StandardFontMetricsTest extends TestCase { + public function testMeasureStringReturnsZeroForEmptyTextAndNonPositiveSizes(): void + { + $metrics = new StandardFontMetrics(); + + self::assertSame(0.0, $metrics->measureString('F1', 10.0, '')); + self::assertSame(0.0, $metrics->measureString('F1', 0.0, 'abc')); + self::assertSame(0.0, $metrics->measureString('F1', -5.0, 'abc')); + } + public function testMeasureStringDistinguishesNarrowAndWideGlyphsInProportionalFonts(): void { $metrics = new StandardFontMetrics(); @@ -28,8 +37,24 @@ public function testMeasureStringUsesFixedWidthMetricsForCourierFonts(): void $narrow = $metrics->measureString('F5', 10.0, 'iiii'); $wide = $metrics->measureString('F5', 10.0, 'WWWW'); + $obliqueWide = $metrics->measureString('F6', 10.0, 'WWWW'); self::assertEqualsWithDelta($narrow, $wide, 0.0001); + self::assertEqualsWithDelta($wide, $obliqueWide, 0.0001); + } + + public function testMeasureStringUsesTimesMetricsForTimesAliases(): void + { + $metrics = new StandardFontMetrics(); + + self::assertSame( + $metrics->measureString('F3', 10.0, 'A'), + $metrics->measureString('F4', 10.0, 'A'), + ); + self::assertNotSame( + $metrics->measureString('F1', 10.0, 'A'), + $metrics->measureString('F3', 10.0, 'A'), + ); } public function testMeasureStringFallsBackToReasonableWidthForUnknownGlyphs(): void @@ -38,4 +63,16 @@ public function testMeasureStringFallsBackToReasonableWidthForUnknownGlyphs(): v self::assertGreaterThan(0.0, $metrics->measureString('F1', 10.0, "😀")); } + + public function testMeasureStringSplitsUnicodeCharactersAndHandlesInvalidUtf8(): void + { + $metrics = new StandardFontMetrics(); + + self::assertEqualsWithDelta( + $metrics->measureString('F1', 10.0, 'A') + $metrics->measureString('F1', 10.0, "😀"), + $metrics->measureString('F1', 10.0, "A😀"), + 0.0001, + ); + self::assertSame(0.0, $metrics->measureString('F1', 10.0, "\xc3\x28")); + } } From 90213b299f9baa8c08ef427bd11b754e4e13f7c6 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 29 May 2026 13:22:30 -0300 Subject: [PATCH 02/44] test(layout): harden text line breaker mutation coverage Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Layout/TextLineBreaker.php | 63 +++++++++--------- tests/Unit/Layout/TextLineBreakerTest.php | 81 +++++++++++++++++++++++ 2 files changed, 112 insertions(+), 32 deletions(-) diff --git a/src/Layout/TextLineBreaker.php b/src/Layout/TextLineBreaker.php index 9eafd4e..dc9e39d 100644 --- a/src/Layout/TextLineBreaker.php +++ b/src/Layout/TextLineBreaker.php @@ -65,7 +65,7 @@ public function wrap( $lines[] = $currentLine; } - return $lines === [] ? [$text] : $lines; + return $lines; } private function fitsOnCurrentLine( @@ -98,14 +98,9 @@ private function appendBrokenWord( string &$currentLine, ): void { $segments = $this->breakWord($word, $maxWidth, $fontAlias, $fontSize, $hyphens); - $lastIndex = count($segments) - 1; - - foreach ($segments as $index => $segment) { - if ($index === $lastIndex) { - $currentLine = $segment; - continue; - } + $currentLine = array_pop($segments) ?? ''; + foreach ($segments as $segment) { $lines[] = $segment; } } @@ -146,15 +141,17 @@ private function resolveManualBreaks( float $fontSize, string $hyphens, ): ?array { - if ($hyphens !== 'manual' || !str_contains($word, "\u{00AD}")) { + if ($hyphens !== 'manual') { + return null; + } + + if (!str_contains($word, "\u{00AD}")) { return null; } $manualBreaks = explode("\u{00AD}", $word); - return count($manualBreaks) > 1 - ? $this->packManualSegments($manualBreaks, $maxWidth, $fontAlias, $fontSize) - : null; + return $this->packManualSegments($manualBreaks, $maxWidth, $fontAlias, $fontSize); } /** @@ -166,64 +163,64 @@ private function breakWordAutomatically( string $fontAlias, float $fontSize, ): array { - $segments = []; $remaining = $this->splitCharacters($word); + if ($remaining === []) { + return [$word]; + } + + $segments = []; + $hyphenWidth = $this->fontMetrics->measureString($fontAlias, $fontSize, '-'); while ($remaining !== []) { - ['segment' => $segment, 'consumed' => $consumed] = $this->resolveAutoSegment( + $segment = $this->resolveAutoSegment( $remaining, $maxWidth, $fontAlias, $fontSize, + $hyphenWidth, ); - if ($consumed <= 0) { - break; - } + $consumed = count($this->splitCharacters($segment)); $remaining = array_slice($remaining, $consumed); $segments[] = $remaining === [] ? $segment : ($segment . '-'); } - return $segments === [] ? [$word] : $segments; + return $segments; } /** * @param list $remaining - * @return array{segment: string, consumed: int} + * @return non-empty-string */ private function resolveAutoSegment( array $remaining, float $maxWidth, string $fontAlias, float $fontSize, - ): array { + float $hyphenWidth, + ): string { $segment = ''; - $consumed = 0; $remainingCount = count($remaining); foreach ($remaining as $index => $character) { $candidate = $segment . $character; - $isLastCharacter = $index === ($remainingCount - 1); - $candidateWidth = $this->fontMetrics->measureString( - $fontAlias, - $fontSize, - $candidate . ($isLastCharacter ? '' : '-'), - ); + $hasMoreCharacters = ($index + 1) < $remainingCount; + $candidateWidth = $this->fontMetrics->measureString($fontAlias, $fontSize, $candidate) + + ($hasMoreCharacters ? $hyphenWidth : 0.0); if ($candidateWidth > $maxWidth && $segment !== '') { break; } if ($candidateWidth > $maxWidth) { - return ['segment' => $character, 'consumed' => 1]; + return $character; } $segment = $candidate; - $consumed = $index + 1; } - return ['segment' => $segment, 'consumed' => $consumed]; + return $segment; } /** @@ -239,13 +236,15 @@ private function packManualSegments( $packed = []; $current = ''; $lastIndex = count($segments) - 1; + $hyphenWidth = $this->fontMetrics->measureString($fontAlias, $fontSize, '-'); foreach ($segments as $index => $segment) { $candidate = $current . $segment; - $candidateWithHyphen = $candidate . ($index === $lastIndex ? '' : '-'); + $candidateWidth = $this->fontMetrics->measureString($fontAlias, $fontSize, $candidate) + + ($index === $lastIndex ? 0.0 : $hyphenWidth); if ( $current !== '' - && $this->fontMetrics->measureString($fontAlias, $fontSize, $candidateWithHyphen) > $maxWidth + && $candidateWidth > $maxWidth ) { $packed[] = $current . '-'; $current = $segment; diff --git a/tests/Unit/Layout/TextLineBreakerTest.php b/tests/Unit/Layout/TextLineBreakerTest.php index 1171b5d..630673f 100644 --- a/tests/Unit/Layout/TextLineBreakerTest.php +++ b/tests/Unit/Layout/TextLineBreakerTest.php @@ -10,6 +10,7 @@ use LibreSign\XObjectTemplate\Layout\TextLineBreaker; use LibreSign\XObjectTemplate\Pdf\StandardFontMetrics; use PHPUnit\Framework\TestCase; +use ReflectionMethod; final class TextLineBreakerTest extends TestCase { @@ -70,6 +71,72 @@ public function testWrapAutomaticallyHyphenatesFixedWidthWords(): void ); } + public function testWrapAutomaticallyHyphenatesIntoMultipleSegmentsAndLeavesLastSegmentPlain(): void + { + $breaker = new TextLineBreaker(new StandardFontMetrics()); + + self::assertSame( + ['ab-', 'cd-', 'efg'], + $breaker->wrap('abcdefg', 18.0, 'F5', 10.0, 'auto', 'normal'), + ); + } + + public function testWrapPacksManualSegmentsInOrderAtExactWidthBoundary(): void + { + $breaker = new TextLineBreaker(new StandardFontMetrics()); + + self::assertSame( + ['abc-', 'de'], + $breaker->wrap("ab\u{00AD}c\u{00AD}de", 24.0, 'F5', 10.0, 'manual', 'normal'), + ); + } + + public function testResolveManualBreaksOnlyRunsForManualSoftHyphenatedWords(): void + { + $breaker = new TextLineBreaker(new StandardFontMetrics()); + + self::assertNull( + $this->invokeBreakerMethod($breaker, 'resolveManualBreaks', "ab\u{00AD}cd", 24.0, 'F5', 10.0, 'auto'), + ); + self::assertNull( + $this->invokeBreakerMethod($breaker, 'resolveManualBreaks', 'abcd', 24.0, 'F5', 10.0, 'manual'), + ); + self::assertSame( + ['ab-', 'cd'], + $this->invokeBreakerMethod($breaker, 'resolveManualBreaks', "ab\u{00AD}cd", 18.0, 'F5', 10.0, 'manual'), + ); + } + + public function testPackManualSegmentsContinuesAcrossMultipleOverflowsAndKeepsLastSegmentPlain(): void + { + $breaker = new TextLineBreaker(new StandardFontMetrics()); + + self::assertSame( + ['ab-', 'cd-', 'efg'], + $this->invokeBreakerMethod($breaker, 'packManualSegments', ['ab', 'cd', 'ef', 'g'], 18.0, 'F5', 10.0), + ); + } + + public function testPackManualSegmentsReturnsFallbackForEmptySegmentList(): void + { + $breaker = new TextLineBreaker(new StandardFontMetrics()); + + self::assertSame( + [''], + $this->invokeBreakerMethod($breaker, 'packManualSegments', [], 18.0, 'F5', 10.0), + ); + } + + public function testSplitWordsDropsEmptyChunksAndReindexesValues(): void + { + $breaker = new TextLineBreaker(new StandardFontMetrics()); + + self::assertSame( + ['alpha', 'beta'], + $this->invokeBreakerMethod($breaker, 'splitWords', " alpha\n\tbeta "), + ); + } + public function testWrapFallsBackToSingleCharactersWhenTheFirstAutoSegmentDoesNotFit(): void { $breaker = new TextLineBreaker(new StandardFontMetrics()); @@ -87,4 +154,18 @@ public function testWrapReturnsInvalidUtf8WordsUnchangedWhenCharacterSplittingFa self::assertSame([$invalidUtf8], $breaker->wrap($invalidUtf8, 1.0, 'F1', 10.0, 'auto', 'normal')); } + + public function testSplitCharactersReturnsEmptyListForInvalidUtf8(): void + { + $breaker = new TextLineBreaker(new StandardFontMetrics()); + + self::assertSame([], $this->invokeBreakerMethod($breaker, 'splitCharacters', "\xc3\x28")); + } + + private function invokeBreakerMethod(TextLineBreaker $breaker, string $method, mixed ...$arguments): mixed + { + $reflectionMethod = new ReflectionMethod($breaker, $method); + + return $reflectionMethod->invoke($breaker, ...$arguments); + } } From b9420b46650f13cb6734df96d43dd20ae826d933 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 29 May 2026 13:26:02 -0300 Subject: [PATCH 03/44] fix(layout): align text line breaker return docs Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Layout/TextLineBreaker.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Layout/TextLineBreaker.php b/src/Layout/TextLineBreaker.php index dc9e39d..0645cfb 100644 --- a/src/Layout/TextLineBreaker.php +++ b/src/Layout/TextLineBreaker.php @@ -191,7 +191,7 @@ private function breakWordAutomatically( /** * @param list $remaining - * @return non-empty-string + * @return string */ private function resolveAutoSegment( array $remaining, From a63243eb216073036630e7bd0e78ec98e3811d4c Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 29 May 2026 14:14:56 -0300 Subject: [PATCH 04/44] fix: harden mutation hotspots Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Layout/TextOverflowTruncator.php | 27 +- src/Pdf/FilesystemPdfImageEmbedder.php | 180 ++++++-- .../Unit/Layout/TextOverflowTruncatorTest.php | 11 + .../Pdf/FilesystemPdfImageEmbedderTest.php | 387 +++++++++++++++++- 4 files changed, 555 insertions(+), 50 deletions(-) diff --git a/src/Layout/TextOverflowTruncator.php b/src/Layout/TextOverflowTruncator.php index 9f72425..5578a8f 100644 --- a/src/Layout/TextOverflowTruncator.php +++ b/src/Layout/TextOverflowTruncator.php @@ -36,15 +36,13 @@ public function forceEllipsis( float $fontSize, ): string { $ellipsis = '...'; + $ellipsisWidth = $this->fontMetrics->measureString($fontAlias, $fontSize, $ellipsis); $characters = $this->splitCharacters($text); - while ($characters !== []) { - $candidate = implode('', $characters) . $ellipsis; - if ($this->fontMetrics->measureString($fontAlias, $fontSize, $candidate) <= $maxWidth) { - return rtrim(implode('', $characters)) . $ellipsis; + foreach ($this->buildCandidates($characters) as $candidate) { + if (($this->fontMetrics->measureString($fontAlias, $fontSize, $candidate) + $ellipsisWidth) <= $maxWidth) { + return rtrim($candidate) . $ellipsis; } - - array_pop($characters); } return $ellipsis; @@ -59,4 +57,21 @@ private function splitCharacters(string $text): array return $characters === false ? [] : $characters; } + + /** + * @param list $characters + * @return list + */ + private function buildCandidates(array $characters): array + { + $candidates = []; + $candidate = ''; + + foreach ($characters as $character) { + $candidate .= $character; + $candidates[] = $candidate; + } + + return array_reverse($candidates); + } } diff --git a/src/Pdf/FilesystemPdfImageEmbedder.php b/src/Pdf/FilesystemPdfImageEmbedder.php index c59a7e2..16cb814 100644 --- a/src/Pdf/FilesystemPdfImageEmbedder.php +++ b/src/Pdf/FilesystemPdfImageEmbedder.php @@ -13,21 +13,11 @@ { public function embed(string $source): EmbeddedPdfImage { - if (!is_file($source) || !is_readable($source)) { - throw new InvalidArgumentException(sprintf('Image source "%s" must be a readable file.', $source)); - } - - $contents = file_get_contents($source); - if ($contents === false) { - throw new InvalidArgumentException(sprintf('Failed to read image source "%s".', $source)); - } - - $imageInfo = getimagesizefromstring($contents); - if ($imageInfo === false || !isset($imageInfo['mime'])) { - throw new InvalidArgumentException(sprintf('Unable to detect the image format for "%s".', $source)); - } + $this->assertReadableSource($source); - $mime = $imageInfo['mime']; + $contents = $this->readSourceContents($source); + $imageInfo = $this->detectImageInfo($contents, $source); + $mime = $this->resolveMimeType($imageInfo, $source); return match ($mime) { 'image/jpeg' => $this->embedJpeg($contents, $imageInfo), @@ -45,20 +35,16 @@ private function embedJpeg(string $contents, array $imageInfo): EmbeddedPdfImage { $width = $imageInfo[0] ?? null; $height = $imageInfo[1] ?? null; - if (!is_int($width) || !is_int($height)) { + + if (!is_int($width)) { throw new InvalidArgumentException('JPEG metadata must expose width and height.'); } - $channels = $imageInfo['channels'] ?? 3; - if (!is_int($channels)) { - $channels = 3; + if (!is_int($height)) { + throw new InvalidArgumentException('JPEG metadata must expose width and height.'); } - $colorSpace = match ($channels) { - 1 => '/DeviceGray', - 4 => '/DeviceCMYK', - default => '/DeviceRGB', - }; + $colorSpace = $this->resolveJpegColorSpace($imageInfo['channels'] ?? null); return new EmbeddedPdfImage( dictionary: [ @@ -136,11 +122,15 @@ private function parsePng(string $contents): array { $this->assertPngSignature($contents); + $contentLength = strlen($contents); $offset = 8; $header = null; $idat = ''; + $iendOffset = null; + + while (($contentLength - $offset) >= 12) { + $this->assertNoPngChunksAfterIend($iendOffset); - while ($offset + 8 <= strlen($contents)) { ['data' => $data, 'type' => $type] = $this->readPngChunk($contents, $offset); if ($type === 'IHDR') { @@ -152,10 +142,16 @@ private function parsePng(string $contents): array } if ($type === 'IEND') { - break; + $iendOffset = $offset; } } + if ($iendOffset === null) { + throw new InvalidArgumentException('PNG trailer chunk is missing.'); + } + + $this->assertPngEndsAtIend($iendOffset, $contentLength); + if ($header === null) { throw new InvalidArgumentException('PNG metadata is incomplete.'); } @@ -186,21 +182,23 @@ private function assertPngSignature(string $contents): void */ private function readPngChunk(string $contents, int &$offset): array { - $chunkLength = unpack('Nvalue', substr($contents, $offset, 4)); + $chunkLengthBytes = substr($contents, $offset, 4); + $chunkLength = $this->parseChunkLength($chunkLengthBytes); + $offset += 4; $type = substr($contents, $offset, 4); $offset += 4; - if ($chunkLength === false || !isset($chunkLength['value'])) { - throw new InvalidArgumentException('Invalid PNG chunk length.'); + if (strlen($type) !== 4) { + throw new InvalidArgumentException('Invalid PNG chunk type.'); } - $data = substr($contents, $offset, $chunkLength['value']); - if (strlen($data) !== $chunkLength['value']) { + $data = substr($contents, $offset, $chunkLength); + if (strlen($data) !== $chunkLength) { throw new InvalidArgumentException('PNG chunk data is truncated.'); } - $offset += $chunkLength['value'] + 4; + $offset += $chunkLength + 4; return [ 'data' => $data, @@ -221,13 +219,14 @@ private function readPngChunk(string $contents, int &$offset): array */ private function parsePngHeader(string $data): array { + if (strlen($data) !== 13) { + throw new InvalidArgumentException('Unable to parse the PNG IHDR chunk.'); + } + $header = unpack( 'Nwidth/Nheight/CbitDepth/CcolorType/Ccompression/Cfilter/Cinterlace', $data, ); - if ($header === false) { - throw new InvalidArgumentException('Unable to parse the PNG IHDR chunk.'); - } return $header; } @@ -285,8 +284,8 @@ private function createImageDictionary(int $width, int $height, string $colorSpa */ private function unfilterPngScanlines(string $idat, int $height, int $rowLength, int $bytesPerPixel): array { - $inflated = gzuncompress($idat); - if ($inflated === false) { + $inflated = @gzuncompress($idat); + if (!is_string($inflated)) { throw new InvalidArgumentException('PNG image data could not be decompressed.'); } @@ -323,12 +322,13 @@ private function unfilterPngRow( ): string { $row = ''; $rowLength = strlen($filteredRow); + $previousRowWithPadding = str_repeat("\x00", $bytesPerPixel) . $previousRow; for ($index = 0; $index < $rowLength; $index++) { $rawByte = ord($filteredRow[$index]); $left = $index >= $bytesPerPixel ? ord($row[$index - $bytesPerPixel]) : 0; $above = ord($previousRow[$index]); - $upperLeft = $index >= $bytesPerPixel ? ord($previousRow[$index - $bytesPerPixel]) : 0; + $upperLeft = ord($previousRowWithPadding[$index]); $decodedByte = match ($filterType) { 0 => $rawByte, @@ -354,14 +354,110 @@ private function paethPredictor(int $left, int $above, int $upperLeft): int $aboveDistance = abs($prediction - $above); $upperLeftDistance = abs($prediction - $upperLeft); - if ($leftDistance <= $aboveDistance && $leftDistance <= $upperLeftDistance) { - return $left; + $bestDistance = $leftDistance; + $bestValue = $left; + + if ($aboveDistance < $bestDistance) { + $bestDistance = $aboveDistance; + $bestValue = $above; + } + + if ($upperLeftDistance < $bestDistance) { + return $upperLeft; + } + + return $bestValue; + } + + private function parseChunkLength(string $chunkLengthBytes): int + { + if (strlen($chunkLengthBytes) !== 4) { + throw new InvalidArgumentException('Invalid PNG chunk length.'); + } + + return (ord($chunkLengthBytes[0]) << 24) + | (ord($chunkLengthBytes[1]) << 16) + | (ord($chunkLengthBytes[2]) << 8) + | ord($chunkLengthBytes[3]); + } + + private function assertNoPngChunksAfterIend(?int $iendOffset): void + { + if ($iendOffset !== null) { + throw new InvalidArgumentException('PNG data after IEND is not supported.'); + } + } + + private function assertPngEndsAtIend(int $iendOffset, int $contentLength): void + { + if ($iendOffset !== $contentLength) { + throw new InvalidArgumentException('PNG data after IEND is not supported.'); + } + } + + private function assertReadableSource(string $source): void + { + if (!is_file($source)) { + throw new InvalidArgumentException(sprintf('Image source "%s" must be an existing file.', $source)); + } + + if (!is_readable($source)) { + throw new InvalidArgumentException(sprintf('Image source "%s" must be readable.', $source)); + } + } + + private function readSourceContents(string $source): string + { + $contents = @file_get_contents($source); + if (!is_string($contents)) { + throw new InvalidArgumentException(sprintf('Failed to read image source "%s".', $source)); + } + + return $contents; + } + + /** + * @return array + */ + private function detectImageInfo(string $contents, string $source): array + { + $imageInfo = getimagesizefromstring($contents); + if (!is_array($imageInfo)) { + throw new InvalidArgumentException(sprintf('Unable to detect the image format for "%s".', $source)); + } + + return $imageInfo; + } + + /** + * @param array $imageInfo + */ + private function resolveMimeType(array $imageInfo, string $source): string + { + if (!array_key_exists('mime', $imageInfo)) { + throw new InvalidArgumentException(sprintf( + 'Image metadata for "%s" does not expose a mime type.', + $source, + )); } - if ($aboveDistance <= $upperLeftDistance) { - return $above; + $mime = $imageInfo['mime']; + if (!is_string($mime)) { + throw new InvalidArgumentException(sprintf( + 'Image metadata for "%s" must expose the mime type as a string.', + $source, + )); } - return $upperLeft; + return $mime; + } + + private function resolveJpegColorSpace(mixed $channels): string + { + return match ($channels) { + 1 => '/DeviceGray', + 4 => '/DeviceCMYK', + default => '/DeviceRGB', + }; } } diff --git a/tests/Unit/Layout/TextOverflowTruncatorTest.php b/tests/Unit/Layout/TextOverflowTruncatorTest.php index 4f79290..615f988 100644 --- a/tests/Unit/Layout/TextOverflowTruncatorTest.php +++ b/tests/Unit/Layout/TextOverflowTruncatorTest.php @@ -60,4 +60,15 @@ public function testForceEllipsisReturnsOnlyTheMarkerForInvalidUtf8Input(): void self::assertSame('...', $truncator->forceEllipsis("\xc3\x28", 10.0, 'F1', 10.0)); } + + public function testForceEllipsisUsesSuffixWidthWhenCheckingFit(): void + { + $metrics = new StandardFontMetrics(); + $truncator = new TextOverflowTruncator($metrics); + + self::assertSame( + 'i...', + $truncator->forceEllipsis('iW', $metrics->measureString('F1', 10.0, 'i...'), 'F1', 10.0), + ); + } } diff --git a/tests/Unit/Pdf/FilesystemPdfImageEmbedderTest.php b/tests/Unit/Pdf/FilesystemPdfImageEmbedderTest.php index ae3af95..8c691ed 100644 --- a/tests/Unit/Pdf/FilesystemPdfImageEmbedderTest.php +++ b/tests/Unit/Pdf/FilesystemPdfImageEmbedderTest.php @@ -91,6 +91,8 @@ public function testEmbedSupportsOpaqueGrayscalePng(): void $image = $embedder->embed($pngPath); self::assertSame('/DeviceGray', $image->dictionary['ColorSpace']); + self::assertSame(1, $image->dictionary['DecodeParms']['Colors']); + self::assertSame(8, $image->dictionary['DecodeParms']['BitsPerComponent']); self::assertNull($image->softMask); self::assertSame("\x00\x80", gzuncompress($image->stream)); } @@ -109,6 +111,8 @@ public function testEmbedCreatesSoftMaskForGrayAlphaPng(): void self::assertSame('/DeviceGray', $image->dictionary['ColorSpace']); self::assertNotNull($image->softMask); + self::assertSame(1, $image->softMask->dictionary['DecodeParms']['Colors']); + self::assertSame(8, $image->softMask->dictionary['BitsPerComponent']); self::assertSame("\x00\x80", gzuncompress($image->stream)); self::assertSame("\x00\x40", gzuncompress($image->softMask->stream)); } @@ -153,9 +157,15 @@ public function testEmbedRejectsUnsupportedPngHeaders(int $bitDepth, int $interl public function testEmbedRejectsUnsupportedFormats(): void { $embedder = new FilesystemPdfImageEmbedder(); - $gifPath = $this->createTemporaryFile('gif', 'GIF89a'); + $gifContents = base64_decode('R0lGODdhAQABAIAAAP///////ywAAAAAAQABAAACAkQBADs=', true); + if ($gifContents === false) { + self::fail('Failed to decode the embedded GIF fixture.'); + } + + $gifPath = $this->createTemporaryFile('gif', $gifContents); $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported image format "image/gif".'); $embedder->embed($gifPath); } @@ -186,8 +196,11 @@ public function testEmbedSupportsJpegStreams(): void $image = $embedder->embed($jpegPath); + self::assertSame('/XObject', $image->dictionary['Type']); + self::assertSame('/Image', $image->dictionary['Subtype']); self::assertSame('/DCTDecode', $image->dictionary['Filter']); self::assertSame('/DeviceRGB', $image->dictionary['ColorSpace']); + self::assertSame(8, $image->dictionary['BitsPerComponent']); self::assertSame(1, $image->dictionary['Width']); self::assertSame(1, $image->dictionary['Height']); self::assertSame($jpegContents, $image->stream); @@ -221,12 +234,37 @@ public function testEmbedJpegRejectsMissingDimensions(): void $method->invoke($embedder, 'jpeg-binary', ['channels' => 3]); } + public function testEmbedJpegRejectsMissingWidthOrHeightIndividually(): void + { + $embedder = new FilesystemPdfImageEmbedder(); + $method = new ReflectionMethod($embedder, 'embedJpeg'); + + foreach ([[1 => 3, 'channels' => 3], [0 => 4, 'channels' => 3]] as $metadata) { + try { + $method->invoke($embedder, 'jpeg-binary', $metadata); + self::fail('Expected missing JPEG dimensions to be rejected.'); + } catch (\InvalidArgumentException $exception) { + self::assertSame('JPEG metadata must expose width and height.', $exception->getMessage()); + } + } + } + + public function testEmbedJpegDefaultsMissingChannelMetadataToRgb(): void + { + $embedder = new FilesystemPdfImageEmbedder(); + $method = new ReflectionMethod($embedder, 'embedJpeg'); + + $image = $method->invoke($embedder, 'jpeg-binary', [0 => 4, 1 => 3]); + + self::assertSame('/DeviceRGB', $image->dictionary['ColorSpace']); + } + public function testEmbedRejectsUnreadableFiles(): void { $embedder = new FilesystemPdfImageEmbedder(); $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('must be a readable file'); + $this->expectExceptionMessage('must be an existing file'); $embedder->embed('/tmp/does-not-exist-preview.png'); } @@ -258,6 +296,57 @@ public function testEmbedRejectsUnsupportedRowFilters(): void $embedder->embed($pngPath); } + public function testEmbedRejectsPngsWithInvalidSignature(): void + { + $embedder = new FilesystemPdfImageEmbedder(); + $method = new ReflectionMethod($embedder, 'parsePng'); + $png = PngFixtureFactory::createPng( + width: 1, + height: 1, + colorType: 2, + scanlines: "\x00\xff\x00\x00", + ); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid PNG signature.'); + + $method->invoke($embedder, 'BROKEN!!' . substr($png, 8)); + } + + public function testEmbedRejectsTrailingDataAfterIendChunk(): void + { + $embedder = new FilesystemPdfImageEmbedder(); + $png = PngFixtureFactory::createPng( + width: 1, + height: 1, + colorType: 2, + scanlines: "\x00\xff\x00\x00", + ); + $trailingDataPath = $this->createTemporaryFile('png', $png . self::createPngChunk('tEXt', 'tail')); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('PNG data after IEND is not supported.'); + + $embedder->embed($trailingDataPath); + } + + public function testParsePngRejectsAdditionalChunksAfterIend(): void + { + $embedder = new FilesystemPdfImageEmbedder(); + $method = new ReflectionMethod($embedder, 'parsePng'); + $png = PngFixtureFactory::createPng( + width: 1, + height: 1, + colorType: 2, + scanlines: "\x00\xff\x00\x00", + ) . self::createPngChunk('tEXt', 'tail'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('PNG data after IEND is not supported.'); + + $method->invoke($embedder, $png); + } + public function testEmbedRejectsUnsupportedPngCompressionAndFilterMethods(): void { $embedder = new FilesystemPdfImageEmbedder(); @@ -391,6 +480,287 @@ public function testPaethPredictorSelectsExpectedNeighborAcrossTieCases(): void self::assertSame(50, $method->invoke($embedder, 50, 10, 20)); self::assertSame(50, $method->invoke($embedder, 10, 50, 20)); self::assertSame(20, $method->invoke($embedder, 10, 30, 20)); + self::assertSame(0, $method->invoke($embedder, 0, 3, 2)); + self::assertSame(3, $method->invoke($embedder, 0, 3, 1)); + } + + public function testResolveMimeTypeRejectsMissingMimeKey(): void + { + $embedder = new FilesystemPdfImageEmbedder(); + $method = new ReflectionMethod($embedder, 'resolveMimeType'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Image metadata for "fixture.png" does not expose a mime type.'); + + $method->invoke($embedder, [0 => 1, 1 => 1], 'fixture.png'); + } + + public function testResolveMimeTypeRejectsNonStringMimeValues(): void + { + $embedder = new FilesystemPdfImageEmbedder(); + $method = new ReflectionMethod($embedder, 'resolveMimeType'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Image metadata for "fixture.png" must expose the mime type as a string.'); + + $method->invoke($embedder, ['mime' => 123], 'fixture.png'); + } + + public function testReadPngChunkRejectsTruncatedLengthField(): void + { + $embedder = new FilesystemPdfImageEmbedder(); + $method = new ReflectionMethod($embedder, 'readPngChunk'); + $offset = 0; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid PNG chunk length.'); + + $method->invokeArgs($embedder, ["\x00\x00\x00", &$offset]); + } + + public function testParseChunkLengthPreservesAllFourBytes(): void + { + $embedder = new FilesystemPdfImageEmbedder(); + $method = new ReflectionMethod($embedder, 'parseChunkLength'); + + self::assertSame(0x01020304, $method->invoke($embedder, "\x01\x02\x03\x04")); + } + + public function testParsePngRejectsMissingMetadataWhenIhdrChunkIsAbsent(): void + { + $embedder = new FilesystemPdfImageEmbedder(); + $method = new ReflectionMethod($embedder, 'parsePng'); + $png = "\x89PNG\r\n\x1a\n" + . self::createPngChunk('IDAT', PngFixtureFactory::compressScanlines("\x00\xff\x00\x00")) + . self::createPngChunk('IEND', ''); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('PNG metadata is incomplete.'); + + $method->invoke($embedder, $png); + } + + public function testParsePngRejectsMissingImageDataWhenIdatChunkIsAbsent(): void + { + $embedder = new FilesystemPdfImageEmbedder(); + $method = new ReflectionMethod($embedder, 'parsePng'); + $ihdr = pack('NNCCCCC', 1, 1, 8, 2, 0, 0, 0); + $png = "\x89PNG\r\n\x1a\n" + . self::createPngChunk('IHDR', $ihdr) + . self::createPngChunk('IEND', ''); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('PNG image data is missing.'); + + $method->invoke($embedder, $png); + } + + public function testReadPngChunkRejectsInvalidChunkTypeLength(): void + { + $embedder = new FilesystemPdfImageEmbedder(); + $method = new ReflectionMethod($embedder, 'readPngChunk'); + $offset = 0; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid PNG chunk type.'); + + $method->invokeArgs($embedder, [pack('N', 0) . 'ABC', &$offset]); + } + + public function testReadPngChunkRejectsTruncatedChunkPayload(): void + { + $embedder = new FilesystemPdfImageEmbedder(); + $method = new ReflectionMethod($embedder, 'readPngChunk'); + $offset = 0; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('PNG chunk data is truncated.'); + + $method->invokeArgs($embedder, [pack('N', 1) . 'IHDR', &$offset]); + } + + public function testParsePngHeaderRejectsUnexpectedHeaderLength(): void + { + $embedder = new FilesystemPdfImageEmbedder(); + $method = new ReflectionMethod($embedder, 'parsePngHeader'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unable to parse the PNG IHDR chunk.'); + + $method->invoke($embedder, 'short-header'); + } + + public function testUnfilterPngScanlinesRejectsInvalidCompressedData(): void + { + $embedder = new FilesystemPdfImageEmbedder(); + $method = new ReflectionMethod($embedder, 'unfilterPngScanlines'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('PNG image data could not be decompressed.'); + + $method->invoke($embedder, 'not-compressed', 1, 1, 1); + } + + public function testUnfilterPngScanlinesRejectsMissingRowFilterBytes(): void + { + $embedder = new FilesystemPdfImageEmbedder(); + $method = new ReflectionMethod($embedder, 'unfilterPngScanlines'); + $compressed = gzcompress(''); + if (!is_string($compressed)) { + self::fail('Failed to compress empty scanlines fixture.'); + } + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('PNG scanlines are truncated.'); + + $method->invoke($embedder, $compressed, 1, 1, 1); + } + + public function testUnfilterPngScanlinesRejectsMissingRowPayloadBytes(): void + { + $embedder = new FilesystemPdfImageEmbedder(); + $method = new ReflectionMethod($embedder, 'unfilterPngScanlines'); + $compressed = gzcompress("\x00"); + if (!is_string($compressed)) { + self::fail('Failed to compress truncated row scanlines fixture.'); + } + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('PNG row data is truncated.'); + + $method->invoke($embedder, $compressed, 1, 1, 1); + } + + public function testUnfilterPngRowSupportsSubFilterWithMultiPixelContext(): void + { + $embedder = new FilesystemPdfImageEmbedder(); + $method = new ReflectionMethod($embedder, 'unfilterPngRow'); + + $decodedRow = $method->invoke( + $embedder, + 1, + "\x05\x06\x02\x03", + "\x00\x00\x00\x00", + 2, + ); + + self::assertSame("\x05\x06\x07\x09", $decodedRow); + } + + public function testUnfilterPngRowSupportsUpFilterWithPriorRowContext(): void + { + $embedder = new FilesystemPdfImageEmbedder(); + $method = new ReflectionMethod($embedder, 'unfilterPngRow'); + + $decodedRow = $method->invoke( + $embedder, + 2, + "\x05\x06\x07\x08", + "\x01\x02\x03\x04", + 2, + ); + + self::assertSame("\x06\x08\x0a\x0c", $decodedRow); + } + + public function testUnfilterPngRowUsesPreviousUpperLeftAtBoundary(): void + { + $embedder = new FilesystemPdfImageEmbedder(); + $method = new ReflectionMethod($embedder, 'unfilterPngRow'); + + $decodedRow = $method->invoke( + $embedder, + 4, + "\xff\x00", + "\x01\x01", + 1, + ); + + self::assertSame('0000', bin2hex((string) $decodedRow)); + } + + public function testUnfilterPngRowUsesZeroUpperLeftFallbackForFirstByte(): void + { + $embedder = new FilesystemPdfImageEmbedder(); + $method = new ReflectionMethod($embedder, 'unfilterPngRow'); + + $decodedRow = $method->invoke( + $embedder, + 4, + "\x00", + "\x01", + 1, + ); + + self::assertSame("\x01", $decodedRow); + } + + public function testParsePngRejectsMissingTrailerChunkWhenTrailingBytesAreTooShort(): void + { + $embedder = new FilesystemPdfImageEmbedder(); + $method = new ReflectionMethod($embedder, 'parsePng'); + $png = PngFixtureFactory::createPng( + width: 1, + height: 1, + colorType: 2, + scanlines: "\x00\xff\x00\x00", + ); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('PNG trailer chunk is missing.'); + + $method->invoke($embedder, substr($png, 0, -12) . "\x00\x00\x00"); + } + + public function testAssertReadableSourceRejectsUnreadableFiles(): void + { + $embedder = new FilesystemPdfImageEmbedder(); + $method = new ReflectionMethod($embedder, 'assertReadableSource'); + $path = $this->createTemporaryFile('png', 'contents'); + chmod($path, 0); + + try { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf('Image source "%s" must be readable.', $path)); + + $method->invoke($embedder, $path); + } finally { + chmod($path, 0644); + } + } + + public function testReadSourceContentsRejectsUnreadableSources(): void + { + $embedder = new FilesystemPdfImageEmbedder(); + $method = new ReflectionMethod($embedder, 'readSourceContents'); + $missingPath = sys_get_temp_dir() . '/xobject-template-missing-image.bin'; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf('Failed to read image source "%s".', $missingPath)); + + $method->invoke($embedder, $missingPath); + } + + public function testAssertNoPngChunksAfterIendRejectsAdditionalChunks(): void + { + $embedder = new FilesystemPdfImageEmbedder(); + $method = new ReflectionMethod($embedder, 'assertNoPngChunksAfterIend'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('PNG data after IEND is not supported.'); + + $method->invoke($embedder, 12); + } + + public function testAssertPngEndsAtIendRejectsTrailingData(): void + { + $embedder = new FilesystemPdfImageEmbedder(); + $method = new ReflectionMethod($embedder, 'assertPngEndsAtIend'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('PNG data after IEND is not supported.'); + + $method->invoke($embedder, 12, 16); } /** @@ -447,4 +817,17 @@ private function createTemporaryFile(string $extension, string $contents): strin return $pathWithExtension; } + + private static function createPngChunk(string $type, string $data): string + { + $crc = crc32($type . $data); + if ($crc < 0) { + $crc += 4_294_967_296; + } + + return pack('N', strlen($data)) + . $type + . $data + . pack('N', $crc); + } } From 05020a97dc3f073ca677ca807e23dc8aad49158d Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 29 May 2026 14:25:01 -0300 Subject: [PATCH 05/44] fix: address phpmd embedder warnings Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Pdf/FilesystemPdfImageEmbedder.php | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/Pdf/FilesystemPdfImageEmbedder.php b/src/Pdf/FilesystemPdfImageEmbedder.php index 16cb814..f70a037 100644 --- a/src/Pdf/FilesystemPdfImageEmbedder.php +++ b/src/Pdf/FilesystemPdfImageEmbedder.php @@ -9,6 +9,9 @@ use InvalidArgumentException; +/** + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) + */ final readonly class FilesystemPdfImageEmbedder implements PdfImageEmbedderInterface { public function embed(string $source): EmbeddedPdfImage @@ -284,7 +287,7 @@ private function createImageDictionary(int $width, int $height, string $colorSpa */ private function unfilterPngScanlines(string $idat, int $height, int $rowLength, int $bytesPerPixel): array { - $inflated = @gzuncompress($idat); + $inflated = $this->runWithoutWarnings(static fn (): string|false => gzuncompress($idat)); if (!is_string($inflated)) { throw new InvalidArgumentException('PNG image data could not be decompressed.'); } @@ -322,13 +325,13 @@ private function unfilterPngRow( ): string { $row = ''; $rowLength = strlen($filteredRow); - $previousRowWithPadding = str_repeat("\x00", $bytesPerPixel) . $previousRow; + $paddedPreviousRow = str_repeat("\x00", $bytesPerPixel) . $previousRow; for ($index = 0; $index < $rowLength; $index++) { $rawByte = ord($filteredRow[$index]); $left = $index >= $bytesPerPixel ? ord($row[$index - $bytesPerPixel]) : 0; $above = ord($previousRow[$index]); - $upperLeft = ord($previousRowWithPadding[$index]); + $upperLeft = ord($paddedPreviousRow[$index]); $decodedByte = match ($filterType) { 0 => $rawByte, @@ -408,7 +411,7 @@ private function assertReadableSource(string $source): void private function readSourceContents(string $source): string { - $contents = @file_get_contents($source); + $contents = $this->runWithoutWarnings(static fn (): string|false => file_get_contents($source)); if (!is_string($contents)) { throw new InvalidArgumentException(sprintf('Failed to read image source "%s".', $source)); } @@ -460,4 +463,15 @@ private function resolveJpegColorSpace(mixed $channels): string default => '/DeviceRGB', }; } + + private function runWithoutWarnings(callable $operation): mixed + { + set_error_handler(static fn (): bool => true); + + try { + return $operation(); + } finally { + restore_error_handler(); + } + } } From 61aeadc04b449a64db3f51e5a11837ad30b7c475 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 29 May 2026 18:33:34 -0300 Subject: [PATCH 06/44] refactor(pdf): add ParsedPngImage Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Pdf/ParsedPngImage.php | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/Pdf/ParsedPngImage.php diff --git a/src/Pdf/ParsedPngImage.php b/src/Pdf/ParsedPngImage.php new file mode 100644 index 0000000..d7e4dc7 --- /dev/null +++ b/src/Pdf/ParsedPngImage.php @@ -0,0 +1,20 @@ + Date: Fri, 29 May 2026 18:33:34 -0300 Subject: [PATCH 07/44] refactor(pdf): add WarningToExceptionConverterInterface Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Pdf/WarningToExceptionConverterInterface.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/Pdf/WarningToExceptionConverterInterface.php diff --git a/src/Pdf/WarningToExceptionConverterInterface.php b/src/Pdf/WarningToExceptionConverterInterface.php new file mode 100644 index 0000000..0ead2e4 --- /dev/null +++ b/src/Pdf/WarningToExceptionConverterInterface.php @@ -0,0 +1,14 @@ + Date: Fri, 29 May 2026 18:33:34 -0300 Subject: [PATCH 08/44] refactor(pdf): add FilesystemImageSourceReaderInterface Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Pdf/FilesystemImageSourceReaderInterface.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/Pdf/FilesystemImageSourceReaderInterface.php diff --git a/src/Pdf/FilesystemImageSourceReaderInterface.php b/src/Pdf/FilesystemImageSourceReaderInterface.php new file mode 100644 index 0000000..f1591d8 --- /dev/null +++ b/src/Pdf/FilesystemImageSourceReaderInterface.php @@ -0,0 +1,14 @@ + Date: Fri, 29 May 2026 18:33:35 -0300 Subject: [PATCH 09/44] refactor(pdf): add ImageMetadataInspectorInterface Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Pdf/ImageMetadataInspectorInterface.php | 22 +++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 src/Pdf/ImageMetadataInspectorInterface.php diff --git a/src/Pdf/ImageMetadataInspectorInterface.php b/src/Pdf/ImageMetadataInspectorInterface.php new file mode 100644 index 0000000..3d08641 --- /dev/null +++ b/src/Pdf/ImageMetadataInspectorInterface.php @@ -0,0 +1,22 @@ + + */ + public function detect(string $contents, string $source): array; + + /** + * @param array $imageInfo + */ + public function resolveMimeType(array $imageInfo, string $source): string; +} From fbe5151bfce9fe12a708e38d32560c9bb4ea4e0b Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 29 May 2026 18:33:35 -0300 Subject: [PATCH 10/44] refactor(pdf): add JpegPdfImageFactoryInterface Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Pdf/JpegPdfImageFactoryInterface.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/Pdf/JpegPdfImageFactoryInterface.php diff --git a/src/Pdf/JpegPdfImageFactoryInterface.php b/src/Pdf/JpegPdfImageFactoryInterface.php new file mode 100644 index 0000000..acb9b7c --- /dev/null +++ b/src/Pdf/JpegPdfImageFactoryInterface.php @@ -0,0 +1,17 @@ + $imageInfo + */ + public function create(string $contents, array $imageInfo): EmbeddedPdfImage; +} From 138dd60a04526da96ccb83351b4372436b3912d1 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 29 May 2026 18:33:35 -0300 Subject: [PATCH 11/44] refactor(pdf): add PngParserInterface Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Pdf/PngParserInterface.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/Pdf/PngParserInterface.php diff --git a/src/Pdf/PngParserInterface.php b/src/Pdf/PngParserInterface.php new file mode 100644 index 0000000..28bff14 --- /dev/null +++ b/src/Pdf/PngParserInterface.php @@ -0,0 +1,14 @@ + Date: Fri, 29 May 2026 18:33:35 -0300 Subject: [PATCH 12/44] refactor(pdf): add PngPdfImageFactoryInterface Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Pdf/PngPdfImageFactoryInterface.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/Pdf/PngPdfImageFactoryInterface.php diff --git a/src/Pdf/PngPdfImageFactoryInterface.php b/src/Pdf/PngPdfImageFactoryInterface.php new file mode 100644 index 0000000..4e11dce --- /dev/null +++ b/src/Pdf/PngPdfImageFactoryInterface.php @@ -0,0 +1,14 @@ + Date: Fri, 29 May 2026 18:33:35 -0300 Subject: [PATCH 13/44] refactor(pdf): add PngScanlineUnfiltererInterface Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Pdf/PngScanlineUnfiltererInterface.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/Pdf/PngScanlineUnfiltererInterface.php diff --git a/src/Pdf/PngScanlineUnfiltererInterface.php b/src/Pdf/PngScanlineUnfiltererInterface.php new file mode 100644 index 0000000..0f200b8 --- /dev/null +++ b/src/Pdf/PngScanlineUnfiltererInterface.php @@ -0,0 +1,17 @@ + + */ + public function unfilter(string $idat, int $height, int $rowLength, int $bytesPerPixel): array; +} From 3f26bd6326c23777ecbd45683884d07b5b6cf28f Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 29 May 2026 18:33:35 -0300 Subject: [PATCH 14/44] refactor(pdf): add PhpWarningToExceptionConverter Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Pdf/PhpWarningToExceptionConverter.php | 34 ++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/Pdf/PhpWarningToExceptionConverter.php diff --git a/src/Pdf/PhpWarningToExceptionConverter.php b/src/Pdf/PhpWarningToExceptionConverter.php new file mode 100644 index 0000000..9b9de6a --- /dev/null +++ b/src/Pdf/PhpWarningToExceptionConverter.php @@ -0,0 +1,34 @@ + Date: Fri, 29 May 2026 18:33:35 -0300 Subject: [PATCH 15/44] refactor(pdf): add FilesystemImageSourceReader Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Pdf/FilesystemImageSourceReader.php | 47 +++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/Pdf/FilesystemImageSourceReader.php diff --git a/src/Pdf/FilesystemImageSourceReader.php b/src/Pdf/FilesystemImageSourceReader.php new file mode 100644 index 0000000..40b0c87 --- /dev/null +++ b/src/Pdf/FilesystemImageSourceReader.php @@ -0,0 +1,47 @@ +warningConverter = $warningConverter ?? new PhpWarningToExceptionConverter(); + } + + public function read(string $source): string + { + $this->assertReadableSource($source); + + $contents = $this->warningConverter->run( + static fn (): string|false => file_get_contents($source), + sprintf('Failed to read image source "%s".', $source), + ); + if (!is_string($contents)) { + throw new InvalidArgumentException(sprintf('Failed to read image source "%s".', $source)); + } + + return $contents; + } + + private function assertReadableSource(string $source): void + { + if (!is_file($source)) { + throw new InvalidArgumentException(sprintf('Image source "%s" must be an existing file.', $source)); + } + + if (!is_readable($source)) { + throw new InvalidArgumentException(sprintf('Image source "%s" must be readable.', $source)); + } + } +} From 36285102dde9a21b8ba51ed8565585fe385ae316 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 29 May 2026 18:33:35 -0300 Subject: [PATCH 16/44] refactor(pdf): add ImageMetadataInspector Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Pdf/ImageMetadataInspector.php | 50 ++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 src/Pdf/ImageMetadataInspector.php diff --git a/src/Pdf/ImageMetadataInspector.php b/src/Pdf/ImageMetadataInspector.php new file mode 100644 index 0000000..a2d8831 --- /dev/null +++ b/src/Pdf/ImageMetadataInspector.php @@ -0,0 +1,50 @@ + + */ + public function detect(string $contents, string $source): array + { + $imageInfo = getimagesizefromstring($contents); + if (!is_array($imageInfo)) { + throw new InvalidArgumentException(sprintf('Unable to detect the image format for "%s".', $source)); + } + + return $imageInfo; + } + + /** + * @param array $imageInfo + */ + public function resolveMimeType(array $imageInfo, string $source): string + { + if (!array_key_exists('mime', $imageInfo)) { + throw new InvalidArgumentException(sprintf( + 'Image metadata for "%s" does not expose a mime type.', + $source, + )); + } + + $mime = $imageInfo['mime']; + if (!is_string($mime)) { + throw new InvalidArgumentException(sprintf( + 'Image metadata for "%s" must expose the mime type as a string.', + $source, + )); + } + + return $mime; + } +} From e456b23ab9b6b4315c8c33a323e8153fed8a370e Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 29 May 2026 18:33:35 -0300 Subject: [PATCH 17/44] refactor(pdf): add JpegPdfImageFactory Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Pdf/JpegPdfImageFactory.php | 49 +++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/Pdf/JpegPdfImageFactory.php diff --git a/src/Pdf/JpegPdfImageFactory.php b/src/Pdf/JpegPdfImageFactory.php new file mode 100644 index 0000000..a4de767 --- /dev/null +++ b/src/Pdf/JpegPdfImageFactory.php @@ -0,0 +1,49 @@ + $imageInfo + */ + public function create(string $contents, array $imageInfo): EmbeddedPdfImage + { + $width = $imageInfo[0] ?? null; + $height = $imageInfo[1] ?? null; + + if (!is_int($width) || !is_int($height)) { + throw new InvalidArgumentException('JPEG metadata must expose width and height.'); + } + + return new EmbeddedPdfImage( + dictionary: [ + 'Type' => '/XObject', + 'Subtype' => '/Image', + 'Width' => $width, + 'Height' => $height, + 'ColorSpace' => $this->resolveColorSpace($imageInfo['channels'] ?? null), + 'BitsPerComponent' => 8, + 'Filter' => '/DCTDecode', + ], + stream: $contents, + ); + } + + private function resolveColorSpace(mixed $channels): string + { + return match ($channels) { + 1 => '/DeviceGray', + 4 => '/DeviceCMYK', + default => '/DeviceRGB', + }; + } +} From 028ac997a702daba77748fef97a5585cc54269e1 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 29 May 2026 18:33:35 -0300 Subject: [PATCH 18/44] refactor(pdf): add PngColorTypeDescription Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Pdf/PngColorTypeDescription.php | 47 +++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/Pdf/PngColorTypeDescription.php diff --git a/src/Pdf/PngColorTypeDescription.php b/src/Pdf/PngColorTypeDescription.php new file mode 100644 index 0000000..2e08890 --- /dev/null +++ b/src/Pdf/PngColorTypeDescription.php @@ -0,0 +1,47 @@ +colorCount = $colorCount; + $this->bytesPerPixel = $bytesPerPixel; + } +} From 9a0cc99cad0af2a2ff0d1002ec4eae5b5bfcbfc0 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 29 May 2026 18:33:36 -0300 Subject: [PATCH 19/44] refactor(pdf): add PngParser Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Pdf/PngParser.php | 163 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 src/Pdf/PngParser.php diff --git a/src/Pdf/PngParser.php b/src/Pdf/PngParser.php new file mode 100644 index 0000000..4733c99 --- /dev/null +++ b/src/Pdf/PngParser.php @@ -0,0 +1,163 @@ +assertPngSignature($contents); + + $contentLength = strlen($contents); + $offset = 8; + $header = null; + $idat = ''; + + while (($contentLength - $offset) >= 12) { + ['data' => $data, 'type' => $type] = $this->readChunk($contents, $offset); + + if ($type === 'IHDR') { + $header = $this->parseHeader($data); + } + + if ($type === 'IDAT') { + $idat .= $data; + } + + if ($type === 'IEND') { + if ($offset !== $contentLength) { + throw new InvalidArgumentException('PNG data after IEND is not supported.'); + } + + if ($header === null) { + throw new InvalidArgumentException('PNG metadata is incomplete.'); + } + + $this->assertSupportedHeader($header); + + if ($idat === '') { + throw new InvalidArgumentException('PNG image data is missing.'); + } + + return new ParsedPngImage( + width: $header['width'], + height: $header['height'], + colorType: $header['colorType'], + idat: $idat, + ); + } + } + + throw new InvalidArgumentException('PNG trailer chunk is missing.'); + } + + /** + * @return array{data: string, type: string} + */ + public function readChunk(string $contents, int &$offset): array + { + $chunkLengthBytes = substr($contents, $offset, 4); + $chunkLength = $this->parseChunkLength($chunkLengthBytes); + + $offset += 4; + $type = substr($contents, $offset, 4); + $offset += 4; + + if (strlen($type) !== 4) { + throw new InvalidArgumentException('Invalid PNG chunk type.'); + } + + $data = substr($contents, $offset, $chunkLength); + if (strlen($data) !== $chunkLength) { + throw new InvalidArgumentException('PNG chunk data is truncated.'); + } + + $offset += $chunkLength + 4; + + return [ + 'data' => $data, + 'type' => $type, + ]; + } + + /** + * @return array{ + * width: int, + * height: int, + * bitDepth: int, + * colorType: int, + * compression: int, + * filter: int, + * interlace: int + * } + */ + public function parseHeader(string $data): array + { + if (strlen($data) !== 13) { + throw new InvalidArgumentException('Unable to parse the PNG IHDR chunk.'); + } + + $header = unpack( + 'Nwidth/Nheight/CbitDepth/CcolorType/Ccompression/Cfilter/Cinterlace', + $data, + ); + if (!is_array($header)) { + throw new InvalidArgumentException('Unable to parse the PNG IHDR chunk.'); + } + + return $header; + } + + public function parseChunkLength(string $chunkLengthBytes): int + { + if (strlen($chunkLengthBytes) !== 4) { + throw new InvalidArgumentException('Invalid PNG chunk length.'); + } + + return (ord($chunkLengthBytes[0]) << 24) + | (ord($chunkLengthBytes[1]) << 16) + | (ord($chunkLengthBytes[2]) << 8) + | ord($chunkLengthBytes[3]); + } + + private function assertPngSignature(string $contents): void + { + if (str_starts_with($contents, "\x89PNG\r\n\x1a\n") === false) { + throw new InvalidArgumentException('Invalid PNG signature.'); + } + } + + /** + * @param array{ + * width: int, + * height: int, + * bitDepth: int, + * colorType: int, + * compression: int, + * filter: int, + * interlace: int + * } $header + */ + private function assertSupportedHeader(array $header): void + { + if ($header['bitDepth'] !== 8) { + throw new InvalidArgumentException(sprintf('Unsupported PNG bit depth %d.', $header['bitDepth'])); + } + + if ($header['compression'] !== 0 || $header['filter'] !== 0) { + throw new InvalidArgumentException('Unsupported PNG compression or filter method.'); + } + + if ($header['interlace'] !== 0) { + throw new InvalidArgumentException('Interlaced PNG images are not supported.'); + } + } +} From 3236a0569b397a4bf1cd65520fd7299e9c316347 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 29 May 2026 18:33:36 -0300 Subject: [PATCH 20/44] refactor(pdf): add PngScanlineUnfilterer Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Pdf/PngScanlineUnfilterer.php | 111 ++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 src/Pdf/PngScanlineUnfilterer.php diff --git a/src/Pdf/PngScanlineUnfilterer.php b/src/Pdf/PngScanlineUnfilterer.php new file mode 100644 index 0000000..f6b3d61 --- /dev/null +++ b/src/Pdf/PngScanlineUnfilterer.php @@ -0,0 +1,111 @@ +warningConverter = $warningConverter ?? new PhpWarningToExceptionConverter(); + } + + /** + * @return list + */ + public function unfilter(string $idat, int $height, int $rowLength, int $bytesPerPixel): array + { + $inflated = $this->warningConverter->run( + static fn (): string|false => gzuncompress($idat), + 'PNG image data could not be decompressed.', + ); + if (!is_string($inflated)) { + throw new InvalidArgumentException('PNG image data could not be decompressed.'); + } + + $rows = []; + $offset = 0; + $previousRow = str_repeat("\x00", $rowLength); + + for ($rowIndex = 0; $rowIndex < $height; $rowIndex++) { + if (!isset($inflated[$offset])) { + throw new InvalidArgumentException('PNG scanlines are truncated.'); + } + + $filterType = ord($inflated[$offset]); + $offset++; + $filteredRow = substr($inflated, $offset, $rowLength); + if (strlen($filteredRow) !== $rowLength) { + throw new InvalidArgumentException('PNG row data is truncated.'); + } + + $offset += $rowLength; + $row = $this->unfilterRow($filterType, $filteredRow, $previousRow, $bytesPerPixel); + $rows[] = $row; + $previousRow = $row; + } + + return $rows; + } + + public function unfilterRow( + int $filterType, + string $filteredRow, + string $previousRow, + int $bytesPerPixel, + ): string { + $row = ''; + $paddedPreviousRow = str_repeat("\x00", $bytesPerPixel) . $previousRow; + + foreach (str_split($filteredRow) as $index => $rawByteCharacter) { + $rawByte = ord($rawByteCharacter); + $left = $index >= $bytesPerPixel ? ord($row[$index - $bytesPerPixel]) : 0; + $above = ord($previousRow[$index]); + $upperLeft = ord($paddedPreviousRow[$index]); + + $decodedByte = match ($filterType) { + 0 => $rawByte, + 1 => ($rawByte + $left) & 0xff, + 2 => ($rawByte + $above) & 0xff, + 3 => ($rawByte + intdiv($left + $above, 2)) & 0xff, + 4 => ($rawByte + $this->paethPredictor($left, $above, $upperLeft)) & 0xff, + default => throw new InvalidArgumentException( + sprintf('Unsupported PNG row filter %d.', $filterType), + ), + }; + + $row .= chr($decodedByte); + } + + return $row; + } + + public function paethPredictor(int $left, int $above, int $upperLeft): int + { + $prediction = $left + $above - $upperLeft; + $leftDistance = abs($prediction - $left); + $aboveDistance = abs($prediction - $above); + $upperLeftDistance = abs($prediction - $upperLeft); + + $bestDistance = min($leftDistance, $aboveDistance, $upperLeftDistance); + + if ($bestDistance === $leftDistance) { + return $left; + } + + if ($bestDistance === $aboveDistance) { + return $above; + } + + return $upperLeft; + } +} From 35351381bf9ee3baf27bea1b721dbcac1233e4b8 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 29 May 2026 18:33:36 -0300 Subject: [PATCH 21/44] refactor(pdf): add PngPdfImageFactory Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Pdf/PngPdfImageFactory.php | 138 +++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 src/Pdf/PngPdfImageFactory.php diff --git a/src/Pdf/PngPdfImageFactory.php b/src/Pdf/PngPdfImageFactory.php new file mode 100644 index 0000000..156a61e --- /dev/null +++ b/src/Pdf/PngPdfImageFactory.php @@ -0,0 +1,138 @@ +parser = $parser ?? new PngParser(); + $this->scanlineUnfilterer = $scanlineUnfilterer ?? new PngScanlineUnfilterer(); + } + + public function create(string $contents): EmbeddedPdfImage + { + $png = $this->parser->parse($contents); + $colorType = $this->describeColorType($png->colorType); + + if ($colorType->hasAlpha === false) { + return new EmbeddedPdfImage( + dictionary: $this->createImageDictionary( + $png->width, + $png->height, + $colorType->colorSpace, + $colorType->colorCount, + ), + stream: $png->idat, + ); + } + + [$colorScanlines, $alphaScanlines] = $this->splitAlphaScanlines($png, $colorType); + + return new EmbeddedPdfImage( + dictionary: $this->createImageDictionary( + $png->width, + $png->height, + $colorType->colorSpace, + $colorType->colorCount, + ), + stream: $this->compressScanlines($colorScanlines), + softMask: new EmbeddedPdfImage( + dictionary: $this->createImageDictionary($png->width, $png->height, '/DeviceGray', 1), + stream: $this->compressScanlines($alphaScanlines), + ), + ); + } + + private function describeColorType(int $colorType): PngColorTypeDescription + { + return match ($colorType) { + 0 => new PngColorTypeDescription('/DeviceGray', 1, 1, false), + 2 => new PngColorTypeDescription('/DeviceRGB', 3, 3, false), + 4 => new PngColorTypeDescription('/DeviceGray', 1, 2, true), + 6 => new PngColorTypeDescription('/DeviceRGB', 3, 4, true), + default => throw new InvalidArgumentException(sprintf('Unsupported PNG color type %d.', $colorType)), + }; + } + + /** + * @return array{0: string, 1: string} + */ + private function splitAlphaScanlines(ParsedPngImage $png, PngColorTypeDescription $colorType): array + { + $colorCount = $colorType->colorCount; + $bytesPerPixel = $colorType->bytesPerPixel; + $rowLength = $png->width * $bytesPerPixel; + $unfilteredRows = $this->scanlineUnfilterer->unfilter( + $png->idat, + $png->height, + $rowLength, + $bytesPerPixel, + ); + + $colorScanlines = ''; + $alphaScanlines = ''; + foreach ($unfilteredRows as $row) { + $colorRow = ''; + $alphaRow = ''; + foreach (str_split($row, $bytesPerPixel) as $pixel) { + if (strlen($pixel) !== $bytesPerPixel) { + throw new InvalidArgumentException('PNG row data is truncated.'); + } + + $colorRow .= substr($pixel, 0, $colorCount); + $alphaRow .= $pixel[$bytesPerPixel - 1]; + } + + $colorScanlines .= "\x00" . $colorRow; + $alphaScanlines .= "\x00" . $alphaRow; + } + + return [$colorScanlines, $alphaScanlines]; + } + + /** + * @return array + */ + private function createImageDictionary(int $width, int $height, string $colorSpace, int $colorCount): array + { + return [ + 'Type' => '/XObject', + 'Subtype' => '/Image', + 'Width' => $width, + 'Height' => $height, + 'ColorSpace' => $colorSpace, + 'BitsPerComponent' => 8, + 'Filter' => '/FlateDecode', + 'DecodeParms' => [ + 'Predictor' => 15, + 'Colors' => $colorCount, + 'BitsPerComponent' => 8, + 'Columns' => $width, + ], + ]; + } + + private function compressScanlines(string $scanlines): string + { + $compressed = gzcompress($scanlines); + if (!is_string($compressed)) { + throw new InvalidArgumentException('PNG scanlines could not be compressed.'); + } + + return $compressed; + } +} From a5bd7e2b44d0180156367c41a59b251e4cb0b154 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 29 May 2026 18:33:36 -0300 Subject: [PATCH 22/44] refactor(pdf): update FilesystemPdfImageEmbedder Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Pdf/FilesystemPdfImageEmbedder.php | 476 ++----------------------- 1 file changed, 22 insertions(+), 454 deletions(-) diff --git a/src/Pdf/FilesystemPdfImageEmbedder.php b/src/Pdf/FilesystemPdfImageEmbedder.php index f70a037..aa987e8 100644 --- a/src/Pdf/FilesystemPdfImageEmbedder.php +++ b/src/Pdf/FilesystemPdfImageEmbedder.php @@ -9,469 +9,37 @@ use InvalidArgumentException; -/** - * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) - */ final readonly class FilesystemPdfImageEmbedder implements PdfImageEmbedderInterface { + private FilesystemImageSourceReaderInterface $sourceReader; + private ImageMetadataInspectorInterface $metadataInspector; + private JpegPdfImageFactoryInterface $jpegImageFactory; + private PngPdfImageFactoryInterface $pngImageFactory; + + public function __construct( + ?FilesystemImageSourceReaderInterface $sourceReader = null, + ?ImageMetadataInspectorInterface $metadataInspector = null, + ?JpegPdfImageFactoryInterface $jpegImageFactory = null, + ?PngPdfImageFactoryInterface $pngImageFactory = null, + ) { + $this->sourceReader = $sourceReader ?? new FilesystemImageSourceReader(); + $this->metadataInspector = $metadataInspector ?? new ImageMetadataInspector(); + $this->jpegImageFactory = $jpegImageFactory ?? new JpegPdfImageFactory(); + $this->pngImageFactory = $pngImageFactory ?? new PngPdfImageFactory(); + } + public function embed(string $source): EmbeddedPdfImage { - $this->assertReadableSource($source); - - $contents = $this->readSourceContents($source); - $imageInfo = $this->detectImageInfo($contents, $source); - $mime = $this->resolveMimeType($imageInfo, $source); + $contents = $this->sourceReader->read($source); + $imageInfo = $this->metadataInspector->detect($contents, $source); + $mime = $this->metadataInspector->resolveMimeType($imageInfo, $source); return match ($mime) { - 'image/jpeg' => $this->embedJpeg($contents, $imageInfo), - 'image/png' => $this->embedPng($contents), + 'image/jpeg' => $this->jpegImageFactory->create($contents, $imageInfo), + 'image/png' => $this->pngImageFactory->create($contents), default => throw new InvalidArgumentException( sprintf('Unsupported image format "%s".', $mime), ), }; } - - /** - * @param array $imageInfo - */ - private function embedJpeg(string $contents, array $imageInfo): EmbeddedPdfImage - { - $width = $imageInfo[0] ?? null; - $height = $imageInfo[1] ?? null; - - if (!is_int($width)) { - throw new InvalidArgumentException('JPEG metadata must expose width and height.'); - } - - if (!is_int($height)) { - throw new InvalidArgumentException('JPEG metadata must expose width and height.'); - } - - $colorSpace = $this->resolveJpegColorSpace($imageInfo['channels'] ?? null); - - return new EmbeddedPdfImage( - dictionary: [ - 'Type' => '/XObject', - 'Subtype' => '/Image', - 'Width' => $width, - 'Height' => $height, - 'ColorSpace' => $colorSpace, - 'BitsPerComponent' => 8, - 'Filter' => '/DCTDecode', - ], - stream: $contents, - ); - } - - private function embedPng(string $contents): EmbeddedPdfImage - { - $png = $this->parsePng($contents); - [$colorSpace, $colorCount, $hasAlpha] = $this->describePngColorType($png['colorType']); - - if ($hasAlpha === false) { - return new EmbeddedPdfImage( - dictionary: $this->createImageDictionary($png['width'], $png['height'], $colorSpace, $colorCount), - stream: $png['idat'], - ); - } - - $bytesPerPixel = $colorCount + 1; - $rowLength = $png['width'] * $bytesPerPixel; - $unfilteredRows = $this->unfilterPngScanlines($png['idat'], $png['height'], $rowLength, $bytesPerPixel); - - $colorScanlines = ''; - $alphaScanlines = ''; - foreach ($unfilteredRows as $row) { - $colorRow = ''; - $alphaRow = ''; - for ($offset = 0; $offset < strlen($row); $offset += $bytesPerPixel) { - $pixel = substr($row, $offset, $bytesPerPixel); - $colorRow .= substr($pixel, 0, $colorCount); - $alphaRow .= $pixel[$bytesPerPixel - 1]; - } - - $colorScanlines .= "\x00" . $colorRow; - $alphaScanlines .= "\x00" . $alphaRow; - } - - return new EmbeddedPdfImage( - dictionary: $this->createImageDictionary($png['width'], $png['height'], $colorSpace, $colorCount), - stream: gzcompress($colorScanlines), - softMask: new EmbeddedPdfImage( - dictionary: $this->createImageDictionary($png['width'], $png['height'], '/DeviceGray', 1), - stream: gzcompress($alphaScanlines), - ), - ); - } - - /** - * @return array{0: string, 1: int, 2: bool} - */ - private function describePngColorType(int $colorType): array - { - return match ($colorType) { - 0 => ['/DeviceGray', 1, false], - 2 => ['/DeviceRGB', 3, false], - 4 => ['/DeviceGray', 1, true], - 6 => ['/DeviceRGB', 3, true], - default => throw new InvalidArgumentException(sprintf('Unsupported PNG color type %d.', $colorType)), - }; - } - - /** - * @return array{width: int, height: int, colorType: int, idat: string} - */ - private function parsePng(string $contents): array - { - $this->assertPngSignature($contents); - - $contentLength = strlen($contents); - $offset = 8; - $header = null; - $idat = ''; - $iendOffset = null; - - while (($contentLength - $offset) >= 12) { - $this->assertNoPngChunksAfterIend($iendOffset); - - ['data' => $data, 'type' => $type] = $this->readPngChunk($contents, $offset); - - if ($type === 'IHDR') { - $header = $this->parsePngHeader($data); - } - - if ($type === 'IDAT') { - $idat .= $data; - } - - if ($type === 'IEND') { - $iendOffset = $offset; - } - } - - if ($iendOffset === null) { - throw new InvalidArgumentException('PNG trailer chunk is missing.'); - } - - $this->assertPngEndsAtIend($iendOffset, $contentLength); - - if ($header === null) { - throw new InvalidArgumentException('PNG metadata is incomplete.'); - } - - $this->assertSupportedPngHeader($header); - - if ($idat === '') { - throw new InvalidArgumentException('PNG image data is missing.'); - } - - return [ - 'width' => $header['width'], - 'height' => $header['height'], - 'colorType' => $header['colorType'], - 'idat' => $idat, - ]; - } - - private function assertPngSignature(string $contents): void - { - if (str_starts_with($contents, "\x89PNG\r\n\x1a\n") === false) { - throw new InvalidArgumentException('Invalid PNG signature.'); - } - } - - /** - * @return array{data: string, type: string} - */ - private function readPngChunk(string $contents, int &$offset): array - { - $chunkLengthBytes = substr($contents, $offset, 4); - $chunkLength = $this->parseChunkLength($chunkLengthBytes); - - $offset += 4; - $type = substr($contents, $offset, 4); - $offset += 4; - - if (strlen($type) !== 4) { - throw new InvalidArgumentException('Invalid PNG chunk type.'); - } - - $data = substr($contents, $offset, $chunkLength); - if (strlen($data) !== $chunkLength) { - throw new InvalidArgumentException('PNG chunk data is truncated.'); - } - - $offset += $chunkLength + 4; - - return [ - 'data' => $data, - 'type' => $type, - ]; - } - - /** - * @return array{ - * width: int, - * height: int, - * bitDepth: int, - * colorType: int, - * compression: int, - * filter: int, - * interlace: int - * } - */ - private function parsePngHeader(string $data): array - { - if (strlen($data) !== 13) { - throw new InvalidArgumentException('Unable to parse the PNG IHDR chunk.'); - } - - $header = unpack( - 'Nwidth/Nheight/CbitDepth/CcolorType/Ccompression/Cfilter/Cinterlace', - $data, - ); - - return $header; - } - - /** - * @param array{ - * width: int, - * height: int, - * bitDepth: int, - * colorType: int, - * compression: int, - * filter: int, - * interlace: int - * } $header - */ - private function assertSupportedPngHeader(array $header): void - { - if ($header['bitDepth'] !== 8) { - throw new InvalidArgumentException(sprintf('Unsupported PNG bit depth %d.', $header['bitDepth'])); - } - - if ($header['compression'] !== 0 || $header['filter'] !== 0) { - throw new InvalidArgumentException('Unsupported PNG compression or filter method.'); - } - - if ($header['interlace'] !== 0) { - throw new InvalidArgumentException('Interlaced PNG images are not supported.'); - } - } - - /** - * @return array - */ - private function createImageDictionary(int $width, int $height, string $colorSpace, int $colorCount): array - { - return [ - 'Type' => '/XObject', - 'Subtype' => '/Image', - 'Width' => $width, - 'Height' => $height, - 'ColorSpace' => $colorSpace, - 'BitsPerComponent' => 8, - 'Filter' => '/FlateDecode', - 'DecodeParms' => [ - 'Predictor' => 15, - 'Colors' => $colorCount, - 'BitsPerComponent' => 8, - 'Columns' => $width, - ], - ]; - } - - /** - * @return list - */ - private function unfilterPngScanlines(string $idat, int $height, int $rowLength, int $bytesPerPixel): array - { - $inflated = $this->runWithoutWarnings(static fn (): string|false => gzuncompress($idat)); - if (!is_string($inflated)) { - throw new InvalidArgumentException('PNG image data could not be decompressed.'); - } - - $rows = []; - $offset = 0; - $previousRow = str_repeat("\x00", $rowLength); - - for ($rowIndex = 0; $rowIndex < $height; $rowIndex++) { - if (!isset($inflated[$offset])) { - throw new InvalidArgumentException('PNG scanlines are truncated.'); - } - - $filterType = ord($inflated[$offset]); - $offset++; - $filteredRow = substr($inflated, $offset, $rowLength); - if (strlen($filteredRow) !== $rowLength) { - throw new InvalidArgumentException('PNG row data is truncated.'); - } - - $offset += $rowLength; - $row = $this->unfilterPngRow($filterType, $filteredRow, $previousRow, $bytesPerPixel); - $rows[] = $row; - $previousRow = $row; - } - - return $rows; - } - - private function unfilterPngRow( - int $filterType, - string $filteredRow, - string $previousRow, - int $bytesPerPixel, - ): string { - $row = ''; - $rowLength = strlen($filteredRow); - $paddedPreviousRow = str_repeat("\x00", $bytesPerPixel) . $previousRow; - - for ($index = 0; $index < $rowLength; $index++) { - $rawByte = ord($filteredRow[$index]); - $left = $index >= $bytesPerPixel ? ord($row[$index - $bytesPerPixel]) : 0; - $above = ord($previousRow[$index]); - $upperLeft = ord($paddedPreviousRow[$index]); - - $decodedByte = match ($filterType) { - 0 => $rawByte, - 1 => ($rawByte + $left) & 0xff, - 2 => ($rawByte + $above) & 0xff, - 3 => ($rawByte + intdiv($left + $above, 2)) & 0xff, - 4 => ($rawByte + $this->paethPredictor($left, $above, $upperLeft)) & 0xff, - default => throw new InvalidArgumentException( - sprintf('Unsupported PNG row filter %d.', $filterType), - ), - }; - - $row .= chr($decodedByte); - } - - return $row; - } - - private function paethPredictor(int $left, int $above, int $upperLeft): int - { - $prediction = $left + $above - $upperLeft; - $leftDistance = abs($prediction - $left); - $aboveDistance = abs($prediction - $above); - $upperLeftDistance = abs($prediction - $upperLeft); - - $bestDistance = $leftDistance; - $bestValue = $left; - - if ($aboveDistance < $bestDistance) { - $bestDistance = $aboveDistance; - $bestValue = $above; - } - - if ($upperLeftDistance < $bestDistance) { - return $upperLeft; - } - - return $bestValue; - } - - private function parseChunkLength(string $chunkLengthBytes): int - { - if (strlen($chunkLengthBytes) !== 4) { - throw new InvalidArgumentException('Invalid PNG chunk length.'); - } - - return (ord($chunkLengthBytes[0]) << 24) - | (ord($chunkLengthBytes[1]) << 16) - | (ord($chunkLengthBytes[2]) << 8) - | ord($chunkLengthBytes[3]); - } - - private function assertNoPngChunksAfterIend(?int $iendOffset): void - { - if ($iendOffset !== null) { - throw new InvalidArgumentException('PNG data after IEND is not supported.'); - } - } - - private function assertPngEndsAtIend(int $iendOffset, int $contentLength): void - { - if ($iendOffset !== $contentLength) { - throw new InvalidArgumentException('PNG data after IEND is not supported.'); - } - } - - private function assertReadableSource(string $source): void - { - if (!is_file($source)) { - throw new InvalidArgumentException(sprintf('Image source "%s" must be an existing file.', $source)); - } - - if (!is_readable($source)) { - throw new InvalidArgumentException(sprintf('Image source "%s" must be readable.', $source)); - } - } - - private function readSourceContents(string $source): string - { - $contents = $this->runWithoutWarnings(static fn (): string|false => file_get_contents($source)); - if (!is_string($contents)) { - throw new InvalidArgumentException(sprintf('Failed to read image source "%s".', $source)); - } - - return $contents; - } - - /** - * @return array - */ - private function detectImageInfo(string $contents, string $source): array - { - $imageInfo = getimagesizefromstring($contents); - if (!is_array($imageInfo)) { - throw new InvalidArgumentException(sprintf('Unable to detect the image format for "%s".', $source)); - } - - return $imageInfo; - } - - /** - * @param array $imageInfo - */ - private function resolveMimeType(array $imageInfo, string $source): string - { - if (!array_key_exists('mime', $imageInfo)) { - throw new InvalidArgumentException(sprintf( - 'Image metadata for "%s" does not expose a mime type.', - $source, - )); - } - - $mime = $imageInfo['mime']; - if (!is_string($mime)) { - throw new InvalidArgumentException(sprintf( - 'Image metadata for "%s" must expose the mime type as a string.', - $source, - )); - } - - return $mime; - } - - private function resolveJpegColorSpace(mixed $channels): string - { - return match ($channels) { - 1 => '/DeviceGray', - 4 => '/DeviceCMYK', - default => '/DeviceRGB', - }; - } - - private function runWithoutWarnings(callable $operation): mixed - { - set_error_handler(static fn (): bool => true); - - try { - return $operation(); - } finally { - restore_error_handler(); - } - } } From f06fdc4c8d01527f7d0570f9c30508666b4d5f76 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 29 May 2026 18:33:36 -0300 Subject: [PATCH 23/44] refactor(pdf): update SinglePagePdfExporter Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Pdf/SinglePagePdfExporter.php | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/Pdf/SinglePagePdfExporter.php b/src/Pdf/SinglePagePdfExporter.php index 2b4c917..bd0e0fd 100644 --- a/src/Pdf/SinglePagePdfExporter.php +++ b/src/Pdf/SinglePagePdfExporter.php @@ -207,8 +207,12 @@ private function serializeValue(mixed $value): string return $this->serializeArrayValue($value); } - if (is_int($value) || is_float($value)) { - return $this->formatNumber((float) $value); + if (is_int($value)) { + return (string) $value; + } + + if (is_float($value)) { + return $this->formatNumber($value); } if (is_string($value)) { @@ -265,8 +269,8 @@ private function renderDocument(array $objects, int $catalogReference): string $pdf .= sprintf("xref\n0 %d\n", $objectCount + 1); $pdf .= "0000000000 65535 f \n"; - for ($reference = 1; $reference <= $objectCount; $reference++) { - $pdf .= sprintf("%010d 00000 n \n", $offsets[$reference]); + foreach ($offsets as $offset) { + $pdf .= sprintf("%010d 00000 n \n", $offset); } $pdf .= "trailer\n"; From b91d793b7067bbe726b85cb5395cbe10f2e8c3fb Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 29 May 2026 18:33:36 -0300 Subject: [PATCH 24/44] fix(pdf): update StandardFontMetrics Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Pdf/StandardFontMetrics.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Pdf/StandardFontMetrics.php b/src/Pdf/StandardFontMetrics.php index bb69291..06d747a 100644 --- a/src/Pdf/StandardFontMetrics.php +++ b/src/Pdf/StandardFontMetrics.php @@ -55,7 +55,15 @@ public function measureString(string $fontAlias, float $fontSize, string $text): float { - if ($text === '' || $fontSize <= 0.0) { + if ($text === '') { + return 0.0; + } + + if ($fontSize === 0.0) { + return 0.0; + } + + if (max($fontSize, 0.0) !== $fontSize) { return 0.0; } From 9ca5e75663df991bbd688cef7ae004b0bf6019cf Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 29 May 2026 18:33:36 -0300 Subject: [PATCH 25/44] fix(layout): update TextLineBreaker Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Layout/TextLineBreaker.php | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/Layout/TextLineBreaker.php b/src/Layout/TextLineBreaker.php index 0645cfb..86798ef 100644 --- a/src/Layout/TextLineBreaker.php +++ b/src/Layout/TextLineBreaker.php @@ -115,7 +115,7 @@ private function breakWord( float $fontSize, string $hyphens, ): array { - if ($hyphens === 'none') { + if ($hyphens === 'none' || ($hyphens !== 'manual' && $hyphens !== 'auto')) { return [$word]; } @@ -124,10 +124,6 @@ private function breakWord( return $manualSegments; } - if ($hyphens !== 'auto') { - return [$word]; - } - return $this->breakWordAutomatically($word, $maxWidth, $fontAlias, $fontSize); } @@ -163,27 +159,30 @@ private function breakWordAutomatically( string $fontAlias, float $fontSize, ): array { - $remaining = $this->splitCharacters($word); - if ($remaining === []) { + $characters = $this->splitCharacters($word); + if ($characters === []) { return [$word]; } $segments = []; $hyphenWidth = $this->fontMetrics->measureString($fontAlias, $fontSize, '-'); + $offset = 0; + $characterCount = count($characters); - while ($remaining !== []) { + while (isset($characters[$offset])) { $segment = $this->resolveAutoSegment( - $remaining, + array_slice($characters, $offset), $maxWidth, $fontAlias, $fontSize, $hyphenWidth, ); - $consumed = count($this->splitCharacters($segment)); - - $remaining = array_slice($remaining, $consumed); - $segments[] = $remaining === [] ? $segment : ($segment . '-'); + $segmentCharacters = $this->splitCharacters($segment); + $fallbackCount = count($this->splitCharacters($characters[$offset])); + $consumedCount = max(count($segmentCharacters) + $fallbackCount - 1, $fallbackCount); + $offset = min($offset + $consumedCount, $characterCount); + $segments[] = !isset($characters[$offset]) ? $segment : ($segment . '-'); } return $segments; From 9c36b68c562996ef05c26b9469e79cd49f1b810e Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 29 May 2026 18:33:36 -0300 Subject: [PATCH 26/44] test(support): add UsesTemporaryFiles Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/Support/UsesTemporaryFiles.php | 48 ++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 tests/Support/UsesTemporaryFiles.php diff --git a/tests/Support/UsesTemporaryFiles.php b/tests/Support/UsesTemporaryFiles.php new file mode 100644 index 0000000..9ecd811 --- /dev/null +++ b/tests/Support/UsesTemporaryFiles.php @@ -0,0 +1,48 @@ + */ + private array $temporaryFiles = []; + + protected function tearDownTemporaryFiles(): void + { + foreach ($this->temporaryFiles as $temporaryFile) { + if (is_file($temporaryFile)) { + unlink($temporaryFile); + } + } + + $this->temporaryFiles = []; + } + + protected function createTemporaryFile(string $extension, string $contents): string + { + $temporaryFile = tempnam(sys_get_temp_dir(), 'xot_'); + if ($temporaryFile === false) { + throw new RuntimeException('Failed to create a temporary file for image export tests.'); + } + + $pathWithExtension = $temporaryFile . '.' . $extension; + if (rename($temporaryFile, $pathWithExtension) === false) { + throw new RuntimeException('Failed to rename the temporary image fixture.'); + } + + if (file_put_contents($pathWithExtension, $contents) === false) { + throw new RuntimeException('Failed to write the temporary image fixture.'); + } + + $this->temporaryFiles[] = $pathWithExtension; + + return $pathWithExtension; + } +} From c391d664751ff0a0b5a73323e6fcab40c0b20e02 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 29 May 2026 18:33:36 -0300 Subject: [PATCH 27/44] test(support): update PngFixtureFactory Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/Support/PngFixtureFactory.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Support/PngFixtureFactory.php b/tests/Support/PngFixtureFactory.php index 7ca8bc0..eb5c120 100644 --- a/tests/Support/PngFixtureFactory.php +++ b/tests/Support/PngFixtureFactory.php @@ -98,7 +98,7 @@ public static function createRgbaPngFromPixelRenderer( return self::createPng($width, $height, 6, $scanlines); } - private static function createChunk(string $type, string $data): string + public static function createChunk(string $type, string $data): string { $crc = crc32($type . $data); if ($crc < 0) { From 5bfe53d83be0d468e88ef01f8fca675d9f8b0e75 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 29 May 2026 18:33:36 -0300 Subject: [PATCH 28/44] test(pdf): add FilesystemImageSourceReaderTest Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Pdf/FilesystemImageSourceReaderTest.php | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 tests/Unit/Pdf/FilesystemImageSourceReaderTest.php diff --git a/tests/Unit/Pdf/FilesystemImageSourceReaderTest.php b/tests/Unit/Pdf/FilesystemImageSourceReaderTest.php new file mode 100644 index 0000000..e434fb1 --- /dev/null +++ b/tests/Unit/Pdf/FilesystemImageSourceReaderTest.php @@ -0,0 +1,70 @@ +tearDownTemporaryFiles(); + } + + public function testReadReturnsContentsForReadableFiles(): void + { + $reader = new FilesystemImageSourceReader(); + $path = $this->createTemporaryFile('png', 'contents'); + + self::assertSame('contents', $reader->read($path)); + } + + public function testReadUsesInjectedWarningConverter(): void + { + $reader = new FilesystemImageSourceReader(new class implements WarningToExceptionConverterInterface { + public function run(callable $operation, string $message): mixed + { + return 'converted-contents'; + } + }); + $path = $this->createTemporaryFile('png', 'disk-contents'); + + self::assertSame('converted-contents', $reader->read($path)); + } + + public function testReadRejectsMissingSources(): void + { + $reader = new FilesystemImageSourceReader(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('must be an existing file'); + + $reader->read('/tmp/does-not-exist-preview.png'); + } + + public function testReadRejectsUnreadableSources(): void + { + $reader = new FilesystemImageSourceReader(); + $path = $this->createTemporaryFile('png', 'contents'); + chmod($path, 0); + + try { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf('Image source "%s" must be readable.', $path)); + + $reader->read($path); + } finally { + chmod($path, 0644); + } + } +} From 9c53398247a206325fc5b1e192f7b4bd4dbc0ed1 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 29 May 2026 18:33:36 -0300 Subject: [PATCH 29/44] test(pdf): add ImageMetadataInspectorTest Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/Unit/Pdf/ImageMetadataInspectorTest.php | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 tests/Unit/Pdf/ImageMetadataInspectorTest.php diff --git a/tests/Unit/Pdf/ImageMetadataInspectorTest.php b/tests/Unit/Pdf/ImageMetadataInspectorTest.php new file mode 100644 index 0000000..757242b --- /dev/null +++ b/tests/Unit/Pdf/ImageMetadataInspectorTest.php @@ -0,0 +1,62 @@ +detect($png, 'fixture.png'); + + self::assertSame(1, $imageInfo[0]); + self::assertSame(1, $imageInfo[1]); + self::assertSame('image/png', $inspector->resolveMimeType($imageInfo, 'fixture.png')); + } + + public function testDetectRejectsUnknownBinaryPayloads(): void + { + $inspector = new ImageMetadataInspector(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unable to detect the image format for "fixture.bin".'); + + $inspector->detect('not-an-image', 'fixture.bin'); + } + + public function testResolveMimeTypeRejectsMissingMimeKey(): void + { + $inspector = new ImageMetadataInspector(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Image metadata for "fixture.png" does not expose a mime type.'); + + $inspector->resolveMimeType([0 => 1, 1 => 1], 'fixture.png'); + } + + public function testResolveMimeTypeRejectsNonStringMimeValues(): void + { + $inspector = new ImageMetadataInspector(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Image metadata for "fixture.png" must expose the mime type as a string.'); + + $inspector->resolveMimeType(['mime' => 123], 'fixture.png'); + } +} From 6b9733c415112013a70ee386b719be17fec62e33 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 29 May 2026 18:33:37 -0300 Subject: [PATCH 30/44] test(pdf): add JpegPdfImageFactoryTest Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/Unit/Pdf/JpegPdfImageFactoryTest.php | 74 ++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 tests/Unit/Pdf/JpegPdfImageFactoryTest.php diff --git a/tests/Unit/Pdf/JpegPdfImageFactoryTest.php b/tests/Unit/Pdf/JpegPdfImageFactoryTest.php new file mode 100644 index 0000000..ea1f69c --- /dev/null +++ b/tests/Unit/Pdf/JpegPdfImageFactoryTest.php @@ -0,0 +1,74 @@ +create('jpeg-binary', [0 => 4, 1 => 3, 'channels' => $channels]); + + self::assertSame($expectedColorSpace, $image->dictionary['ColorSpace']); + self::assertSame(4, $image->dictionary['Width']); + self::assertSame(3, $image->dictionary['Height']); + self::assertSame('jpeg-binary', $image->stream); + } + + public function testCreateRejectsMissingDimensions(): void + { + $factory = new JpegPdfImageFactory(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('JPEG metadata must expose width and height.'); + + $factory->create('jpeg-binary', ['channels' => 3]); + } + + public function testCreateRejectsMissingWidthOrHeightIndividually(): void + { + $factory = new JpegPdfImageFactory(); + + foreach ([[1 => 3, 'channels' => 3], [0 => 4, 'channels' => 3]] as $metadata) { + try { + $factory->create('jpeg-binary', $metadata); + self::fail('Expected missing JPEG dimensions to be rejected.'); + } catch (\InvalidArgumentException $exception) { + self::assertSame('JPEG metadata must expose width and height.', $exception->getMessage()); + } + } + } + + public function testCreateDefaultsMissingChannelMetadataToRgb(): void + { + $factory = new JpegPdfImageFactory(); + + $image = $factory->create('jpeg-binary', [0 => 4, 1 => 3]); + + self::assertSame('/DeviceRGB', $image->dictionary['ColorSpace']); + } + + /** + * @return iterable + */ + public static function jpegChannelProvider(): iterable + { + yield 'grayscale' => ['channels' => 1, 'expectedColorSpace' => '/DeviceGray']; + yield 'rgb default' => ['channels' => 3, 'expectedColorSpace' => '/DeviceRGB']; + yield 'cmyk' => ['channels' => 4, 'expectedColorSpace' => '/DeviceCMYK']; + yield 'invalid channel metadata defaults to rgb' => ['channels' => '4', 'expectedColorSpace' => '/DeviceRGB']; + } +} From eef32a045869694a0145e0df4c37036754b8b0e5 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 29 May 2026 18:33:37 -0300 Subject: [PATCH 31/44] test(pdf): add PhpWarningToExceptionConverterTest Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../PhpWarningToExceptionConverterTest.php | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 tests/Unit/Pdf/PhpWarningToExceptionConverterTest.php diff --git a/tests/Unit/Pdf/PhpWarningToExceptionConverterTest.php b/tests/Unit/Pdf/PhpWarningToExceptionConverterTest.php new file mode 100644 index 0000000..c5828c2 --- /dev/null +++ b/tests/Unit/Pdf/PhpWarningToExceptionConverterTest.php @@ -0,0 +1,42 @@ +run(static fn (): string => 'ok', 'unused')); + } + + public function testRunConvertsWarningsToInvalidArgumentExceptions(): void + { + $converter = new PhpWarningToExceptionConverter(); + + try { + $converter->run( + static function (): void { + trigger_error('warning-from-test', E_USER_WARNING); + }, + 'converted message', + ); + self::fail('Expected the warning to be converted into an exception.'); + } catch (\InvalidArgumentException $exception) { + self::assertSame('converted message', $exception->getMessage()); + self::assertSame(0, $exception->getCode()); + self::assertInstanceOf(ErrorException::class, $exception->getPrevious()); + self::assertSame(0, $exception->getPrevious()?->getCode()); + } + } +} From 5ff240f5bb8a6eec1484d72c8f1edc39a15ff27a Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 29 May 2026 18:33:37 -0300 Subject: [PATCH 32/44] test(pdf): add PngColorTypeDescriptionTest Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Unit/Pdf/PngColorTypeDescriptionTest.php | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 tests/Unit/Pdf/PngColorTypeDescriptionTest.php diff --git a/tests/Unit/Pdf/PngColorTypeDescriptionTest.php b/tests/Unit/Pdf/PngColorTypeDescriptionTest.php new file mode 100644 index 0000000..090b8c2 --- /dev/null +++ b/tests/Unit/Pdf/PngColorTypeDescriptionTest.php @@ -0,0 +1,66 @@ +colorSpace); + self::assertSame($colorCount, $description->colorCount); + self::assertSame($bytesPerPixel, $description->bytesPerPixel); + self::assertSame($hasAlpha, $description->hasAlpha); + } + + /** + * @return iterable + */ + public static function provideSupportedColorLayouts(): iterable + { + yield 'grayscale' => ['/DeviceGray', 1, 1, false]; + yield 'rgb' => ['/DeviceRGB', 3, 3, false]; + yield 'grayscale with alpha' => ['/DeviceGray', 1, 2, true]; + yield 'rgb with alpha' => ['/DeviceRGB', 3, 4, true]; + } + + #[DataProvider('provideInvalidColorLayouts')] + public function testConstructorRejectsInvalidColorLayouts( + int $colorCount, + int $bytesPerPixel, + bool $hasAlpha, + string $message, + ): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + new PngColorTypeDescription('/DeviceRGB', $colorCount, $bytesPerPixel, $hasAlpha); + } + + /** + * @return iterable + */ + public static function provideInvalidColorLayouts(): iterable + { + yield 'unsupported color count' => [2, 2, false, 'PNG color count must be 1 or 3.']; + yield 'non-positive bytes per pixel' => [1, 0, false, 'PNG bytes per pixel must be positive.']; + yield 'opaque layout mismatch' => [1, 2, false, 'PNG color layout is inconsistent.']; + yield 'alpha layout mismatch' => [3, 3, true, 'PNG color layout is inconsistent.']; + } +} From 62c5f4988c77c492b873d3046b374b01ab287a14 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 29 May 2026 18:33:37 -0300 Subject: [PATCH 33/44] test(pdf): add PngParserTest Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/Unit/Pdf/PngParserTest.php | 175 +++++++++++++++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 tests/Unit/Pdf/PngParserTest.php diff --git a/tests/Unit/Pdf/PngParserTest.php b/tests/Unit/Pdf/PngParserTest.php new file mode 100644 index 0000000..80e06f6 --- /dev/null +++ b/tests/Unit/Pdf/PngParserTest.php @@ -0,0 +1,175 @@ +parse($png); + + self::assertSame(1, $parsed->width); + self::assertSame(1, $parsed->height); + self::assertSame(2, $parsed->colorType); + self::assertSame($compressed, $parsed->idat); + } + + public function testParseRejectsInvalidSignature(): void + { + $parser = new PngParser(); + $png = PngFixtureFactory::createPng( + width: 1, + height: 1, + colorType: 2, + scanlines: "\x00\xff\x00\x00", + ); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid PNG signature.'); + + $parser->parse('BROKEN!!' . substr($png, 8)); + } + + public function testParseRejectsTrailingDataAfterIendChunk(): void + { + $parser = new PngParser(); + $png = PngFixtureFactory::createPng( + width: 1, + height: 1, + colorType: 2, + scanlines: "\x00\xff\x00\x00", + ) . PngFixtureFactory::createChunk('tEXt', 'tail'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('PNG data after IEND is not supported.'); + + $parser->parse($png); + } + + public function testParseRejectsTrailingBytesAfterIend(): void + { + $parser = new PngParser(); + $png = PngFixtureFactory::createPng( + width: 1, + height: 1, + colorType: 2, + scanlines: "\x00\xff\x00\x00", + ) . "\x00\x00\x00"; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('PNG data after IEND is not supported.'); + + $parser->parse($png); + } + + public function testReadChunkRejectsTruncatedLengthField(): void + { + $parser = new PngParser(); + $offset = 0; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid PNG chunk length.'); + + $parser->readChunk("\x00\x00\x00", $offset); + } + + public function testParseChunkLengthPreservesAllFourBytes(): void + { + $parser = new PngParser(); + + self::assertSame(0x01020304, $parser->parseChunkLength("\x01\x02\x03\x04")); + } + + public function testParseRejectsMissingMetadataWhenIhdrChunkIsAbsent(): void + { + $parser = new PngParser(); + $png = "\x89PNG\r\n\x1a\n" + . PngFixtureFactory::createChunk('IDAT', PngFixtureFactory::compressScanlines("\x00\xff\x00\x00")) + . PngFixtureFactory::createChunk('IEND', ''); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('PNG metadata is incomplete.'); + + $parser->parse($png); + } + + public function testParseRejectsMissingImageDataWhenIdatChunkIsAbsent(): void + { + $parser = new PngParser(); + $ihdr = pack('NNCCCCC', 1, 1, 8, 2, 0, 0, 0); + $png = "\x89PNG\r\n\x1a\n" + . PngFixtureFactory::createChunk('IHDR', $ihdr) + . PngFixtureFactory::createChunk('IEND', ''); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('PNG image data is missing.'); + + $parser->parse($png); + } + + public function testReadChunkRejectsInvalidChunkTypeLength(): void + { + $parser = new PngParser(); + $offset = 0; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid PNG chunk type.'); + + $parser->readChunk(pack('N', 0) . 'ABC', $offset); + } + + public function testReadChunkRejectsTruncatedChunkPayload(): void + { + $parser = new PngParser(); + $offset = 0; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('PNG chunk data is truncated.'); + + $parser->readChunk(pack('N', 1) . 'IHDR', $offset); + } + + public function testParseHeaderRejectsUnexpectedHeaderLength(): void + { + $parser = new PngParser(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unable to parse the PNG IHDR chunk.'); + + $parser->parseHeader('short-header'); + } + + public function testParseRejectsMissingTrailerChunkWhenTrailingBytesAreTooShort(): void + { + $parser = new PngParser(); + $png = PngFixtureFactory::createPng( + width: 1, + height: 1, + colorType: 2, + scanlines: "\x00\xff\x00\x00", + ); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('PNG trailer chunk is missing.'); + + $parser->parse(substr($png, 0, -12) . "\x00\x00\x00"); + } +} From cc8815b9d5082832f4e6b0f3180e622dbe2a1e47 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 29 May 2026 18:33:37 -0300 Subject: [PATCH 34/44] test(pdf): add PngPdfImageFactoryTest Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/Unit/Pdf/PngPdfImageFactoryTest.php | 88 +++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 tests/Unit/Pdf/PngPdfImageFactoryTest.php diff --git a/tests/Unit/Pdf/PngPdfImageFactoryTest.php b/tests/Unit/Pdf/PngPdfImageFactoryTest.php new file mode 100644 index 0000000..c68ced3 --- /dev/null +++ b/tests/Unit/Pdf/PngPdfImageFactoryTest.php @@ -0,0 +1,88 @@ +create('not-a-real-png'); + + self::assertSame('/DeviceRGB', $image->dictionary['ColorSpace']); + self::assertSame('opaque-idat', $image->stream); + self::assertNull($image->softMask); + } + + public function testCreateUsesInjectedScanlineUnfiltererForAlphaImages(): 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"]; + } + }, + ); + + $image = $factory->create('not-a-real-png'); + + self::assertNotNull($image->softMask); + self::assertSame("\x00\xff\x00\x00", gzuncompress($image->stream)); + self::assertSame("\x00\x80", gzuncompress($image->softMask->stream)); + } + + public function testCreateRejectsUnsupportedColorTypes(): void + { + $factory = new PngPdfImageFactory( + new class implements PngParserInterface { + public function parse(string $contents): ParsedPngImage + { + return new ParsedPngImage(1, 1, 3, 'opaque-idat'); + } + }, + new class implements PngScanlineUnfiltererInterface { + public function unfilter(string $idat, int $height, int $rowLength, int $bytesPerPixel): array + { + throw new \RuntimeException('Unsupported color types should fail before unfiltering.'); + } + }, + ); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported PNG color type 3.'); + + $factory->create('not-a-real-png'); + } +} From 48f84a3815d942239b655f3c4376db1bd586e921 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 29 May 2026 18:33:37 -0300 Subject: [PATCH 35/44] test(pdf): add PngScanlineUnfiltererTest Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/Unit/Pdf/PngScanlineUnfiltererTest.php | 201 +++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 tests/Unit/Pdf/PngScanlineUnfiltererTest.php diff --git a/tests/Unit/Pdf/PngScanlineUnfiltererTest.php b/tests/Unit/Pdf/PngScanlineUnfiltererTest.php new file mode 100644 index 0000000..14d42cd --- /dev/null +++ b/tests/Unit/Pdf/PngScanlineUnfiltererTest.php @@ -0,0 +1,201 @@ +expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('PNG image data could not be decompressed.'); + + $unfilterer->unfilter('not-compressed', 1, 1, 1); + } + + public function testUnfilterUsesInjectedWarningConverterResult(): void + { + $unfilterer = new PngScanlineUnfilterer(new class implements WarningToExceptionConverterInterface { + public function run(callable $operation, string $message): mixed + { + return "\x00\x7f"; + } + }); + + self::assertSame(["\x7f"], $unfilterer->unfilter('ignored-idat', 1, 1, 1)); + } + + public function testUnfilterRejectsMissingRowFilterBytes(): void + { + $unfilterer = new PngScanlineUnfilterer(); + $compressed = gzcompress(''); + if (!is_string($compressed)) { + self::fail('Failed to compress empty scanlines fixture.'); + } + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('PNG scanlines are truncated.'); + + $unfilterer->unfilter($compressed, 1, 1, 1); + } + + public function testUnfilterRejectsMissingRowPayloadBytes(): void + { + $unfilterer = new PngScanlineUnfilterer(); + $compressed = gzcompress("\x00"); + if (!is_string($compressed)) { + self::fail('Failed to compress truncated row scanlines fixture.'); + } + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('PNG row data is truncated.'); + + $unfilterer->unfilter($compressed, 1, 1, 1); + } + + public function testUnfilterRowSupportsSubFilterWithMultiPixelContext(): void + { + $unfilterer = new PngScanlineUnfilterer(); + + $decodedRow = $unfilterer->unfilterRow( + 1, + "\x05\x06\x02\x03", + "\x00\x00\x00\x00", + 2, + ); + + self::assertSame("\x05\x06\x07\x09", $decodedRow); + } + + public function testUnfilterRowSupportsUpFilterWithPriorRowContext(): void + { + $unfilterer = new PngScanlineUnfilterer(); + + $decodedRow = $unfilterer->unfilterRow( + 2, + "\x05\x06\x07\x08", + "\x01\x02\x03\x04", + 2, + ); + + self::assertSame("\x06\x08\x0a\x0c", $decodedRow); + } + + public function testUnfilterRowSupportsAverageFilterWithMultiPixelContext(): void + { + $unfilterer = new PngScanlineUnfilterer(); + + $decodedRow = $unfilterer->unfilterRow( + 3, + "\x55\x5a\x5f\x64\x3c\x3c\x3c\x3c", + "\x0a\x14\x1e\x28\x32\x3c\x46\x50", + 4, + ); + + self::assertSame("\x5a\x64\x6e\x78\x82\x8c\x96\xa0", $decodedRow); + } + + public function testUnfilterRowSupportsPaethFilterWithMultiPixelContext(): void + { + $unfilterer = new PngScanlineUnfilterer(); + + $decodedRow = $unfilterer->unfilterRow( + 4, + "\x0a\x0a\x0a\x0a\x0a\x14\x1e\x28", + "\x0a\x14\x1e\x28\x3c\x46\x50\x5a", + 4, + ); + + self::assertSame("\x14\x1e\x28\x32\x46\x5a\x6e\x82", $decodedRow); + } + + public function testUnfilterRowUsesPreviousUpperLeftAtBoundary(): void + { + $unfilterer = new PngScanlineUnfilterer(); + + $decodedRow = $unfilterer->unfilterRow( + 4, + "\xff\x00", + "\x01\x01", + 1, + ); + + self::assertSame('0000', bin2hex($decodedRow)); + } + + public function testUnfilterRowUsesZeroUpperLeftFallbackForFirstByte(): void + { + $unfilterer = new PngScanlineUnfilterer(); + + $decodedRow = $unfilterer->unfilterRow( + 4, + "\x00", + "\x01", + 1, + ); + + self::assertSame("\x01", $decodedRow); + } + + public function testPaethPredictorSelectsExpectedNeighborAcrossTieCases(): void + { + $unfilterer = new PngScanlineUnfilterer(); + + self::assertSame(20, $unfilterer->paethPredictor(20, 20, 10)); + self::assertSame(50, $unfilterer->paethPredictor(50, 10, 20)); + self::assertSame(50, $unfilterer->paethPredictor(10, 50, 20)); + self::assertSame(20, $unfilterer->paethPredictor(10, 30, 20)); + self::assertSame(0, $unfilterer->paethPredictor(0, 3, 2)); + self::assertSame(3, $unfilterer->paethPredictor(0, 3, 1)); + } + + public function testPaethPredictorMatchesReferenceImplementationAcrossRepresentativeRange(): void + { + $unfilterer = new PngScanlineUnfilterer(); + + for ($left = 0; $left <= 15; $left++) { + for ($above = 0; $above <= 15; $above++) { + for ($upperLeft = 0; $upperLeft <= 15; $upperLeft++) { + self::assertSame( + $this->referencePaethPredictor($left, $above, $upperLeft), + $unfilterer->paethPredictor($left, $above, $upperLeft), + sprintf( + 'Failed for left=%d, above=%d, upperLeft=%d.', + $left, + $above, + $upperLeft, + ), + ); + } + } + } + } + + private function referencePaethPredictor(int $left, int $above, int $upperLeft): int + { + $prediction = $left + $above - $upperLeft; + $leftDistance = abs($prediction - $left); + $aboveDistance = abs($prediction - $above); + $upperLeftDistance = abs($prediction - $upperLeft); + + if ($leftDistance <= $aboveDistance && $leftDistance <= $upperLeftDistance) { + return $left; + } + + if ($aboveDistance <= $upperLeftDistance) { + return $above; + } + + return $upperLeft; + } +} From d81a686afb7ace6b5413991fd7032ccc6eeb9095 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 29 May 2026 18:33:37 -0300 Subject: [PATCH 36/44] test(pdf): update FilesystemPdfImageEmbedderTest Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Pdf/FilesystemPdfImageEmbedderTest.php | 552 +++--------------- 1 file changed, 95 insertions(+), 457 deletions(-) diff --git a/tests/Unit/Pdf/FilesystemPdfImageEmbedderTest.php b/tests/Unit/Pdf/FilesystemPdfImageEmbedderTest.php index 8c691ed..64eb454 100644 --- a/tests/Unit/Pdf/FilesystemPdfImageEmbedderTest.php +++ b/tests/Unit/Pdf/FilesystemPdfImageEmbedderTest.php @@ -7,24 +7,108 @@ namespace LibreSign\XObjectTemplate\Tests\Unit\Pdf; +use LibreSign\XObjectTemplate\Pdf\EmbeddedPdfImage; use LibreSign\XObjectTemplate\Pdf\FilesystemPdfImageEmbedder; +use LibreSign\XObjectTemplate\Pdf\FilesystemImageSourceReaderInterface; +use LibreSign\XObjectTemplate\Pdf\ImageMetadataInspectorInterface; +use LibreSign\XObjectTemplate\Pdf\JpegPdfImageFactoryInterface; +use LibreSign\XObjectTemplate\Pdf\PngPdfImageFactoryInterface; use LibreSign\XObjectTemplate\Tests\Support\PngFixtureFactory; +use LibreSign\XObjectTemplate\Tests\Support\UsesTemporaryFiles; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -use ReflectionMethod; final class FilesystemPdfImageEmbedderTest extends TestCase { - /** @var list */ - private array $temporaryFiles = []; + use UsesTemporaryFiles; protected function tearDown(): void { - foreach ($this->temporaryFiles as $temporaryFile) { - @unlink($temporaryFile); - } + $this->tearDownTemporaryFiles(); + } + + public function testEmbedUsesInjectedCollaboratorsForJpegImages(): void + { + $expectedImage = new EmbeddedPdfImage(['Type' => '/Image'], 'jpeg-stream'); + $embedder = new FilesystemPdfImageEmbedder( + new class implements FilesystemImageSourceReaderInterface { + public function read(string $source): string + { + return 'jpeg-binary'; + } + }, + new class implements ImageMetadataInspectorInterface { + public function detect(string $contents, string $source): array + { + return [0 => 1, 1 => 1, 'mime' => 'image/jpeg']; + } + + public function resolveMimeType(array $imageInfo, string $source): string + { + return 'image/jpeg'; + } + }, + new class ($expectedImage) implements JpegPdfImageFactoryInterface { + public function __construct(private readonly EmbeddedPdfImage $expectedImage) + { + } + + public function create(string $contents, array $imageInfo): EmbeddedPdfImage + { + return $this->expectedImage; + } + }, + new class implements PngPdfImageFactoryInterface { + public function create(string $contents): EmbeddedPdfImage + { + throw new \RuntimeException('PNG factory should not be used for JPEG images.'); + } + }, + ); + + self::assertSame($expectedImage, $embedder->embed('/tmp/virtual-image.jpg')); + } + + public function testEmbedUsesInjectedCollaboratorsForPngImages(): void + { + $expectedImage = new EmbeddedPdfImage(['Type' => '/Image'], 'png-stream'); + $embedder = new FilesystemPdfImageEmbedder( + new class implements FilesystemImageSourceReaderInterface { + public function read(string $source): string + { + return 'not-a-real-png'; + } + }, + new class implements ImageMetadataInspectorInterface { + public function detect(string $contents, string $source): array + { + return [0 => 1, 1 => 1, 'mime' => 'image/png']; + } + + public function resolveMimeType(array $imageInfo, string $source): string + { + return 'image/png'; + } + }, + new class implements JpegPdfImageFactoryInterface { + public function create(string $contents, array $imageInfo): EmbeddedPdfImage + { + throw new \RuntimeException('JPEG factory should not be used for PNG images.'); + } + }, + new class ($expectedImage) implements PngPdfImageFactoryInterface { + public function __construct(private readonly EmbeddedPdfImage $expectedImage) + { + } + + public function create(string $contents): EmbeddedPdfImage + { + return $this->expectedImage; + } + }, + ); - $this->temporaryFiles = []; + self::assertSame($expectedImage, $embedder->embed('/tmp/virtual-image.png')); } public function testEmbedReturnsPredictorBackedImageForOpaqueRgbPng(): void @@ -207,58 +291,6 @@ public function testEmbedSupportsJpegStreams(): void self::assertNull($image->softMask); } - #[DataProvider('jpegChannelProvider')] - public function testEmbedJpegMapsChannelMetadataToPdfColorSpaces( - int|string $channels, - string $expectedColorSpace, - ): void { - $embedder = new FilesystemPdfImageEmbedder(); - $method = new ReflectionMethod($embedder, 'embedJpeg'); - - $image = $method->invoke($embedder, 'jpeg-binary', [0 => 4, 1 => 3, 'channels' => $channels]); - - self::assertSame($expectedColorSpace, $image->dictionary['ColorSpace']); - self::assertSame(4, $image->dictionary['Width']); - self::assertSame(3, $image->dictionary['Height']); - self::assertSame('jpeg-binary', $image->stream); - } - - public function testEmbedJpegRejectsMissingDimensions(): void - { - $embedder = new FilesystemPdfImageEmbedder(); - $method = new ReflectionMethod($embedder, 'embedJpeg'); - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('JPEG metadata must expose width and height.'); - - $method->invoke($embedder, 'jpeg-binary', ['channels' => 3]); - } - - public function testEmbedJpegRejectsMissingWidthOrHeightIndividually(): void - { - $embedder = new FilesystemPdfImageEmbedder(); - $method = new ReflectionMethod($embedder, 'embedJpeg'); - - foreach ([[1 => 3, 'channels' => 3], [0 => 4, 'channels' => 3]] as $metadata) { - try { - $method->invoke($embedder, 'jpeg-binary', $metadata); - self::fail('Expected missing JPEG dimensions to be rejected.'); - } catch (\InvalidArgumentException $exception) { - self::assertSame('JPEG metadata must expose width and height.', $exception->getMessage()); - } - } - } - - public function testEmbedJpegDefaultsMissingChannelMetadataToRgb(): void - { - $embedder = new FilesystemPdfImageEmbedder(); - $method = new ReflectionMethod($embedder, 'embedJpeg'); - - $image = $method->invoke($embedder, 'jpeg-binary', [0 => 4, 1 => 3]); - - self::assertSame('/DeviceRGB', $image->dictionary['ColorSpace']); - } - public function testEmbedRejectsUnreadableFiles(): void { $embedder = new FilesystemPdfImageEmbedder(); @@ -296,23 +328,6 @@ public function testEmbedRejectsUnsupportedRowFilters(): void $embedder->embed($pngPath); } - public function testEmbedRejectsPngsWithInvalidSignature(): void - { - $embedder = new FilesystemPdfImageEmbedder(); - $method = new ReflectionMethod($embedder, 'parsePng'); - $png = PngFixtureFactory::createPng( - width: 1, - height: 1, - colorType: 2, - scanlines: "\x00\xff\x00\x00", - ); - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid PNG signature.'); - - $method->invoke($embedder, 'BROKEN!!' . substr($png, 8)); - } - public function testEmbedRejectsTrailingDataAfterIendChunk(): void { $embedder = new FilesystemPdfImageEmbedder(); @@ -322,7 +337,10 @@ public function testEmbedRejectsTrailingDataAfterIendChunk(): void colorType: 2, scanlines: "\x00\xff\x00\x00", ); - $trailingDataPath = $this->createTemporaryFile('png', $png . self::createPngChunk('tEXt', 'tail')); + $trailingDataPath = $this->createTemporaryFile( + 'png', + $png . PngFixtureFactory::createChunk('tEXt', 'tail'), + ); $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('PNG data after IEND is not supported.'); @@ -330,23 +348,6 @@ public function testEmbedRejectsTrailingDataAfterIendChunk(): void $embedder->embed($trailingDataPath); } - public function testParsePngRejectsAdditionalChunksAfterIend(): void - { - $embedder = new FilesystemPdfImageEmbedder(); - $method = new ReflectionMethod($embedder, 'parsePng'); - $png = PngFixtureFactory::createPng( - width: 1, - height: 1, - colorType: 2, - scanlines: "\x00\xff\x00\x00", - ) . self::createPngChunk('tEXt', 'tail'); - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('PNG data after IEND is not supported.'); - - $method->invoke($embedder, $png); - } - public function testEmbedRejectsUnsupportedPngCompressionAndFilterMethods(): void { $embedder = new FilesystemPdfImageEmbedder(); @@ -439,330 +440,6 @@ public function testEmbedSeparatesMultiRowRgbaPixelsIntoColorAndAlphaStreams(): self::assertSame("\x00\x40\x80\x00\xc0\xff", gzuncompress($image->softMask->stream)); } - public function testUnfilterPngRowSupportsAverageFilterWithMultiPixelContext(): void - { - $embedder = new FilesystemPdfImageEmbedder(); - $method = new ReflectionMethod($embedder, 'unfilterPngRow'); - - $decodedRow = $method->invoke( - $embedder, - 3, - "\x55\x5a\x5f\x64\x3c\x3c\x3c\x3c", - "\x0a\x14\x1e\x28\x32\x3c\x46\x50", - 4, - ); - - self::assertSame("\x5a\x64\x6e\x78\x82\x8c\x96\xa0", $decodedRow); - } - - public function testUnfilterPngRowSupportsPaethFilterWithMultiPixelContext(): void - { - $embedder = new FilesystemPdfImageEmbedder(); - $method = new ReflectionMethod($embedder, 'unfilterPngRow'); - - $decodedRow = $method->invoke( - $embedder, - 4, - "\x0a\x0a\x0a\x0a\x0a\x14\x1e\x28", - "\x0a\x14\x1e\x28\x3c\x46\x50\x5a", - 4, - ); - - self::assertSame("\x14\x1e\x28\x32\x46\x5a\x6e\x82", $decodedRow); - } - - public function testPaethPredictorSelectsExpectedNeighborAcrossTieCases(): void - { - $embedder = new FilesystemPdfImageEmbedder(); - $method = new ReflectionMethod($embedder, 'paethPredictor'); - - self::assertSame(20, $method->invoke($embedder, 20, 20, 10)); - self::assertSame(50, $method->invoke($embedder, 50, 10, 20)); - self::assertSame(50, $method->invoke($embedder, 10, 50, 20)); - self::assertSame(20, $method->invoke($embedder, 10, 30, 20)); - self::assertSame(0, $method->invoke($embedder, 0, 3, 2)); - self::assertSame(3, $method->invoke($embedder, 0, 3, 1)); - } - - public function testResolveMimeTypeRejectsMissingMimeKey(): void - { - $embedder = new FilesystemPdfImageEmbedder(); - $method = new ReflectionMethod($embedder, 'resolveMimeType'); - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Image metadata for "fixture.png" does not expose a mime type.'); - - $method->invoke($embedder, [0 => 1, 1 => 1], 'fixture.png'); - } - - public function testResolveMimeTypeRejectsNonStringMimeValues(): void - { - $embedder = new FilesystemPdfImageEmbedder(); - $method = new ReflectionMethod($embedder, 'resolveMimeType'); - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Image metadata for "fixture.png" must expose the mime type as a string.'); - - $method->invoke($embedder, ['mime' => 123], 'fixture.png'); - } - - public function testReadPngChunkRejectsTruncatedLengthField(): void - { - $embedder = new FilesystemPdfImageEmbedder(); - $method = new ReflectionMethod($embedder, 'readPngChunk'); - $offset = 0; - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid PNG chunk length.'); - - $method->invokeArgs($embedder, ["\x00\x00\x00", &$offset]); - } - - public function testParseChunkLengthPreservesAllFourBytes(): void - { - $embedder = new FilesystemPdfImageEmbedder(); - $method = new ReflectionMethod($embedder, 'parseChunkLength'); - - self::assertSame(0x01020304, $method->invoke($embedder, "\x01\x02\x03\x04")); - } - - public function testParsePngRejectsMissingMetadataWhenIhdrChunkIsAbsent(): void - { - $embedder = new FilesystemPdfImageEmbedder(); - $method = new ReflectionMethod($embedder, 'parsePng'); - $png = "\x89PNG\r\n\x1a\n" - . self::createPngChunk('IDAT', PngFixtureFactory::compressScanlines("\x00\xff\x00\x00")) - . self::createPngChunk('IEND', ''); - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('PNG metadata is incomplete.'); - - $method->invoke($embedder, $png); - } - - public function testParsePngRejectsMissingImageDataWhenIdatChunkIsAbsent(): void - { - $embedder = new FilesystemPdfImageEmbedder(); - $method = new ReflectionMethod($embedder, 'parsePng'); - $ihdr = pack('NNCCCCC', 1, 1, 8, 2, 0, 0, 0); - $png = "\x89PNG\r\n\x1a\n" - . self::createPngChunk('IHDR', $ihdr) - . self::createPngChunk('IEND', ''); - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('PNG image data is missing.'); - - $method->invoke($embedder, $png); - } - - public function testReadPngChunkRejectsInvalidChunkTypeLength(): void - { - $embedder = new FilesystemPdfImageEmbedder(); - $method = new ReflectionMethod($embedder, 'readPngChunk'); - $offset = 0; - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid PNG chunk type.'); - - $method->invokeArgs($embedder, [pack('N', 0) . 'ABC', &$offset]); - } - - public function testReadPngChunkRejectsTruncatedChunkPayload(): void - { - $embedder = new FilesystemPdfImageEmbedder(); - $method = new ReflectionMethod($embedder, 'readPngChunk'); - $offset = 0; - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('PNG chunk data is truncated.'); - - $method->invokeArgs($embedder, [pack('N', 1) . 'IHDR', &$offset]); - } - - public function testParsePngHeaderRejectsUnexpectedHeaderLength(): void - { - $embedder = new FilesystemPdfImageEmbedder(); - $method = new ReflectionMethod($embedder, 'parsePngHeader'); - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Unable to parse the PNG IHDR chunk.'); - - $method->invoke($embedder, 'short-header'); - } - - public function testUnfilterPngScanlinesRejectsInvalidCompressedData(): void - { - $embedder = new FilesystemPdfImageEmbedder(); - $method = new ReflectionMethod($embedder, 'unfilterPngScanlines'); - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('PNG image data could not be decompressed.'); - - $method->invoke($embedder, 'not-compressed', 1, 1, 1); - } - - public function testUnfilterPngScanlinesRejectsMissingRowFilterBytes(): void - { - $embedder = new FilesystemPdfImageEmbedder(); - $method = new ReflectionMethod($embedder, 'unfilterPngScanlines'); - $compressed = gzcompress(''); - if (!is_string($compressed)) { - self::fail('Failed to compress empty scanlines fixture.'); - } - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('PNG scanlines are truncated.'); - - $method->invoke($embedder, $compressed, 1, 1, 1); - } - - public function testUnfilterPngScanlinesRejectsMissingRowPayloadBytes(): void - { - $embedder = new FilesystemPdfImageEmbedder(); - $method = new ReflectionMethod($embedder, 'unfilterPngScanlines'); - $compressed = gzcompress("\x00"); - if (!is_string($compressed)) { - self::fail('Failed to compress truncated row scanlines fixture.'); - } - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('PNG row data is truncated.'); - - $method->invoke($embedder, $compressed, 1, 1, 1); - } - - public function testUnfilterPngRowSupportsSubFilterWithMultiPixelContext(): void - { - $embedder = new FilesystemPdfImageEmbedder(); - $method = new ReflectionMethod($embedder, 'unfilterPngRow'); - - $decodedRow = $method->invoke( - $embedder, - 1, - "\x05\x06\x02\x03", - "\x00\x00\x00\x00", - 2, - ); - - self::assertSame("\x05\x06\x07\x09", $decodedRow); - } - - public function testUnfilterPngRowSupportsUpFilterWithPriorRowContext(): void - { - $embedder = new FilesystemPdfImageEmbedder(); - $method = new ReflectionMethod($embedder, 'unfilterPngRow'); - - $decodedRow = $method->invoke( - $embedder, - 2, - "\x05\x06\x07\x08", - "\x01\x02\x03\x04", - 2, - ); - - self::assertSame("\x06\x08\x0a\x0c", $decodedRow); - } - - public function testUnfilterPngRowUsesPreviousUpperLeftAtBoundary(): void - { - $embedder = new FilesystemPdfImageEmbedder(); - $method = new ReflectionMethod($embedder, 'unfilterPngRow'); - - $decodedRow = $method->invoke( - $embedder, - 4, - "\xff\x00", - "\x01\x01", - 1, - ); - - self::assertSame('0000', bin2hex((string) $decodedRow)); - } - - public function testUnfilterPngRowUsesZeroUpperLeftFallbackForFirstByte(): void - { - $embedder = new FilesystemPdfImageEmbedder(); - $method = new ReflectionMethod($embedder, 'unfilterPngRow'); - - $decodedRow = $method->invoke( - $embedder, - 4, - "\x00", - "\x01", - 1, - ); - - self::assertSame("\x01", $decodedRow); - } - - public function testParsePngRejectsMissingTrailerChunkWhenTrailingBytesAreTooShort(): void - { - $embedder = new FilesystemPdfImageEmbedder(); - $method = new ReflectionMethod($embedder, 'parsePng'); - $png = PngFixtureFactory::createPng( - width: 1, - height: 1, - colorType: 2, - scanlines: "\x00\xff\x00\x00", - ); - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('PNG trailer chunk is missing.'); - - $method->invoke($embedder, substr($png, 0, -12) . "\x00\x00\x00"); - } - - public function testAssertReadableSourceRejectsUnreadableFiles(): void - { - $embedder = new FilesystemPdfImageEmbedder(); - $method = new ReflectionMethod($embedder, 'assertReadableSource'); - $path = $this->createTemporaryFile('png', 'contents'); - chmod($path, 0); - - try { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage(sprintf('Image source "%s" must be readable.', $path)); - - $method->invoke($embedder, $path); - } finally { - chmod($path, 0644); - } - } - - public function testReadSourceContentsRejectsUnreadableSources(): void - { - $embedder = new FilesystemPdfImageEmbedder(); - $method = new ReflectionMethod($embedder, 'readSourceContents'); - $missingPath = sys_get_temp_dir() . '/xobject-template-missing-image.bin'; - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage(sprintf('Failed to read image source "%s".', $missingPath)); - - $method->invoke($embedder, $missingPath); - } - - public function testAssertNoPngChunksAfterIendRejectsAdditionalChunks(): void - { - $embedder = new FilesystemPdfImageEmbedder(); - $method = new ReflectionMethod($embedder, 'assertNoPngChunksAfterIend'); - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('PNG data after IEND is not supported.'); - - $method->invoke($embedder, 12); - } - - public function testAssertPngEndsAtIendRejectsTrailingData(): void - { - $embedder = new FilesystemPdfImageEmbedder(); - $method = new ReflectionMethod($embedder, 'assertPngEndsAtIend'); - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('PNG data after IEND is not supported.'); - - $method->invoke($embedder, 12, 16); - } - /** * @return iterable */ @@ -774,17 +451,6 @@ public static function rgbaFilterProvider(): iterable yield 'paeth filter' => ['filterType' => 4]; } - /** - * @return iterable - */ - public static function jpegChannelProvider(): iterable - { - yield 'grayscale' => ['channels' => 1, 'expectedColorSpace' => '/DeviceGray']; - yield 'rgb default' => ['channels' => 3, 'expectedColorSpace' => '/DeviceRGB']; - yield 'cmyk' => ['channels' => 4, 'expectedColorSpace' => '/DeviceCMYK']; - yield 'invalid channel metadata defaults to rgb' => ['channels' => '4', 'expectedColorSpace' => '/DeviceRGB']; - } - /** * @return iterable */ @@ -802,32 +468,4 @@ public static function unsupportedPngHeaderProvider(): iterable 'expectedMessage' => 'Interlaced PNG images are not supported.', ]; } - - private function createTemporaryFile(string $extension, string $contents): string - { - $temporaryFile = tempnam(sys_get_temp_dir(), 'xot_'); - if ($temporaryFile === false) { - self::fail('Failed to create a temporary file for image export tests.'); - } - - $pathWithExtension = $temporaryFile . '.' . $extension; - rename($temporaryFile, $pathWithExtension); - file_put_contents($pathWithExtension, $contents); - $this->temporaryFiles[] = $pathWithExtension; - - return $pathWithExtension; - } - - private static function createPngChunk(string $type, string $data): string - { - $crc = crc32($type . $data); - if ($crc < 0) { - $crc += 4_294_967_296; - } - - return pack('N', strlen($data)) - . $type - . $data - . pack('N', $crc); - } } From b19303e076837fdeb05e9b06bd94d8b4344690b4 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 29 May 2026 18:33:37 -0300 Subject: [PATCH 37/44] test(pdf): update SinglePagePdfExporterTest Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/Unit/Pdf/SinglePagePdfExporterTest.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/Unit/Pdf/SinglePagePdfExporterTest.php b/tests/Unit/Pdf/SinglePagePdfExporterTest.php index b3388fb..6f31680 100644 --- a/tests/Unit/Pdf/SinglePagePdfExporterTest.php +++ b/tests/Unit/Pdf/SinglePagePdfExporterTest.php @@ -313,6 +313,7 @@ public function testSerializeValueFormatsNumbersListsAndRawPdfValues(): void { $exporter = new SinglePagePdfExporter(); + self::assertSame('3', $this->invokeExporterMethod($exporter, 'serializeValue', 3)); self::assertSame('12.5', $this->invokeExporterMethod($exporter, 'serializeValue', 12.5)); self::assertSame( '[/Name 2 0 R (text) true 3]', @@ -333,6 +334,14 @@ public function testRenderDocumentSortsObjectsAndRejectsReservedGaps(): void (string) $rendered, ); self::assertStringContainsString("xref\n0 3\n", $rendered); + self::assertStringContainsString( + sprintf( + "0000000000 65535 f \n%010d 00000 n \n%010d 00000 n \n", + strpos((string) $rendered, "1 0 obj\n"), + strpos((string) $rendered, "2 0 obj\n"), + ), + (string) $rendered, + ); $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('PDF object 2 was reserved but not written.'); From 333dcb1f9f106e18820230e275f4dd3a37e77d49 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 29 May 2026 18:33:37 -0300 Subject: [PATCH 38/44] test(pdf): update StandardFontMetricsTest Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/Unit/Pdf/StandardFontMetricsTest.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/Unit/Pdf/StandardFontMetricsTest.php b/tests/Unit/Pdf/StandardFontMetricsTest.php index 726e3b0..5532025 100644 --- a/tests/Unit/Pdf/StandardFontMetricsTest.php +++ b/tests/Unit/Pdf/StandardFontMetricsTest.php @@ -57,6 +57,16 @@ public function testMeasureStringUsesTimesMetricsForTimesAliases(): void ); } + public function testMeasureStringUsesExpectedBuiltInGlyphWidths(): void + { + $metrics = new StandardFontMetrics(); + + self::assertEqualsWithDelta(0.3335, $metrics->measureString('F1', 0.5, 'A'), 0.0001); + self::assertEqualsWithDelta(0.667, $metrics->measureString('F1', 1.0, 'A'), 0.0001); + self::assertEqualsWithDelta(6.67, $metrics->measureString('F1', 10.0, 'A'), 0.0001); + self::assertEqualsWithDelta(7.22, $metrics->measureString('F3', 10.0, 'A'), 0.0001); + } + public function testMeasureStringFallsBackToReasonableWidthForUnknownGlyphs(): void { $metrics = new StandardFontMetrics(); From 85d6eaf8c25bccd39fe45581f39c6f85e90070eb Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 29 May 2026 18:33:37 -0300 Subject: [PATCH 39/44] test(layout): update TextLineBreakerTest Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/Unit/Layout/TextLineBreakerTest.php | 38 +++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/Unit/Layout/TextLineBreakerTest.php b/tests/Unit/Layout/TextLineBreakerTest.php index 630673f..96f2aa0 100644 --- a/tests/Unit/Layout/TextLineBreakerTest.php +++ b/tests/Unit/Layout/TextLineBreakerTest.php @@ -36,6 +36,33 @@ public function testWrapKeepsWordsOnTheSameLineWhenTheyStillFit(): void self::assertSame(['a b'], $breaker->wrap('a b', 20.0, 'F5', 10.0, 'none', 'normal')); } + public function testWrapKeepsLongWordsUnbrokenWhenHyphenationIsDisabled(): void + { + $breaker = new TextLineBreaker(new StandardFontMetrics()); + + self::assertSame(['abcdef'], $breaker->wrap('abcdef', 5.0, 'F1', 10.0, 'none', 'normal')); + } + + public function testBreakWordKeepsWordUnchangedWhenHyphenationIsDisabled(): void + { + $breaker = new TextLineBreaker(new StandardFontMetrics()); + + self::assertSame( + ['abcdef'], + $this->invokeBreakerMethod($breaker, 'breakWord', 'abcdef', 5.0, 'F1', 10.0, 'none'), + ); + } + + public function testBreakWordKeepsWordUnchangedForUnsupportedHyphenationMode(): void + { + $breaker = new TextLineBreaker(new StandardFontMetrics()); + + self::assertSame( + ['abcdef'], + $this->invokeBreakerMethod($breaker, 'breakWord', 'abcdef', 5.0, 'F1', 10.0, 'inherit'), + ); + } + public function testWrapKeepsAppendingAfterManualHyphenBreaks(): void { $metrics = new StandardFontMetrics(); @@ -162,6 +189,17 @@ public function testSplitCharactersReturnsEmptyListForInvalidUtf8(): void self::assertSame([], $this->invokeBreakerMethod($breaker, 'splitCharacters', "\xc3\x28")); } + public function testBreakWordAutomaticallyReturnsInvalidUtf8WordWhenCharacterSplittingFails(): void + { + $breaker = new TextLineBreaker(new StandardFontMetrics()); + $invalidUtf8 = "\xc3\x28"; + + self::assertSame( + [$invalidUtf8], + $this->invokeBreakerMethod($breaker, 'breakWordAutomatically', $invalidUtf8, 1.0, 'F1', 10.0), + ); + } + private function invokeBreakerMethod(TextLineBreaker $breaker, string $method, mixed ...$arguments): mixed { $reflectionMethod = new ReflectionMethod($breaker, $method); From 5d4ec6edf73c67eebd8dde955ba9ff11eb553319 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 29 May 2026 18:33:37 -0300 Subject: [PATCH 40/44] test(layout): update StructuredLayoutRendererTest Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Layout/StructuredLayoutRendererTest.php | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/tests/Unit/Layout/StructuredLayoutRendererTest.php b/tests/Unit/Layout/StructuredLayoutRendererTest.php index 25e78fb..f89ebfb 100644 --- a/tests/Unit/Layout/StructuredLayoutRendererTest.php +++ b/tests/Unit/Layout/StructuredLayoutRendererTest.php @@ -16,6 +16,20 @@ final class StructuredLayoutRendererTest extends TestCase { + public function testLayoutUsesBreakNodesAsTwelvePointFlowSpacing(): void + { + $renderer = $this->createRenderer(); + + $result = $renderer->layout([ + new Node(tag: 'br', text: '', attributes: []), + $this->textNode('After'), + ], 100.0, 100.0); + + self::assertCount(1, $result->lines); + self::assertSame('After', $result->lines[0]->text); + self::assertSame(76.0, $result->lines[0]->y); + } + public function testLayoutKeepsAbsoluteNodesOutOfFlowAndAccumulatesFlowHeights(): void { $renderer = $this->createRenderer(); @@ -115,6 +129,104 @@ public function testLayoutUsesAutoFlexHeightToPositionFollowingSiblings(): void self::assertSame(63.0, $result->lines[0]->y); } + public function testLayoutUsesAutoHeightForEmptyFlexContainersWithoutClipping(): void + { + $renderer = $this->createRenderer(); + + $result = $renderer->layout([ + new Node( + tag: 'div', + text: '', + attributes: ['style' => 'display:flex;width:100;padding:2 0 3 0;background-color:#abcdef'], + ), + ], 120.0, 80.0); + + self::assertCount(1, $result->decorations); + self::assertSame(100.0, $result->decorations[0]->width); + self::assertSame(5.0, $result->decorations[0]->height); + self::assertSame(75.0, $result->decorations[0]->y); + } + + public function testLayoutUsesFixedHeightFallbackForEmptyFlexContainersInsideClipBoxes(): void + { + $renderer = $this->createRenderer(); + + $result = $renderer->layout([ + new Node( + tag: 'div', + text: '', + attributes: ['style' => 'overflow:hidden;width:40;height:20'], + children: [ + new Node( + tag: 'div', + text: '', + attributes: [ + 'style' => 'display:flex;width:40;height:2;padding:2 0 3 0;' + . 'background-color:#abcdef', + ], + ), + ], + ), + ], 120.0, 80.0); + + self::assertCount(1, $result->decorations); + self::assertSame(40.0, $result->decorations[0]->width); + self::assertSame(2.0, $result->decorations[0]->height); + self::assertSame(78.0, $result->decorations[0]->y); + } + + public function testLayoutUsesPaddingHeightForEmptyFlexContainersInsideClipBoxesWithoutExplicitHeight(): void + { + $renderer = $this->createRenderer(); + + $result = $renderer->layout([ + new Node( + tag: 'div', + text: '', + attributes: ['style' => 'overflow:hidden;width:40;height:20'], + children: [ + new Node( + tag: 'div', + text: '', + attributes: [ + 'style' => 'display:flex;width:40;padding:2 0 3 0;background-color:#abcdef', + ], + ), + ], + ), + ], 120.0, 80.0); + + self::assertCount(1, $result->decorations); + self::assertSame(40.0, $result->decorations[0]->width); + self::assertSame(5.0, $result->decorations[0]->height); + self::assertSame(75.0, $result->decorations[0]->y); + } + + public function testLayoutSupportsAbsolutelyPositionedChildrenInsideFlexContainers(): void + { + $renderer = $this->createRenderer(); + + $result = $renderer->layout([ + new Node( + tag: 'div', + text: '', + attributes: ['style' => 'display:flex;width:40;height:20'], + children: [ + $this->imageNode('/absolute.png', 'position:absolute;left:5;top:2;width:10;height:10'), + $this->imageNode('/flow.png', 'width:10;height:10'), + ], + ), + ], 40.0, 40.0); + + self::assertCount(2, $result->images); + self::assertSame('/absolute.png', $result->images[0]->source); + self::assertSame(5.0, $result->images[0]->x); + self::assertSame(28.0, $result->images[0]->y); + self::assertSame('/flow.png', $result->images[1]->source); + self::assertSame(0.0, $result->images[1]->x); + self::assertSame(30.0, $result->images[1]->y); + } + public function testLayoutAccumulatesParentTextAndChildHeightBeforeFollowingNodes(): void { $renderer = $this->createRenderer(); From 9dc3c7db1e925c3fa5a7f3fd52a39fbb79146c9a Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 29 May 2026 18:33:37 -0300 Subject: [PATCH 41/44] test(pdf): update TemplateDocumentBuilderTest Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Unit/Pdf/TemplateDocumentBuilderTest.php | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/tests/Unit/Pdf/TemplateDocumentBuilderTest.php b/tests/Unit/Pdf/TemplateDocumentBuilderTest.php index 7ce473c..5b77f38 100644 --- a/tests/Unit/Pdf/TemplateDocumentBuilderTest.php +++ b/tests/Unit/Pdf/TemplateDocumentBuilderTest.php @@ -15,6 +15,7 @@ use LibreSign\XObjectTemplate\Pdf\TemplateDocumentBuilder; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use ReflectionMethod; final class TemplateDocumentBuilderTest extends TestCase { @@ -483,6 +484,88 @@ public function testBuildContentStreamRendersFillOnlyStrokeOnlyAndEmptyDecoratio $this->assertStringNotContainsString(' RG', $emptyStream); } + public function testBuildDecorationCommandRequiresStrokeColorAndPositiveStrokeWidth(): void + { + $builder = new TemplateDocumentBuilder(); + $method = new ReflectionMethod($builder, 'buildDecorationCommand'); + + self::assertSame( + 'q\nQ', + $method->invoke( + $builder, + new LayoutDecoration(x: 1.0, y: 2.0, width: 5.0, height: 6.0, strokeWidth: 1.5), + ), + ); + self::assertSame( + 'q\nQ', + $method->invoke( + $builder, + new LayoutDecoration(x: 1.0, y: 2.0, width: 5.0, height: 6.0, strokeColor: '', strokeWidth: 1.5), + ), + ); + self::assertSame( + 'q\nQ', + $method->invoke($builder, new LayoutDecoration( + x: 1.0, + y: 2.0, + width: 5.0, + height: 6.0, + strokeColor: '#040506', + strokeWidth: 0.0, + )), + ); + } + + public function testBuildDecorationPathClampsRadiusByWidthHalfOnNarrowDecorations(): void + { + $builder = new TemplateDocumentBuilder(); + $method = new ReflectionMethod($builder, 'buildDecorationPath'); + + self::assertSame( + implode("\n", [ + '4.000000 0.000000 m', + '4.000000 0.000000 l', + '6.209139 0.000000 8.000000 1.790861 8.000000 4.000000 c', + '8.000000 16.000000 l', + '8.000000 18.209139 6.209139 20.000000 4.000000 20.000000 c', + '4.000000 20.000000 l', + '1.790861 20.000000 0.000000 18.209139 0.000000 16.000000 c', + '0.000000 4.000000 l', + '0.000000 1.790861 1.790861 0.000000 4.000000 0.000000 c', + 'h', + ]), + $method->invoke( + $builder, + new LayoutDecoration(x: 0.0, y: 0.0, width: 8.0, height: 20.0, borderRadius: 10.0), + ), + ); + } + + public function testBuildDecorationPathClampsRadiusByHeightHalfOnShortDecorations(): void + { + $builder = new TemplateDocumentBuilder(); + $method = new ReflectionMethod($builder, 'buildDecorationPath'); + + self::assertSame( + implode("\n", [ + '4.000000 0.000000 m', + '16.000000 0.000000 l', + '18.209139 0.000000 20.000000 1.790861 20.000000 4.000000 c', + '20.000000 4.000000 l', + '20.000000 6.209139 18.209139 8.000000 16.000000 8.000000 c', + '4.000000 8.000000 l', + '1.790861 8.000000 0.000000 6.209139 0.000000 4.000000 c', + '0.000000 4.000000 l', + '0.000000 1.790861 1.790861 0.000000 4.000000 0.000000 c', + 'h', + ]), + $method->invoke( + $builder, + new LayoutDecoration(x: 0.0, y: 0.0, width: 20.0, height: 8.0, borderRadius: 10.0), + ), + ); + } + public function testBuildResourcesExposesImageDictionaryAndCustomFontsFromDerivedBuilder(): void { $builder = (new TemplateDocumentBuilder())->withFontResources([ From b2a5983881d10afc95f038cdc70fc34967582296 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 29 May 2026 18:33:38 -0300 Subject: [PATCH 42/44] test(core): update XObjectTemplateCompilerTest Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/Unit/XObjectTemplateCompilerTest.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/Unit/XObjectTemplateCompilerTest.php b/tests/Unit/XObjectTemplateCompilerTest.php index 13b831d..5d6b625 100644 --- a/tests/Unit/XObjectTemplateCompilerTest.php +++ b/tests/Unit/XObjectTemplateCompilerTest.php @@ -129,4 +129,22 @@ public function testCompilerConstructorKeepsProvidedParserAndLayoutInstances(): self::assertSame($htmlParser, $htmlParserProperty->getValue($compiler)); self::assertSame($layoutEngine, $layoutEngineProperty->getValue($compiler)); } + + public function testCompilerConstructorKeepsProvidedContextInterpolatorInstance(): void + { + $contextInterpolatorClass = \LibreSign\XObjectTemplate\Html\HtmlContextInterpolator::class; + $contextInterpolator = new $contextInterpolatorClass(); + $compiler = (new \ReflectionClass(XObjectTemplateCompiler::class))->newInstanceArgs([ + null, + null, + null, + null, + null, + $contextInterpolator, + ]); + + $contextInterpolatorProperty = new ReflectionProperty($compiler, 'contextInterpolator'); + + self::assertSame($contextInterpolator, $contextInterpolatorProperty->getValue($compiler)); + } } From 4fbd3d158f21549e37c1e9c1a135f6e19a2a929b Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 29 May 2026 18:49:12 -0300 Subject: [PATCH 43/44] refactor(pdf): group image formats by namespace Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Pdf/FilesystemPdfImageEmbedder.php | 4 ++++ src/Pdf/{ => Jpeg}/JpegPdfImageFactory.php | 4 +++- src/Pdf/{ => Jpeg}/JpegPdfImageFactoryInterface.php | 4 +++- src/Pdf/{ => Png}/ParsedPngImage.php | 2 +- src/Pdf/{ => Png}/PngColorTypeDescription.php | 2 +- src/Pdf/{ => Png}/PngParser.php | 4 +++- src/Pdf/{ => Png}/PngParserInterface.php | 4 +++- src/Pdf/{ => Png}/PngPdfImageFactory.php | 10 +++++++++- src/Pdf/{ => Png}/PngPdfImageFactoryInterface.php | 4 +++- src/Pdf/{ => Png}/PngScanlineUnfilterer.php | 5 ++++- src/Pdf/{ => Png}/PngScanlineUnfiltererInterface.php | 2 +- 11 files changed, 35 insertions(+), 10 deletions(-) rename src/Pdf/{ => Jpeg}/JpegPdfImageFactory.php (88%) rename src/Pdf/{ => Jpeg}/JpegPdfImageFactoryInterface.php (76%) rename src/Pdf/{ => Png}/ParsedPngImage.php (88%) rename src/Pdf/{ => Png}/PngColorTypeDescription.php (96%) rename src/Pdf/{ => Png}/PngParser.php (96%) rename src/Pdf/{ => Png}/PngParserInterface.php (70%) rename src/Pdf/{ => Png}/PngPdfImageFactory.php (89%) rename src/Pdf/{ => Png}/PngPdfImageFactoryInterface.php (71%) rename src/Pdf/{ => Png}/PngScanlineUnfilterer.php (93%) rename src/Pdf/{ => Png}/PngScanlineUnfiltererInterface.php (87%) diff --git a/src/Pdf/FilesystemPdfImageEmbedder.php b/src/Pdf/FilesystemPdfImageEmbedder.php index aa987e8..33b9f15 100644 --- a/src/Pdf/FilesystemPdfImageEmbedder.php +++ b/src/Pdf/FilesystemPdfImageEmbedder.php @@ -8,6 +8,10 @@ namespace LibreSign\XObjectTemplate\Pdf; use InvalidArgumentException; +use LibreSign\XObjectTemplate\Pdf\Jpeg\JpegPdfImageFactory; +use LibreSign\XObjectTemplate\Pdf\Jpeg\JpegPdfImageFactoryInterface; +use LibreSign\XObjectTemplate\Pdf\Png\PngPdfImageFactory; +use LibreSign\XObjectTemplate\Pdf\Png\PngPdfImageFactoryInterface; final readonly class FilesystemPdfImageEmbedder implements PdfImageEmbedderInterface { diff --git a/src/Pdf/JpegPdfImageFactory.php b/src/Pdf/Jpeg/JpegPdfImageFactory.php similarity index 88% rename from src/Pdf/JpegPdfImageFactory.php rename to src/Pdf/Jpeg/JpegPdfImageFactory.php index a4de767..caca658 100644 --- a/src/Pdf/JpegPdfImageFactory.php +++ b/src/Pdf/Jpeg/JpegPdfImageFactory.php @@ -5,9 +5,11 @@ declare(strict_types=1); -namespace LibreSign\XObjectTemplate\Pdf; +namespace LibreSign\XObjectTemplate\Pdf\Jpeg; use InvalidArgumentException; +use LibreSign\XObjectTemplate\Pdf\EmbeddedPdfImage; +use LibreSign\XObjectTemplate\Pdf\Jpeg\JpegPdfImageFactoryInterface; /** @internal */ final readonly class JpegPdfImageFactory implements JpegPdfImageFactoryInterface diff --git a/src/Pdf/JpegPdfImageFactoryInterface.php b/src/Pdf/Jpeg/JpegPdfImageFactoryInterface.php similarity index 76% rename from src/Pdf/JpegPdfImageFactoryInterface.php rename to src/Pdf/Jpeg/JpegPdfImageFactoryInterface.php index acb9b7c..a5dbf1a 100644 --- a/src/Pdf/JpegPdfImageFactoryInterface.php +++ b/src/Pdf/Jpeg/JpegPdfImageFactoryInterface.php @@ -5,7 +5,9 @@ declare(strict_types=1); -namespace LibreSign\XObjectTemplate\Pdf; +namespace LibreSign\XObjectTemplate\Pdf\Jpeg; + +use LibreSign\XObjectTemplate\Pdf\EmbeddedPdfImage; /** @internal */ interface JpegPdfImageFactoryInterface diff --git a/src/Pdf/ParsedPngImage.php b/src/Pdf/Png/ParsedPngImage.php similarity index 88% rename from src/Pdf/ParsedPngImage.php rename to src/Pdf/Png/ParsedPngImage.php index d7e4dc7..ea8bcff 100644 --- a/src/Pdf/ParsedPngImage.php +++ b/src/Pdf/Png/ParsedPngImage.php @@ -5,7 +5,7 @@ declare(strict_types=1); -namespace LibreSign\XObjectTemplate\Pdf; +namespace LibreSign\XObjectTemplate\Pdf\Png; /** @internal */ final readonly class ParsedPngImage diff --git a/src/Pdf/PngColorTypeDescription.php b/src/Pdf/Png/PngColorTypeDescription.php similarity index 96% rename from src/Pdf/PngColorTypeDescription.php rename to src/Pdf/Png/PngColorTypeDescription.php index 2e08890..556034c 100644 --- a/src/Pdf/PngColorTypeDescription.php +++ b/src/Pdf/Png/PngColorTypeDescription.php @@ -5,7 +5,7 @@ declare(strict_types=1); -namespace LibreSign\XObjectTemplate\Pdf; +namespace LibreSign\XObjectTemplate\Pdf\Png; use InvalidArgumentException; diff --git a/src/Pdf/PngParser.php b/src/Pdf/Png/PngParser.php similarity index 96% rename from src/Pdf/PngParser.php rename to src/Pdf/Png/PngParser.php index 4733c99..3318ff5 100644 --- a/src/Pdf/PngParser.php +++ b/src/Pdf/Png/PngParser.php @@ -5,9 +5,11 @@ declare(strict_types=1); -namespace LibreSign\XObjectTemplate\Pdf; +namespace LibreSign\XObjectTemplate\Pdf\Png; use InvalidArgumentException; +use LibreSign\XObjectTemplate\Pdf\Png\ParsedPngImage; +use LibreSign\XObjectTemplate\Pdf\Png\PngParserInterface; /** @internal */ final readonly class PngParser implements PngParserInterface diff --git a/src/Pdf/PngParserInterface.php b/src/Pdf/Png/PngParserInterface.php similarity index 70% rename from src/Pdf/PngParserInterface.php rename to src/Pdf/Png/PngParserInterface.php index 28bff14..8f39b75 100644 --- a/src/Pdf/PngParserInterface.php +++ b/src/Pdf/Png/PngParserInterface.php @@ -5,7 +5,9 @@ declare(strict_types=1); -namespace LibreSign\XObjectTemplate\Pdf; +namespace LibreSign\XObjectTemplate\Pdf\Png; + +use LibreSign\XObjectTemplate\Pdf\Png\ParsedPngImage; /** @internal */ interface PngParserInterface diff --git a/src/Pdf/PngPdfImageFactory.php b/src/Pdf/Png/PngPdfImageFactory.php similarity index 89% rename from src/Pdf/PngPdfImageFactory.php rename to src/Pdf/Png/PngPdfImageFactory.php index 156a61e..1ed1c22 100644 --- a/src/Pdf/PngPdfImageFactory.php +++ b/src/Pdf/Png/PngPdfImageFactory.php @@ -5,9 +5,17 @@ declare(strict_types=1); -namespace LibreSign\XObjectTemplate\Pdf; +namespace LibreSign\XObjectTemplate\Pdf\Png; use InvalidArgumentException; +use LibreSign\XObjectTemplate\Pdf\EmbeddedPdfImage; +use LibreSign\XObjectTemplate\Pdf\Png\ParsedPngImage; +use LibreSign\XObjectTemplate\Pdf\Png\PngColorTypeDescription; +use LibreSign\XObjectTemplate\Pdf\Png\PngParser; +use LibreSign\XObjectTemplate\Pdf\Png\PngParserInterface; +use LibreSign\XObjectTemplate\Pdf\Png\PngPdfImageFactoryInterface; +use LibreSign\XObjectTemplate\Pdf\Png\PngScanlineUnfilterer; +use LibreSign\XObjectTemplate\Pdf\Png\PngScanlineUnfiltererInterface; /** @internal */ final readonly class PngPdfImageFactory implements PngPdfImageFactoryInterface diff --git a/src/Pdf/PngPdfImageFactoryInterface.php b/src/Pdf/Png/PngPdfImageFactoryInterface.php similarity index 71% rename from src/Pdf/PngPdfImageFactoryInterface.php rename to src/Pdf/Png/PngPdfImageFactoryInterface.php index 4e11dce..5180a98 100644 --- a/src/Pdf/PngPdfImageFactoryInterface.php +++ b/src/Pdf/Png/PngPdfImageFactoryInterface.php @@ -5,7 +5,9 @@ declare(strict_types=1); -namespace LibreSign\XObjectTemplate\Pdf; +namespace LibreSign\XObjectTemplate\Pdf\Png; + +use LibreSign\XObjectTemplate\Pdf\EmbeddedPdfImage; /** @internal */ interface PngPdfImageFactoryInterface diff --git a/src/Pdf/PngScanlineUnfilterer.php b/src/Pdf/Png/PngScanlineUnfilterer.php similarity index 93% rename from src/Pdf/PngScanlineUnfilterer.php rename to src/Pdf/Png/PngScanlineUnfilterer.php index f6b3d61..e95425c 100644 --- a/src/Pdf/PngScanlineUnfilterer.php +++ b/src/Pdf/Png/PngScanlineUnfilterer.php @@ -5,9 +5,12 @@ declare(strict_types=1); -namespace LibreSign\XObjectTemplate\Pdf; +namespace LibreSign\XObjectTemplate\Pdf\Png; use InvalidArgumentException; +use LibreSign\XObjectTemplate\Pdf\PhpWarningToExceptionConverter; +use LibreSign\XObjectTemplate\Pdf\WarningToExceptionConverterInterface; +use LibreSign\XObjectTemplate\Pdf\Png\PngScanlineUnfiltererInterface; /** @internal */ final readonly class PngScanlineUnfilterer implements PngScanlineUnfiltererInterface diff --git a/src/Pdf/PngScanlineUnfiltererInterface.php b/src/Pdf/Png/PngScanlineUnfiltererInterface.php similarity index 87% rename from src/Pdf/PngScanlineUnfiltererInterface.php rename to src/Pdf/Png/PngScanlineUnfiltererInterface.php index 0f200b8..98eaa7f 100644 --- a/src/Pdf/PngScanlineUnfiltererInterface.php +++ b/src/Pdf/Png/PngScanlineUnfiltererInterface.php @@ -5,7 +5,7 @@ declare(strict_types=1); -namespace LibreSign\XObjectTemplate\Pdf; +namespace LibreSign\XObjectTemplate\Pdf\Png; /** @internal */ interface PngScanlineUnfiltererInterface From 96a5a38b1d36eff77c1f6595c514450a1fa5ee55 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 29 May 2026 18:49:12 -0300 Subject: [PATCH 44/44] test(pdf): mirror image format namespaces Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/Unit/Pdf/FilesystemPdfImageEmbedderTest.php | 4 ++-- tests/Unit/Pdf/{ => Jpeg}/JpegPdfImageFactoryTest.php | 4 ++-- .../Unit/Pdf/{ => Png}/PngColorTypeDescriptionTest.php | 4 ++-- tests/Unit/Pdf/{ => Png}/PngParserTest.php | 4 ++-- tests/Unit/Pdf/{ => Png}/PngPdfImageFactoryTest.php | 10 +++++----- tests/Unit/Pdf/{ => Png}/PngScanlineUnfiltererTest.php | 4 ++-- 6 files changed, 15 insertions(+), 15 deletions(-) rename tests/Unit/Pdf/{ => Jpeg}/JpegPdfImageFactoryTest.php (95%) rename tests/Unit/Pdf/{ => Png}/PngColorTypeDescriptionTest.php (95%) rename tests/Unit/Pdf/{ => Png}/PngParserTest.php (98%) rename tests/Unit/Pdf/{ => Png}/PngPdfImageFactoryTest.php (91%) rename tests/Unit/Pdf/{ => Png}/PngScanlineUnfiltererTest.php (98%) diff --git a/tests/Unit/Pdf/FilesystemPdfImageEmbedderTest.php b/tests/Unit/Pdf/FilesystemPdfImageEmbedderTest.php index 64eb454..6d8a145 100644 --- a/tests/Unit/Pdf/FilesystemPdfImageEmbedderTest.php +++ b/tests/Unit/Pdf/FilesystemPdfImageEmbedderTest.php @@ -11,8 +11,8 @@ use LibreSign\XObjectTemplate\Pdf\FilesystemPdfImageEmbedder; use LibreSign\XObjectTemplate\Pdf\FilesystemImageSourceReaderInterface; use LibreSign\XObjectTemplate\Pdf\ImageMetadataInspectorInterface; -use LibreSign\XObjectTemplate\Pdf\JpegPdfImageFactoryInterface; -use LibreSign\XObjectTemplate\Pdf\PngPdfImageFactoryInterface; +use LibreSign\XObjectTemplate\Pdf\Jpeg\JpegPdfImageFactoryInterface; +use LibreSign\XObjectTemplate\Pdf\Png\PngPdfImageFactoryInterface; use LibreSign\XObjectTemplate\Tests\Support\PngFixtureFactory; use LibreSign\XObjectTemplate\Tests\Support\UsesTemporaryFiles; use PHPUnit\Framework\Attributes\DataProvider; diff --git a/tests/Unit/Pdf/JpegPdfImageFactoryTest.php b/tests/Unit/Pdf/Jpeg/JpegPdfImageFactoryTest.php similarity index 95% rename from tests/Unit/Pdf/JpegPdfImageFactoryTest.php rename to tests/Unit/Pdf/Jpeg/JpegPdfImageFactoryTest.php index ea1f69c..ba7d557 100644 --- a/tests/Unit/Pdf/JpegPdfImageFactoryTest.php +++ b/tests/Unit/Pdf/Jpeg/JpegPdfImageFactoryTest.php @@ -5,9 +5,9 @@ declare(strict_types=1); -namespace LibreSign\XObjectTemplate\Tests\Unit\Pdf; +namespace LibreSign\XObjectTemplate\Tests\Unit\Pdf\Jpeg; -use LibreSign\XObjectTemplate\Pdf\JpegPdfImageFactory; +use LibreSign\XObjectTemplate\Pdf\Jpeg\JpegPdfImageFactory; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; diff --git a/tests/Unit/Pdf/PngColorTypeDescriptionTest.php b/tests/Unit/Pdf/Png/PngColorTypeDescriptionTest.php similarity index 95% rename from tests/Unit/Pdf/PngColorTypeDescriptionTest.php rename to tests/Unit/Pdf/Png/PngColorTypeDescriptionTest.php index 090b8c2..687f355 100644 --- a/tests/Unit/Pdf/PngColorTypeDescriptionTest.php +++ b/tests/Unit/Pdf/Png/PngColorTypeDescriptionTest.php @@ -5,10 +5,10 @@ declare(strict_types=1); -namespace LibreSign\XObjectTemplate\Tests\Unit\Pdf; +namespace LibreSign\XObjectTemplate\Tests\Unit\Pdf\Png; use InvalidArgumentException; -use LibreSign\XObjectTemplate\Pdf\PngColorTypeDescription; +use LibreSign\XObjectTemplate\Pdf\Png\PngColorTypeDescription; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; diff --git a/tests/Unit/Pdf/PngParserTest.php b/tests/Unit/Pdf/Png/PngParserTest.php similarity index 98% rename from tests/Unit/Pdf/PngParserTest.php rename to tests/Unit/Pdf/Png/PngParserTest.php index 80e06f6..3ca591e 100644 --- a/tests/Unit/Pdf/PngParserTest.php +++ b/tests/Unit/Pdf/Png/PngParserTest.php @@ -5,9 +5,9 @@ declare(strict_types=1); -namespace LibreSign\XObjectTemplate\Tests\Unit\Pdf; +namespace LibreSign\XObjectTemplate\Tests\Unit\Pdf\Png; -use LibreSign\XObjectTemplate\Pdf\PngParser; +use LibreSign\XObjectTemplate\Pdf\Png\PngParser; use LibreSign\XObjectTemplate\Tests\Support\PngFixtureFactory; use PHPUnit\Framework\TestCase; diff --git a/tests/Unit/Pdf/PngPdfImageFactoryTest.php b/tests/Unit/Pdf/Png/PngPdfImageFactoryTest.php similarity index 91% rename from tests/Unit/Pdf/PngPdfImageFactoryTest.php rename to tests/Unit/Pdf/Png/PngPdfImageFactoryTest.php index c68ced3..f767cfa 100644 --- a/tests/Unit/Pdf/PngPdfImageFactoryTest.php +++ b/tests/Unit/Pdf/Png/PngPdfImageFactoryTest.php @@ -5,12 +5,12 @@ declare(strict_types=1); -namespace LibreSign\XObjectTemplate\Tests\Unit\Pdf; +namespace LibreSign\XObjectTemplate\Tests\Unit\Pdf\Png; -use LibreSign\XObjectTemplate\Pdf\ParsedPngImage; -use LibreSign\XObjectTemplate\Pdf\PngParserInterface; -use LibreSign\XObjectTemplate\Pdf\PngPdfImageFactory; -use LibreSign\XObjectTemplate\Pdf\PngScanlineUnfiltererInterface; +use LibreSign\XObjectTemplate\Pdf\Png\ParsedPngImage; +use LibreSign\XObjectTemplate\Pdf\Png\PngParserInterface; +use LibreSign\XObjectTemplate\Pdf\Png\PngPdfImageFactory; +use LibreSign\XObjectTemplate\Pdf\Png\PngScanlineUnfiltererInterface; use PHPUnit\Framework\TestCase; final class PngPdfImageFactoryTest extends TestCase diff --git a/tests/Unit/Pdf/PngScanlineUnfiltererTest.php b/tests/Unit/Pdf/Png/PngScanlineUnfiltererTest.php similarity index 98% rename from tests/Unit/Pdf/PngScanlineUnfiltererTest.php rename to tests/Unit/Pdf/Png/PngScanlineUnfiltererTest.php index 14d42cd..111ea11 100644 --- a/tests/Unit/Pdf/PngScanlineUnfiltererTest.php +++ b/tests/Unit/Pdf/Png/PngScanlineUnfiltererTest.php @@ -5,9 +5,9 @@ declare(strict_types=1); -namespace LibreSign\XObjectTemplate\Tests\Unit\Pdf; +namespace LibreSign\XObjectTemplate\Tests\Unit\Pdf\Png; -use LibreSign\XObjectTemplate\Pdf\PngScanlineUnfilterer; +use LibreSign\XObjectTemplate\Pdf\Png\PngScanlineUnfilterer; use LibreSign\XObjectTemplate\Pdf\WarningToExceptionConverterInterface; use PHPUnit\Framework\TestCase;