diff --git a/.github/workflows/duplication.yml b/.github/workflows/duplication.yml index 43629ce..0b6d9a3 100644 --- a/.github/workflows/duplication.yml +++ b/.github/workflows/duplication.yml @@ -11,7 +11,6 @@ on: jobs: duplication: runs-on: ubuntu-latest - continue-on-error: true steps: - uses: actions/checkout@v6 - name: Detect minimum PHP from composer.json diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index 090d19a..60136a9 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -5,6 +5,8 @@ name: mutation on: pull_request: + push: + branches: [main] jobs: mutation: diff --git a/.github/workflows/test-integration.yml b/.github/workflows/test-integration.yml new file mode 100644 index 0000000..e69bdf9 --- /dev/null +++ b/.github/workflows/test-integration.yml @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: 2026 LibreSign +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: test-integration + +on: + pull_request: + push: + branches: [main] + +jobs: + test-integration: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Detect minimum PHP from composer.json + id: php_min + run: | + php_version=$(grep -Po '"php"\s*:\s*"\K[^"]+' composer.json | grep -Eo '[0-9]+\.[0-9]+' | head -n1) + [[ -n "$php_version" ]] || { echo "Could not determine minimum PHP version"; exit 1; } + echo "version=$php_version" >> "$GITHUB_OUTPUT" + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ steps.php_min.outputs.version }} + - run: composer install --no-interaction --prefer-dist + - run: composer bin phpunit install --no-interaction --prefer-dist + - run: composer run test:integration \ No newline at end of file diff --git a/composer.json b/composer.json index c985bcb..e1c953f 100644 --- a/composer.json +++ b/composer.json @@ -57,7 +57,7 @@ "psalm": "vendor-bin/psalm/vendor/vimeo/psalm/psalm --no-progress", "phpmd": "vendor-bin/phpmd/vendor/phpmd/phpmd/src/bin/phpmd src,tests text phpmd.xml", "psalm:update-baseline": "vendor-bin/psalm/vendor/vimeo/psalm/psalm --set-baseline=psalm-baseline.xml", - "duplication:check": "vendor-bin/phpcpd/vendor/sebastian/phpcpd/phpcpd --min-lines=8 --min-tokens=70 --exclude vendor --exclude tests/Fixtures --exclude tests/Snapshots src tests", + "duplication:check": "vendor-bin/phpcpd/vendor/sebastian/phpcpd/phpcpd --min-lines=6 --min-tokens=50 --exclude vendor --exclude tests/Fixtures --exclude tests/Snapshots src tests", "composer:validate": "composer validate --strict", "composer:normalize:check": "vendor-bin/qa/vendor/ergebnis/composer-normalize/bin/composer-normalize --dry-run", "bc:check": "vendor-bin/qa/vendor/roave/backward-compatibility-check/bin/roave-backward-compatibility-check --from=origin/main --to=HEAD", @@ -69,6 +69,7 @@ "sh:security": "shellcheck scripts/*.sh", "profile:xdebug": "XDEBUG_MODE=profile php benchmarks/profile.php", "test:unit": "vendor-bin/phpunit/vendor/phpunit/phpunit/phpunit --testsuite unit", + "test:integration": "vendor-bin/phpunit/vendor/phpunit/phpunit/phpunit --testsuite integration", "test:coverage": "XDEBUG_MODE=coverage vendor-bin/phpunit/vendor/phpunit/phpunit/phpunit --coverage-text --coverage-clover=build/coverage/clover.xml", "mutation:test": "vendor-bin/mutation/vendor/infection/infection/bin/infection --threads=max", "performance:check": "php benchmarks/compiler-benchmark.php", diff --git a/infection.json5 b/infection.json5 index 4a66b6a..2750db1 100644 --- a/infection.json5 +++ b/infection.json5 @@ -12,8 +12,8 @@ "phpUnit": { "customPath": "vendor-bin/phpunit/vendor/bin/phpunit" }, - "minMsi": 75, - "minCoveredMsi": 82, + "minMsi": 100, + "minCoveredMsi": 100, "testFramework": "phpunit", "timeout": 120 } diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 82fff7f..2927b38 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -2,7 +2,13 @@ + beStrictAboutTestsThatDoNotTestAnything="true" + failOnDeprecation="true" + failOnIncomplete="true" + failOnNotice="true" + failOnRisky="true" + failOnSkipped="true" + failOnWarning="true"> tests/Unit diff --git a/psalm.xml b/psalm.xml index a3768db..99b95c4 100644 --- a/psalm.xml +++ b/psalm.xml @@ -1,7 +1,7 @@ - diff --git a/src/Html/SubsetHtmlParser.php b/src/Html/SubsetHtmlParser.php index 81e9143..4bc9998 100644 --- a/src/Html/SubsetHtmlParser.php +++ b/src/Html/SubsetHtmlParser.php @@ -7,6 +7,7 @@ namespace LibreSign\XObjectTemplate\Html; +use DOMAttr; use DOMDocument; use DOMElement; use DOMNode; @@ -58,7 +59,7 @@ public function parse(string $html): array } $nodes = []; - foreach ($body->childNodes as $child) { + foreach ($this->iterateChildNodes($body) as $child) { $parsed = $this->parseDomNode($child, ''); if ($parsed !== null) { $nodes[] = $parsed; @@ -92,7 +93,7 @@ private function parseElementNode(DOMElement $node, string $inheritedStyle): Nod } $children = []; - foreach ($node->childNodes as $childNode) { + foreach ($this->iterateChildNodes($node) as $childNode) { $child = $this->parseDomNode($childNode, $effectiveStyle); if ($child !== null) { $children[] = $child; @@ -128,16 +129,40 @@ private function parseTextNode(DOMNode $node, string $inheritedStyle): ?Node private function collectAttributes(DOMElement $node): array { $attributes = []; - $nodeAttrs = $node->attributes; - if ($nodeAttrs !== null) { - foreach ($nodeAttrs as $attribute) { - $attributes[$attribute->name] = trim($attribute->value); - } + foreach ($this->iterateAttributes($node) as $attribute) { + $attributes[$attribute->name] = trim($attribute->value); } return $attributes; } + /** + * @return \Generator + */ + private function iterateChildNodes(DOMNode $node): \Generator + { + /** @var DOMNode $child */ + foreach ($node->childNodes as $child) { + yield $child; + } + } + + /** + * @return \Generator + */ + private function iterateAttributes(DOMElement $node): \Generator + { + $attributes = $node->attributes; + if ($attributes === null) { + return; + } + + /** @var DOMAttr $attribute */ + foreach ($attributes as $attribute) { + yield $attribute; + } + } + private function mergeStyle(string $inheritedStyle, string $ownStyle): string { $inheritedStyle = $this->filterInheritableStyle($inheritedStyle); diff --git a/src/Layout/StructuredLayoutRenderer.php b/src/Layout/StructuredLayoutRenderer.php index 77e1886..1246d93 100644 --- a/src/Layout/StructuredLayoutRenderer.php +++ b/src/Layout/StructuredLayoutRenderer.php @@ -274,13 +274,15 @@ private function renderBlockContainer( ); } - $renderedHeight = $localClipBox === null - ? $this->boxResolver->resolveAutoContainerHeight($box['height'], $padding, $contentHeight) - : $this->boxResolver->resolveFixedContainerHeight($box['height'], $padding, $contentHeight); - - $this->appendDecoration($style, $box, $renderedHeight, $canvasHeight, $decorations); - - return $renderedHeight; + return $this->finalizeContainerRendering( + $style, + $box, + $padding, + $contentHeight, + $localClipBox, + $canvasHeight, + $decorations, + ); } /** @@ -324,12 +326,15 @@ private function renderFlexContainer( ); if ($items === []) { - $renderedHeight = $localClipBox === null - ? $this->boxResolver->resolveAutoContainerHeight($box['height'], $padding, 0.0) - : $this->boxResolver->resolveFixedContainerHeight($box['height'], $padding, 0.0); - $this->appendDecoration($style, $box, $renderedHeight, $canvasHeight, $decorations); - - return $renderedHeight; + return $this->finalizeContainerRendering( + $style, + $box, + $padding, + 0.0, + $localClipBox, + $canvasHeight, + $decorations, + ); } $metrics = $this->flexPlanner->calculateMetrics($items, $direction, $justifyContent, $gap, $contentBox); @@ -359,9 +364,37 @@ private function renderFlexContainer( } $contentHeight = $direction === 'row' ? $metrics['crossAxisSize'] : $metrics['totalMainAxisSize']; + + return $this->finalizeContainerRendering( + $style, + $box, + $padding, + $contentHeight, + $localClipBox, + $canvasHeight, + $decorations, + ); + } + + /** + * @param array{x: float, y: float, width: float, height: float} $box + * @param array{top: float, right: float, bottom: float, left: float} $padding + * @param list $decorations + * @param array{x: float, y: float, width: float, height: float}|null $localClipBox + */ + private function finalizeContainerRendering( + StyleMap $style, + array $box, + array $padding, + float $contentHeight, + ?array $localClipBox, + float $canvasHeight, + array &$decorations, + ): float { $renderedHeight = $localClipBox === null ? $this->boxResolver->resolveAutoContainerHeight($box['height'], $padding, $contentHeight) : $this->boxResolver->resolveFixedContainerHeight($box['height'], $padding, $contentHeight); + $this->appendDecoration($style, $box, $renderedHeight, $canvasHeight, $decorations); return $renderedHeight; diff --git a/src/Pdf/CompileResultResourceExtractor.php b/src/Pdf/CompileResultResourceExtractor.php new file mode 100644 index 0000000..456315c --- /dev/null +++ b/src/Pdf/CompileResultResourceExtractor.php @@ -0,0 +1,61 @@ +> + */ + public function extract(CompileResult $result, string $resourceType, string $itemMessageTemplate): array + { + if (!array_key_exists($resourceType, $result->resources)) { + return []; + } + + /** @var mixed $rawResources */ + $rawResources = $result->resources[$resourceType]; + $resources = $this->requireStringKeyedArray( + $rawResources, + sprintf('%s resources must be an array.', $resourceType), + ); + + $normalizedResources = []; + foreach (array_keys($resources) as $alias) { + $normalizedResources[$alias] = $this->requireStringKeyedArray( + $resources[$alias], + sprintf($itemMessageTemplate, $alias), + ); + } + + return $normalizedResources; + } + + /** + * @return array + */ + private function requireStringKeyedArray(mixed $value, string $message): array + { + if (!is_array($value)) { + throw new InvalidArgumentException($message); + } + + foreach (array_keys($value) as $key) { + if (!is_string($key)) { + throw new InvalidArgumentException($message); + } + } + + /** @var array $value */ + return $value; + } +} diff --git a/src/Pdf/Png/PhpPngHeaderUnpacker.php b/src/Pdf/Png/PhpPngHeaderUnpacker.php new file mode 100644 index 0000000..d6300b5 --- /dev/null +++ b/src/Pdf/Png/PhpPngHeaderUnpacker.php @@ -0,0 +1,29 @@ +headerUnpacker = $headerUnpacker ?? new PhpPngHeaderUnpacker(); + } + public function parse(string $contents): ParsedPngImage { $this->assertPngSignature($contents); @@ -107,10 +114,7 @@ public function parseHeader(string $data): array throw new InvalidArgumentException('Unable to parse the PNG IHDR chunk.'); } - $header = unpack( - 'Nwidth/Nheight/CbitDepth/CcolorType/Ccompression/Cfilter/Cinterlace', - $data, - ); + $header = $this->headerUnpacker->unpack($data); if (!is_array($header)) { throw new InvalidArgumentException('Unable to parse the PNG IHDR chunk.'); } diff --git a/src/Pdf/Png/PngPdfImageFactory.php b/src/Pdf/Png/PngPdfImageFactory.php index 1ed1c22..313fd4c 100644 --- a/src/Pdf/Png/PngPdfImageFactory.php +++ b/src/Pdf/Png/PngPdfImageFactory.php @@ -22,13 +22,16 @@ { private PngParserInterface $parser; private PngScanlineUnfiltererInterface $scanlineUnfilterer; + private PngScanlineCompressorInterface $scanlineCompressor; public function __construct( ?PngParserInterface $parser = null, ?PngScanlineUnfiltererInterface $scanlineUnfilterer = null, + ?PngScanlineCompressorInterface $scanlineCompressor = null, ) { $this->parser = $parser ?? new PngParser(); $this->scanlineUnfilterer = $scanlineUnfilterer ?? new PngScanlineUnfilterer(); + $this->scanlineCompressor = $scanlineCompressor ?? new PhpPngScanlineCompressor(); } public function create(string $contents): EmbeddedPdfImage @@ -136,7 +139,7 @@ private function createImageDictionary(int $width, int $height, string $colorSpa private function compressScanlines(string $scanlines): string { - $compressed = gzcompress($scanlines); + $compressed = $this->scanlineCompressor->compress($scanlines); if (!is_string($compressed)) { throw new InvalidArgumentException('PNG scanlines could not be compressed.'); } diff --git a/src/Pdf/Png/PngScanlineCompressorInterface.php b/src/Pdf/Png/PngScanlineCompressorInterface.php new file mode 100644 index 0000000..050f735 --- /dev/null +++ b/src/Pdf/Png/PngScanlineCompressorInterface.php @@ -0,0 +1,14 @@ +resourceExtractor = $resourceExtractor ?? new CompileResultResourceExtractor(); } public function export(CompileResult $result): string @@ -36,8 +40,14 @@ public function export(CompileResult $result): string $pageContentReference = $this->reserveObject($objects); $formReference = $this->reserveObject($objects); - $fontReferences = $this->createFontObjects($objects, $result->resources['Font'] ?? []); - $imageReferences = $this->createImageObjects($objects, $result->resources['XObject'] ?? []); + $fontReferences = $this->createFontObjects( + $objects, + $this->resourceExtractor->extract($result, 'Font', 'Font resource "%s" must be an array.'), + ); + $imageReferences = $this->createImageObjects( + $objects, + $this->resourceExtractor->extract($result, 'XObject', 'XObject resource "%s" must be an array.'), + ); $objects[$catalogReference] = $this->serializeDictionary([ 'Type' => '/Catalog', @@ -91,7 +101,7 @@ public function export(CompileResult $result): string /** * @param array $objects - * @param array $fontResources + * @param array> $fontResources * @return array */ private function createFontObjects(array &$objects, array $fontResources): array @@ -99,10 +109,6 @@ private function createFontObjects(array &$objects, array $fontResources): array $fontReferences = []; foreach ($fontResources as $alias => $fontResource) { - if (!is_array($fontResource)) { - throw new InvalidArgumentException(sprintf('Font resource "%s" must be an array.', $alias)); - } - $reference = $this->reserveObject($objects); $objects[$reference] = $this->serializeDictionary($fontResource); $fontReferences[$alias] = $this->asReference($reference); @@ -113,7 +119,7 @@ private function createFontObjects(array &$objects, array $fontResources): array /** * @param array $objects - * @param array $xObjects + * @param array> $xObjects * @return array */ private function createImageObjects(array &$objects, array $xObjects): array @@ -121,10 +127,6 @@ private function createImageObjects(array &$objects, array $xObjects): array $imageReferences = []; foreach ($xObjects as $alias => $resource) { - if (!is_array($resource)) { - throw new InvalidArgumentException(sprintf('XObject resource "%s" must be an array.', $alias)); - } - if (($resource['Subtype'] ?? null) !== '/Image') { throw new InvalidArgumentException(sprintf('Unsupported XObject subtype for "%s".', $alias)); } @@ -194,8 +196,8 @@ private function serializeDictionary(array $dictionary): string } $entries = []; - foreach ($dictionary as $key => $value) { - $entries[] = sprintf('/%s %s', $key, $this->serializeValue($value)); + foreach (array_keys($dictionary) as $key) { + $entries[] = sprintf('/%s %s', $key, $this->serializeValue($dictionary[$key])); } return '<< ' . implode(' ', $entries) . ' >>'; @@ -236,7 +238,28 @@ private function serializeArrayValue(array $value): string return '[' . implode(' ', array_map($this->serializeValue(...), $value)) . ']'; } - return $this->serializeDictionary($value); + return $this->serializeDictionary( + $this->requireStringKeyedArray($value, 'PDF dictionaries must use string keys.'), + ); + } + + /** + * @return array + */ + private function requireStringKeyedArray(mixed $value, string $message): array + { + if (!is_array($value)) { + throw new InvalidArgumentException($message); + } + + foreach (array_keys($value) as $key) { + if (!is_string($key)) { + throw new InvalidArgumentException($message); + } + } + + /** @var array $value */ + return $value; } private function serializeStringValue(string $value): string @@ -248,6 +271,9 @@ private function serializeStringValue(string $value): string return '(' . $this->escapeLiteralString($value) . ')'; } + /** + * @param array $objects + */ private function renderDocument(array $objects, int $catalogReference): string { ksort($objects); diff --git a/src/Pdf/TemplateDocumentBuilder.php b/src/Pdf/TemplateDocumentBuilder.php index 260dd86..7d82c66 100644 --- a/src/Pdf/TemplateDocumentBuilder.php +++ b/src/Pdf/TemplateDocumentBuilder.php @@ -50,10 +50,12 @@ ], ]; + /** @var Closure():int */ private Closure $clock; /** * @param array> $fontResources + * @param ?Closure():int $clock */ public function __construct( private PdfEscaper $pdfEscaper = new PdfEscaper(), @@ -133,6 +135,9 @@ public function buildMetadata(LayoutResult $layout, int $startedAtNs, int $nodeC ]; } + /** + * @param array> $fontResources + */ public function withFontResources(array $fontResources): self { return new self($this->pdfEscaper, $this->colorParser, $fontResources, $this->clock); diff --git a/tests/Support/Pdf/RecordingPdfImageEmbedder.php b/tests/Support/Pdf/RecordingPdfImageEmbedder.php new file mode 100644 index 0000000..c55e72c --- /dev/null +++ b/tests/Support/Pdf/RecordingPdfImageEmbedder.php @@ -0,0 +1,36 @@ + */ + public array $sources = []; + + /** + * @param array $dictionary + */ + public function __construct( + private readonly array $dictionary, + private readonly string $stream, + ) { + } + + public function embed(string $source): EmbeddedPdfImage + { + $this->sources[] = $source; + + return new EmbeddedPdfImage( + dictionary: $this->dictionary, + stream: $this->stream, + ); + } +} diff --git a/tests/Unit/Layout/StructuredFlexLayoutPlannerTest.php b/tests/Unit/Layout/StructuredFlexLayoutPlannerTest.php index c89b34e..4a8043d 100644 --- a/tests/Unit/Layout/StructuredFlexLayoutPlannerTest.php +++ b/tests/Unit/Layout/StructuredFlexLayoutPlannerTest.php @@ -11,299 +11,428 @@ use LibreSign\XObjectTemplate\Html\Node; use LibreSign\XObjectTemplate\Layout\LayoutStyleResolver; use LibreSign\XObjectTemplate\Layout\StructuredFlexLayoutPlanner; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; final class StructuredFlexLayoutPlannerTest extends TestCase { - public function testNormalizeDirectionAndResolveGapUseExpectedAxis(): void + /** + * @return iterable + */ + public static function normalizedDirectionProvider(): iterable { - $parser = new InlineStyleParser(); - $planner = new StructuredFlexLayoutPlanner(new LayoutStyleResolver()); - $style = $parser->parse('gap:10%'); - - self::assertSame('row', $planner->normalizeDirection('ROW')); - self::assertSame('column', $planner->normalizeDirection('column')); - self::assertSame('column', $planner->normalizeDirection(' COLUMN ')); - self::assertSame('row', $planner->normalizeDirection(' unexpected ')); - self::assertSame( - 20.0, - $planner->resolveGap($style, 'row', ['x' => 0.0, 'y' => 0.0, 'width' => 200.0, 'height' => 50.0]), - ); - self::assertSame( - 5.0, - $planner->resolveGap($style, 'column', ['x' => 0.0, 'y' => 0.0, 'width' => 200.0, 'height' => 50.0]), - ); - } + yield 'uppercase row normalizes to row' => [ + 'direction' => 'ROW', + 'expectedDirection' => 'row', + ]; - public function testMeasureItemUsesImageAndTextFallbacks(): void - { - $parser = new InlineStyleParser(); - $planner = new StructuredFlexLayoutPlanner(new LayoutStyleResolver()); + yield 'column stays column' => [ + 'direction' => 'column', + 'expectedDirection' => 'column', + ]; - $imageSize = $planner->measureItem( - new Node(tag: 'img', text: '', attributes: ['src' => '/icon.png']), - $parser->parse(''), - ['x' => 0.0, 'y' => 0.0, 'width' => 100.0, 'height' => 40.0], - ); - $textSize = $planner->measureItem( - new Node(tag: 'span', text: 'Label', attributes: []), - $parser->parse('font-size:10'), - ['x' => 0.0, 'y' => 0.0, 'width' => 100.0, 'height' => 40.0], - ); - $trimmedTextSize = $planner->measureItem( - new Node(tag: 'span', text: ' Label ', attributes: []), - $parser->parse('font-size:10'), - ['x' => 0.0, 'y' => 0.0, 'width' => 100.0, 'height' => 40.0], - ); - $containerFallbackSize = $planner->measureItem( - new Node(tag: 'div', text: '', attributes: []), - $parser->parse(''), - ['x' => 0.0, 'y' => 0.0, 'width' => 100.0, 'height' => 40.0], - ); + yield 'trimmed column stays column' => [ + 'direction' => ' COLUMN ', + 'expectedDirection' => 'column', + ]; - self::assertSame(['width' => 32.0, 'height' => 32.0], $imageSize); - self::assertSame(['width' => 24.46, 'height' => 12.0], $textSize); - self::assertSame(['width' => 24.46, 'height' => 12.0], $trimmedTextSize); - self::assertSame(['width' => 0.0, 'height' => 40.0], $containerFallbackSize); + yield 'unexpected value falls back to row' => [ + 'direction' => ' unexpected ', + 'expectedDirection' => 'row', + ]; } - public function testCalculateMetricsSupportsSpaceBetween(): void + /** + * @return iterable + */ + public static function resolveGapProvider(): iterable { - $planner = new StructuredFlexLayoutPlanner(new LayoutStyleResolver()); - $parser = new InlineStyleParser(); - $items = [ - [ - 'node' => new Node('div', '', []), - 'style' => $parser->parse(''), - 'size' => ['width' => 50.0, 'height' => 20.0], - ], - [ - 'node' => new Node('div', '', []), - 'style' => $parser->parse(''), - 'size' => ['width' => 50.0, 'height' => 30.0], - ], + yield 'row gap uses container width' => [ + 'direction' => 'row', + 'contentBox' => ['x' => 0.0, 'y' => 0.0, 'width' => 200.0, 'height' => 50.0], + 'expectedGap' => 20.0, ]; - $metrics = $planner->calculateMetrics( - $items, - 'row', - 'space-between', - 0.0, - ['x' => 0.0, 'y' => 0.0, 'width' => 200.0, 'height' => 100.0], - ); - - self::assertSame(100.0, $metrics['gap']); - self::assertSame(0.0, $metrics['mainAxisOffset']); - self::assertSame(200.0, $metrics['totalMainAxisSize']); - self::assertSame(30.0, $metrics['crossAxisSize']); - self::assertSame(100.0, $metrics['crossContainerSize']); + yield 'column gap uses container height' => [ + 'direction' => 'column', + 'contentBox' => ['x' => 0.0, 'y' => 0.0, 'width' => 200.0, 'height' => 50.0], + 'expectedGap' => 5.0, + ]; } - public function testCalculateMetricsKeepsConfiguredGapForSingleSpaceBetweenItem(): void + /** + * @return iterable + */ + public static function measureItemProvider(): iterable { - $planner = new StructuredFlexLayoutPlanner(new LayoutStyleResolver()); - $parser = new InlineStyleParser(); - $items = [[ - 'node' => new Node('div', '', []), - 'style' => $parser->parse(''), - 'size' => ['width' => 50.0, 'height' => 20.0], - ]]; + yield 'image fallback uses default icon size' => [ + 'node' => new Node(tag: 'img', text: '', attributes: ['src' => '/icon.png']), + 'inlineStyle' => '', + 'container' => ['x' => 0.0, 'y' => 0.0, 'width' => 100.0, 'height' => 40.0], + 'expectedSize' => ['width' => 32.0, 'height' => 32.0], + ]; - $metrics = $planner->calculateMetrics( - $items, - 'row', - 'space-between', - 7.0, - ['x' => 0.0, 'y' => 0.0, 'width' => 200.0, 'height' => 100.0], - ); + yield 'plain text uses measured font metrics' => [ + 'node' => new Node(tag: 'span', text: 'Label', attributes: []), + 'inlineStyle' => 'font-size:10', + 'container' => ['x' => 0.0, 'y' => 0.0, 'width' => 100.0, 'height' => 40.0], + 'expectedSize' => ['width' => 24.46, 'height' => 12.0], + ]; - self::assertSame(7.0, $metrics['gap']); - self::assertSame(50.0, $metrics['totalMainAxisSize']); - self::assertSame(20.0, $metrics['crossAxisSize']); - } + yield 'trimmed text ignores surrounding whitespace' => [ + 'node' => new Node(tag: 'span', text: ' Label ', attributes: []), + 'inlineStyle' => 'font-size:10', + 'container' => ['x' => 0.0, 'y' => 0.0, 'width' => 100.0, 'height' => 40.0], + 'expectedSize' => ['width' => 24.46, 'height' => 12.0], + ]; - public function testCreateChildBoxSupportsRowAndColumnLayouts(): void - { - $planner = new StructuredFlexLayoutPlanner(new LayoutStyleResolver()); - $item = [ - 'node' => new Node('div', '', []), - 'style' => (new InlineStyleParser())->parse(''), - 'size' => ['width' => 50.0, 'height' => 20.0], + yield 'container fallback preserves container height' => [ + 'node' => new Node(tag: 'div', text: '', attributes: []), + 'inlineStyle' => '', + 'container' => ['x' => 0.0, 'y' => 0.0, 'width' => 100.0, 'height' => 40.0], + 'expectedSize' => ['width' => 0.0, 'height' => 40.0], ]; - $contentBox = ['x' => 10.0, 'y' => 20.0, 'width' => 200.0, 'height' => 100.0]; - $rowBox = $planner->createChildBox($item, 'row', 'center', $contentBox, 100.0, 30.0); - $columnBox = $planner->createChildBox($item, 'column', 'flex-end', $contentBox, 200.0, 15.0); + yield 'whitespace only text becomes empty' => [ + 'node' => new Node(tag: 'span', text: ' ', attributes: []), + 'inlineStyle' => 'font-size:10', + 'container' => ['x' => 0.0, 'y' => 0.0, 'width' => 100.0, 'height' => 0.0], + 'expectedSize' => ['width' => 0.0, 'height' => 0.0], + ]; - self::assertSame(['x' => 40.0, 'y' => 60.0, 'width' => 50.0, 'height' => 20.0], $rowBox); - self::assertSame(['x' => 160.0, 'y' => 35.0, 'width' => 50.0, 'height' => 20.0], $columnBox); - self::assertSame(57.0, $planner->advanceCursor($item, 'row', 7.0)); - self::assertSame(27.0, $planner->advanceCursor($item, 'column', 7.0)); + yield 'visible text still measures when container height is zero' => [ + 'node' => new Node(tag: 'span', text: ' Label ', attributes: []), + 'inlineStyle' => 'font-size:10', + 'container' => ['x' => 0.0, 'y' => 0.0, 'width' => 100.0, 'height' => 0.0], + 'expectedSize' => ['width' => 24.46, 'height' => 12.0], + ]; } - public function testCreateChildBoxFallsBackToContainerDimensionWhenItemCrossSizeIsZero(): void + /** + * @return iterable, + * direction: string, + * justifyContent: string, + * gap: float, + * contentBox: array{x: float, y: float, width: float, height: float}, + * expectedMetrics: array + * }> + */ + public static function calculateMetricsProvider(): iterable { - $planner = new StructuredFlexLayoutPlanner(new LayoutStyleResolver()); - $contentBox = ['x' => 10.0, 'y' => 20.0, 'width' => 200.0, 'height' => 100.0]; + yield 'two items expand gap for row space-between' => [ + 'itemSizes' => [ + ['width' => 50.0, 'height' => 20.0], + ['width' => 50.0, 'height' => 30.0], + ], + 'direction' => 'row', + 'justifyContent' => 'space-between', + 'gap' => 0.0, + 'contentBox' => ['x' => 0.0, 'y' => 0.0, 'width' => 200.0, 'height' => 100.0], + 'expectedMetrics' => [ + 'gap' => 100.0, + 'mainAxisOffset' => 0.0, + 'totalMainAxisSize' => 200.0, + 'crossAxisSize' => 30.0, + 'crossContainerSize' => 100.0, + ], + ]; - $rowBox = $planner->createChildBox( - [ - 'node' => new Node('div', '', []), - 'style' => (new InlineStyleParser())->parse(''), - 'size' => ['width' => 50.0, 'height' => 0.0], + yield 'single space-between item keeps configured gap' => [ + 'itemSizes' => [ + ['width' => 50.0, 'height' => 20.0], ], - 'row', - 'center', - $contentBox, - 100.0, - 30.0, - ); - $columnBox = $planner->createChildBox( - [ - 'node' => new Node('div', '', []), - 'style' => (new InlineStyleParser())->parse(''), - 'size' => ['width' => 0.0, 'height' => 20.0], + 'direction' => 'row', + 'justifyContent' => 'space-between', + 'gap' => 7.0, + 'contentBox' => ['x' => 0.0, 'y' => 0.0, 'width' => 200.0, 'height' => 100.0], + 'expectedMetrics' => [ + 'gap' => 7.0, + 'totalMainAxisSize' => 50.0, + 'crossAxisSize' => 20.0, ], - 'column', - 'flex-end', - $contentBox, - 200.0, - 15.0, - ); + ]; - self::assertSame(100.0, $rowBox['height']); - self::assertSame(200.0, $columnBox['width']); - } + yield 'three items distribute space-between gap' => [ + 'itemSizes' => [ + ['width' => 20.0, 'height' => 10.0], + ['width' => 20.0, 'height' => 10.0], + ['width' => 20.0, 'height' => 10.0], + ], + 'direction' => 'row', + 'justifyContent' => 'space-between', + 'gap' => 0.0, + 'contentBox' => ['x' => 0.0, 'y' => 0.0, 'width' => 200.0, 'height' => 100.0], + 'expectedMetrics' => [ + 'gap' => 70.0, + 'totalMainAxisSize' => 200.0, + ], + ]; - public function testCreateChildBoxClampsOversizedAlignmentOffsetsToZero(): void - { - $planner = new StructuredFlexLayoutPlanner(new LayoutStyleResolver()); - $contentBox = ['x' => 10.0, 'y' => 20.0, 'width' => 200.0, 'height' => 100.0]; + yield 'empty collections keep zero sizes' => [ + 'itemSizes' => [], + 'direction' => 'row', + 'justifyContent' => 'flex-start', + 'gap' => 7.0, + 'contentBox' => ['x' => 0.0, 'y' => 0.0, 'width' => 200.0, 'height' => 100.0], + 'expectedMetrics' => [ + 'totalMainAxisSize' => 0.0, + 'crossAxisSize' => 0.0, + 'crossContainerSize' => 100.0, + ], + ]; - $rowBox = $planner->createChildBox( - [ - 'node' => new Node('div', '', []), - 'style' => (new InlineStyleParser())->parse(''), - 'size' => ['width' => 50.0, 'height' => 150.0], + yield 'center alignment offset clamps to zero when content overflows' => [ + 'itemSizes' => [ + ['width' => 250.0, 'height' => 20.0], ], - 'row', - 'center', - $contentBox, - 100.0, - 30.0, - ); - $columnBox = $planner->createChildBox( - [ - 'node' => new Node('div', '', []), - 'style' => (new InlineStyleParser())->parse(''), - 'size' => ['width' => 250.0, 'height' => 20.0], + 'direction' => 'row', + 'justifyContent' => 'center', + 'gap' => 0.0, + 'contentBox' => ['x' => 0.0, 'y' => 0.0, 'width' => 200.0, 'height' => 100.0], + 'expectedMetrics' => [ + 'mainAxisOffset' => 0.0, ], - 'column', - 'flex-end', - $contentBox, - 200.0, - 15.0, - ); + ]; - self::assertSame(20.0, $rowBox['y']); - self::assertSame(10.0, $columnBox['x']); + yield 'flex-end alignment offset clamps to zero when content overflows' => [ + 'itemSizes' => [ + ['width' => 250.0, 'height' => 20.0], + ], + 'direction' => 'row', + 'justifyContent' => 'flex-end', + 'gap' => 0.0, + 'contentBox' => ['x' => 0.0, 'y' => 0.0, 'width' => 200.0, 'height' => 100.0], + 'expectedMetrics' => [ + 'mainAxisOffset' => 0.0, + ], + ]; } - public function testMeasureItemTreatsWhitespaceOnlyTextAsEmpty(): void + /** + * @return iterable + * }> + */ + public static function createChildBoxProvider(): iterable { - $planner = new StructuredFlexLayoutPlanner(new LayoutStyleResolver()); + yield 'row layout positions centered child' => [ + 'itemSize' => ['width' => 50.0, 'height' => 20.0], + 'direction' => 'row', + 'alignItems' => 'center', + 'contentBox' => ['x' => 10.0, 'y' => 20.0, 'width' => 200.0, 'height' => 100.0], + 'crossContainerSize' => 100.0, + 'cursor' => 30.0, + 'expectedChildBox' => ['x' => 40.0, 'y' => 60.0, 'width' => 50.0, 'height' => 20.0], + ]; - $size = $planner->measureItem( - new Node(tag: 'span', text: ' ', attributes: []), - (new InlineStyleParser())->parse('font-size:10'), - ['x' => 0.0, 'y' => 0.0, 'width' => 100.0, 'height' => 0.0], - ); + yield 'column layout positions flex-end child' => [ + 'itemSize' => ['width' => 50.0, 'height' => 20.0], + 'direction' => 'column', + 'alignItems' => 'flex-end', + 'contentBox' => ['x' => 10.0, 'y' => 20.0, 'width' => 200.0, 'height' => 100.0], + 'crossContainerSize' => 200.0, + 'cursor' => 15.0, + 'expectedChildBox' => ['x' => 160.0, 'y' => 35.0, 'width' => 50.0, 'height' => 20.0], + ]; + + yield 'row layout falls back to container height when item cross size is zero' => [ + 'itemSize' => ['width' => 50.0, 'height' => 0.0], + 'direction' => 'row', + 'alignItems' => 'center', + 'contentBox' => ['x' => 10.0, 'y' => 20.0, 'width' => 200.0, 'height' => 100.0], + 'crossContainerSize' => 100.0, + 'cursor' => 30.0, + 'expectedChildBox' => ['height' => 100.0], + ]; + + yield 'column layout falls back to container width when item cross size is zero' => [ + 'itemSize' => ['width' => 0.0, 'height' => 20.0], + 'direction' => 'column', + 'alignItems' => 'flex-end', + 'contentBox' => ['x' => 10.0, 'y' => 20.0, 'width' => 200.0, 'height' => 100.0], + 'crossContainerSize' => 200.0, + 'cursor' => 15.0, + 'expectedChildBox' => ['width' => 200.0], + ]; - self::assertSame(['width' => 0.0, 'height' => 0.0], $size); + yield 'row layout clamps oversized center alignment to container top' => [ + 'itemSize' => ['width' => 50.0, 'height' => 150.0], + 'direction' => 'row', + 'alignItems' => 'center', + 'contentBox' => ['x' => 10.0, 'y' => 20.0, 'width' => 200.0, 'height' => 100.0], + 'crossContainerSize' => 100.0, + 'cursor' => 30.0, + 'expectedChildBox' => ['y' => 20.0], + ]; + + yield 'column layout clamps oversized flex-end alignment to container left' => [ + 'itemSize' => ['width' => 250.0, 'height' => 20.0], + 'direction' => 'column', + 'alignItems' => 'flex-end', + 'contentBox' => ['x' => 10.0, 'y' => 20.0, 'width' => 200.0, 'height' => 100.0], + 'crossContainerSize' => 200.0, + 'cursor' => 15.0, + 'expectedChildBox' => ['x' => 10.0], + ]; } - public function testMeasureItemUsesTrimmedTextWhenWhitespaceSurroundsVisibleCharacters(): void + /** + * @return iterable + */ + public static function advanceCursorProvider(): iterable { + yield 'row cursor advances by width plus gap' => [ + 'itemSize' => ['width' => 50.0, 'height' => 20.0], + 'direction' => 'row', + 'gap' => 7.0, + 'expectedAdvance' => 57.0, + ]; + + yield 'column cursor advances by height plus gap' => [ + 'itemSize' => ['width' => 50.0, 'height' => 20.0], + 'direction' => 'column', + 'gap' => 7.0, + 'expectedAdvance' => 27.0, + ]; + } + + #[DataProvider('normalizedDirectionProvider')] + public function testNormalizeDirectionReturnsExpectedValue( + string $direction, + string $expectedDirection, + ): void { $planner = new StructuredFlexLayoutPlanner(new LayoutStyleResolver()); - $size = $planner->measureItem( - new Node(tag: 'span', text: ' Label ', attributes: []), - (new InlineStyleParser())->parse('font-size:10'), - ['x' => 0.0, 'y' => 0.0, 'width' => 100.0, 'height' => 0.0], - ); + self::assertSame($expectedDirection, $planner->normalizeDirection($direction)); + } - self::assertSame(['width' => 24.46, 'height' => 12.0], $size); + #[DataProvider('resolveGapProvider')] + public function testResolveGapUsesExpectedAxis( + string $direction, + array $contentBox, + float $expectedGap, + ): void { + $planner = new StructuredFlexLayoutPlanner(new LayoutStyleResolver()); + $style = (new InlineStyleParser())->parse('gap:10%'); + + self::assertSame($expectedGap, $planner->resolveGap($style, $direction, $contentBox)); } - public function testCalculateMetricsSupportsThreeItemSpaceBetweenAndEmptyCollections(): void - { + #[DataProvider('measureItemProvider')] + public function testMeasureItemUsesExpectedFallbacks( + Node $node, + string $inlineStyle, + array $container, + array $expectedSize, + ): void { $planner = new StructuredFlexLayoutPlanner(new LayoutStyleResolver()); - $parser = new InlineStyleParser(); - $items = [ - [ - 'node' => new Node('div', '', []), - 'style' => $parser->parse(''), - 'size' => ['width' => 20.0, 'height' => 10.0], - ], - [ - 'node' => new Node('div', '', []), - 'style' => $parser->parse(''), - 'size' => ['width' => 20.0, 'height' => 10.0], - ], - [ - 'node' => new Node('div', '', []), - 'style' => $parser->parse(''), - 'size' => ['width' => 20.0, 'height' => 10.0], - ], - ]; + $style = (new InlineStyleParser())->parse($inlineStyle); - $threeItemMetrics = $planner->calculateMetrics( - $items, - 'row', - 'space-between', - 0.0, - ['x' => 0.0, 'y' => 0.0, 'width' => 200.0, 'height' => 100.0], + self::assertSame($expectedSize, $planner->measureItem($node, $style, $container)); + } + + #[DataProvider('calculateMetricsProvider')] + public function testCalculateMetricsReturnsExpectedValues( + array $itemSizes, + string $direction, + string $justifyContent, + float $gap, + array $contentBox, + array $expectedMetrics, + ): void { + $planner = new StructuredFlexLayoutPlanner(new LayoutStyleResolver()); + $items = array_map( + fn (array $itemSize): array => $this->createFlexItem($itemSize['width'], $itemSize['height']), + $itemSizes, ); - $emptyMetrics = $planner->calculateMetrics( - [], - 'row', - 'flex-start', - 7.0, - ['x' => 0.0, 'y' => 0.0, 'width' => 200.0, 'height' => 100.0], + + $metrics = $planner->calculateMetrics( + $items, + $direction, + $justifyContent, + $gap, + $contentBox, ); - self::assertSame(70.0, $threeItemMetrics['gap']); - self::assertSame(200.0, $threeItemMetrics['totalMainAxisSize']); - self::assertSame(0.0, $emptyMetrics['totalMainAxisSize']); - self::assertSame(0.0, $emptyMetrics['crossAxisSize']); + foreach ($expectedMetrics as $metric => $expectedValue) { + self::assertSame($expectedValue, $metrics[$metric]); + } } - public function testCalculateMetricsClampsCenterAndFlexEndOffsetsToZero(): void - { + #[DataProvider('createChildBoxProvider')] + public function testCreateChildBoxReturnsExpectedGeometry( + array $itemSize, + string $direction, + string $alignItems, + array $contentBox, + float $crossContainerSize, + float $cursor, + array $expectedChildBox, + ): void { $planner = new StructuredFlexLayoutPlanner(new LayoutStyleResolver()); - $parser = new InlineStyleParser(); - $items = [[ - 'node' => new Node('div', '', []), - 'style' => $parser->parse(''), - 'size' => ['width' => 250.0, 'height' => 20.0], - ]]; - - $centerMetrics = $planner->calculateMetrics( - $items, - 'row', - 'center', - 0.0, - ['x' => 0.0, 'y' => 0.0, 'width' => 200.0, 'height' => 100.0], - ); - $flexEndMetrics = $planner->calculateMetrics( - $items, - 'row', - 'flex-end', - 0.0, - ['x' => 0.0, 'y' => 0.0, 'width' => 200.0, 'height' => 100.0], + $item = $this->createFlexItem($itemSize['width'], $itemSize['height']); + + $childBox = $planner->createChildBox( + $item, + $direction, + $alignItems, + $contentBox, + $crossContainerSize, + $cursor, ); - self::assertSame(0.0, $centerMetrics['mainAxisOffset']); - self::assertSame(0.0, $flexEndMetrics['mainAxisOffset']); + foreach ($expectedChildBox as $property => $expectedValue) { + self::assertSame($expectedValue, $childBox[$property]); + } + } + + #[DataProvider('advanceCursorProvider')] + public function testAdvanceCursorUsesMainAxisDimension( + array $itemSize, + string $direction, + float $gap, + float $expectedAdvance, + ): void { + $planner = new StructuredFlexLayoutPlanner(new LayoutStyleResolver()); + $item = $this->createFlexItem($itemSize['width'], $itemSize['height']); + + self::assertSame($expectedAdvance, $planner->advanceCursor($item, $direction, $gap)); + } + + /** + * @return array{ + * node: Node, + * style: \LibreSign\XObjectTemplate\Css\StyleMap, + * size: array{width: float, height: float} + * } + */ + private function createFlexItem(float $width, float $height): array + { + return [ + 'node' => new Node(tag: 'div', text: '', attributes: []), + 'style' => (new InlineStyleParser())->parse(''), + 'size' => ['width' => $width, 'height' => $height], + ]; } } diff --git a/tests/Unit/Layout/TextBoxLayouterTest.php b/tests/Unit/Layout/TextBoxLayouterTest.php index 10072cf..acbabda 100644 --- a/tests/Unit/Layout/TextBoxLayouterTest.php +++ b/tests/Unit/Layout/TextBoxLayouterTest.php @@ -11,6 +11,7 @@ use LibreSign\XObjectTemplate\Layout\LayoutStyleResolver; use LibreSign\XObjectTemplate\Layout\TextBoxLayouter; use LibreSign\XObjectTemplate\Pdf\StandardFontMetrics; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; final class TextBoxLayouterTest extends TestCase @@ -24,17 +25,82 @@ public static function alignmentProvider(): iterable yield 'right' => ['right']; } - public function testLayoutWrapsTextIntoMultipleMeasuredLines(): void + /** + * @return iterable + */ + public static function justifyStyleProvider(): iterable { - $layouter = new TextBoxLayouter(new LayoutStyleResolver(), new StandardFontMetrics()); - $style = (new InlineStyleParser())->parse('font-size:10'); + yield 'normalized justify' => ['font-size:10;text-align:justify']; + yield 'uppercase trimmed justify' => ['font-size:10;text-align: JUSTIFY ']; + } - $result = $layouter->layout( - 'Wrap this text', - $style, - ['x' => 0.0, 'y' => 0.0, 'width' => 50.0, 'height' => 0.0], - 100.0, - ); + /** + * @return iterable + */ + public static function autoHyphenationStyleProvider(): iterable + { + yield 'normalized auto hyphenation' => ['font-size:10;hyphens:auto']; + yield 'uppercase trimmed auto hyphenation' => ['font-size:10;hyphens: AUTO ']; + } + + /** + * @return iterable + */ + public static function clipOverflowProvider(): iterable + { + yield 'clip with nowrap keeps full text' => [ + 'inlineStyle' => 'font-size:10;overflow:hidden;text-overflow:clip;white-space:nowrap;height:12', + 'expectedText' => 'Wrap this text nicely', + 'expectedTruncated' => false, + ]; + + yield 'clip with wrapping truncates without ellipsis' => [ + 'inlineStyle' => 'font-size:10;overflow:hidden;text-overflow:clip;height:12', + 'expectedText' => 'Wrap this', + 'expectedTruncated' => true, + ]; + } + + /** + * @return iterable + */ + public static function singleLineEllipsisProvider(): iterable + { + yield 'ellipsis with nowrap' => [ + 'inlineStyle' => 'font-size:10;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;height:12', + ]; + + yield 'ellipsis with wrapping' => [ + 'inlineStyle' => 'font-size:10;overflow:hidden;text-overflow:ellipsis;height:12', + ]; + + yield 'uppercase trimmed overflow and ellipsis' => [ + 'inlineStyle' => 'font-size:10;overflow: HIDDEN ;text-overflow: ELLIPSIS ;height:12', + ]; + } + + /** + * @return iterable + */ + public static function providedClipBoxProvider(): iterable + { + yield 'preserves clip box within canvas' => [ + 'clipBox' => ['x' => 5.0, 'y' => 6.0, 'width' => 30.0, 'height' => 8.0], + 'expectedClipBox' => ['x' => 5.0, 'y' => 86.0, 'width' => 30.0, 'height' => 8.0], + ]; + + yield 'clamps clip box below canvas to zero' => [ + 'clipBox' => ['x' => 5.0, 'y' => 95.0, 'width' => 30.0, 'height' => 8.0], + 'expectedClipBox' => ['x' => 5.0, 'y' => 0.0, 'width' => 30.0, 'height' => 8.0], + ]; + } + + public function testLayoutWrapsTextIntoMultipleMeasuredLines(): void + { + $result = $this->layoutWrappedText(); self::assertCount(2, $result['lines']); self::assertSame('Wrap this', $result['lines'][0]->text); @@ -45,15 +111,7 @@ public function testLayoutWrapsTextIntoMultipleMeasuredLines(): void public function testLayoutUsesPdfCanvasCoordinatesForWrappedLines(): void { - $layouter = new TextBoxLayouter(new LayoutStyleResolver(), new StandardFontMetrics()); - $style = (new InlineStyleParser())->parse('font-size:10'); - - $result = $layouter->layout( - 'Wrap this text', - $style, - ['x' => 0.0, 'y' => 0.0, 'width' => 50.0, 'height' => 0.0], - 100.0, - ); + $result = $this->layoutWrappedText(); self::assertCount(2, $result['lines']); self::assertSame(88.0, $result['lines'][0]->y); @@ -93,70 +151,16 @@ public function testLayoutTreatsWhitespaceOnlyInputAsEmptyAndNotTruncated(): voi self::assertFalse($result['truncated']); } - public function testLayoutAddsWordSpacingOnlyToIntermediateJustifiedLines(): void - { - $layouter = new TextBoxLayouter(new LayoutStyleResolver(), new StandardFontMetrics()); - $style = (new InlineStyleParser())->parse('font-size:10;text-align:justify'); - - $result = $layouter->layout( - 'Wrap this text nicely', - $style, - ['x' => 0.0, 'y' => 0.0, 'width' => 50.0, 'height' => 0.0], - 100.0, - ); - - self::assertCount(2, $result['lines']); - self::assertGreaterThan(0.0, $result['lines'][0]->wordSpacing); - self::assertSame(0.0, $result['lines'][1]->wordSpacing); - } - - public function testLayoutSupportsUppercaseTrimmedJustifyAlignment(): void + #[DataProvider('justifyStyleProvider')] + public function testLayoutAddsWordSpacingOnlyToIntermediateJustifiedLines(string $inlineStyle): void { - $layouter = new TextBoxLayouter(new LayoutStyleResolver(), new StandardFontMetrics()); - $style = (new InlineStyleParser())->parse('font-size:10;text-align: JUSTIFY '); - - $result = $layouter->layout( - 'Wrap this text nicely', - $style, - ['x' => 0.0, 'y' => 0.0, 'width' => 50.0, 'height' => 0.0], - 100.0, - ); - - self::assertCount(2, $result['lines']); - self::assertGreaterThan(0.0, $result['lines'][0]->wordSpacing); - self::assertSame(0.0, $result['lines'][1]->wordSpacing); + $this->assertIntermediateJustifiedLineSpacing($inlineStyle); } - public function testLayoutHyphenatesLongWordsWhenAutoIsEnabled(): void + #[DataProvider('autoHyphenationStyleProvider')] + public function testLayoutHyphenatesLongWordsWhenAutoIsEnabled(string $inlineStyle): void { - $layouter = new TextBoxLayouter(new LayoutStyleResolver(), new StandardFontMetrics()); - $style = (new InlineStyleParser())->parse('font-size:10;hyphens:auto'); - - $result = $layouter->layout( - 'Supercalifragilistic', - $style, - ['x' => 0.0, 'y' => 0.0, 'width' => 35.0, 'height' => 0.0], - 100.0, - ); - - self::assertGreaterThan(1, count($result['lines'])); - self::assertStringEndsWith('-', $result['lines'][0]->text); - } - - public function testLayoutSupportsUppercaseTrimmedAutoHyphenation(): void - { - $layouter = new TextBoxLayouter(new LayoutStyleResolver(), new StandardFontMetrics()); - $style = (new InlineStyleParser())->parse('font-size:10;hyphens: AUTO '); - - $result = $layouter->layout( - 'Supercalifragilistic', - $style, - ['x' => 0.0, 'y' => 0.0, 'width' => 35.0, 'height' => 0.0], - 100.0, - ); - - self::assertGreaterThan(1, count($result['lines'])); - self::assertStringEndsWith('-', $result['lines'][0]->text); + $this->assertAutoHyphenation($inlineStyle); } public function testLayoutUsesManualSoftHyphenHints(): void @@ -210,8 +214,11 @@ public function testLayoutSupportsUppercaseTrimmedNowrapWhiteSpace(): void self::assertFalse($result['truncated']); } - public function testLayoutPreservesProvidedClipBoxWhenOverflowIsHidden(): void - { + #[DataProvider('providedClipBoxProvider')] + public function testLayoutTransformsProvidedClipBoxForCanvasCoordinates( + array $clipBox, + array $expectedClipBox, + ): void { $layouter = new TextBoxLayouter(new LayoutStyleResolver(), new StandardFontMetrics()); $style = (new InlineStyleParser())->parse('font-size:10;overflow:hidden;text-overflow:ellipsis;height:12'); @@ -220,28 +227,11 @@ public function testLayoutPreservesProvidedClipBoxWhenOverflowIsHidden(): void $style, ['x' => 0.0, 'y' => 0.0, 'width' => 50.0, 'height' => 12.0], 100.0, - ['x' => 5.0, 'y' => 6.0, 'width' => 30.0, 'height' => 8.0], + $clipBox, ); self::assertCount(1, $result['lines']); - self::assertSame(['x' => 5.0, 'y' => 86.0, 'width' => 30.0, 'height' => 8.0], $result['lines'][0]->clipBox); - } - - public function testLayoutClampsProvidedClipBoxBelowCanvasToZero(): void - { - $layouter = new TextBoxLayouter(new LayoutStyleResolver(), new StandardFontMetrics()); - $style = (new InlineStyleParser())->parse('font-size:10;overflow:hidden;text-overflow:ellipsis;height:12'); - - $result = $layouter->layout( - 'Wrap this text nicely', - $style, - ['x' => 0.0, 'y' => 0.0, 'width' => 50.0, 'height' => 12.0], - 100.0, - ['x' => 5.0, 'y' => 95.0, 'width' => 30.0, 'height' => 8.0], - ); - - self::assertCount(1, $result['lines']); - self::assertSame(['x' => 5.0, 'y' => 0.0, 'width' => 30.0, 'height' => 8.0], $result['lines'][0]->clipBox); + self::assertSame($expectedClipBox, $result['lines'][0]->clipBox); } public function testLayoutDoesNotTruncateWhenOverflowIsHiddenButHeightIsZero(): void @@ -261,12 +251,14 @@ public function testLayoutDoesNotTruncateWhenOverflowIsHiddenButHeightIsZero(): self::assertNull($result['lines'][0]->clipBox); } - public function testLayoutDoesNotAddEllipsisWhenTextOverflowIsClip(): void - { + #[DataProvider('clipOverflowProvider')] + public function testLayoutHandlesClipOverflowWithoutAddingEllipsis( + string $inlineStyle, + string $expectedText, + bool $expectedTruncated, + ): void { $layouter = new TextBoxLayouter(new LayoutStyleResolver(), new StandardFontMetrics()); - $style = (new InlineStyleParser())->parse( - 'font-size:10;overflow:hidden;text-overflow:clip;white-space:nowrap;height:12', - ); + $style = (new InlineStyleParser())->parse($inlineStyle); $result = $layouter->layout( 'Wrap this text nicely', @@ -276,47 +268,14 @@ public function testLayoutDoesNotAddEllipsisWhenTextOverflowIsClip(): void ); self::assertCount(1, $result['lines']); - self::assertFalse($result['truncated']); - self::assertSame('Wrap this text nicely', $result['lines'][0]->text); + self::assertSame($expectedTruncated, $result['truncated']); + self::assertSame($expectedText, $result['lines'][0]->text); } - public function testLayoutDoesNotAddEllipsisWhenTruncationUsesClipOverflow(): void + #[DataProvider('singleLineEllipsisProvider')] + public function testLayoutTruncatesWithEllipsisUsingSupportedStyleVariants(string $inlineStyle): void { - $layouter = new TextBoxLayouter(new LayoutStyleResolver(), new StandardFontMetrics()); - $style = (new InlineStyleParser())->parse( - 'font-size:10;overflow:hidden;text-overflow:clip;height:12', - ); - - $result = $layouter->layout( - 'Wrap this text nicely', - $style, - ['x' => 0.0, 'y' => 0.0, 'width' => 50.0, 'height' => 12.0], - 100.0, - ); - - self::assertCount(1, $result['lines']); - self::assertTrue($result['truncated']); - self::assertSame('Wrap this', $result['lines'][0]->text); - } - - public function testLayoutAddsEllipsisToSingleNowrapLineThatOverflows(): void - { - $layouter = new TextBoxLayouter(new LayoutStyleResolver(), new StandardFontMetrics()); - $style = (new InlineStyleParser())->parse( - 'font-size:10;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;height:12', - ); - - $result = $layouter->layout( - 'Wrap this text nicely', - $style, - ['x' => 0.0, 'y' => 0.0, 'width' => 50.0, 'height' => 12.0], - 100.0, - ); - - self::assertCount(1, $result['lines']); - self::assertTrue($result['truncated']); - self::assertStringEndsWith('...', $result['lines'][0]->text); - self::assertSame(['x' => 0.0, 'y' => 88.0, 'width' => 50.0, 'height' => 12.0], $result['lines'][0]->clipBox); + $this->assertSingleLineEllipsisTruncation($inlineStyle); } public function testLayoutDoesNotAddEllipsisWhenNowrapLineFitsExactly(): void @@ -341,46 +300,6 @@ public function testLayoutDoesNotAddEllipsisWhenNowrapLineFitsExactly(): void self::assertSame($text, $result['lines'][0]->text); } - public function testLayoutTruncatesWithEllipsisWhenOverflowIsHidden(): void - { - $layouter = new TextBoxLayouter(new LayoutStyleResolver(), new StandardFontMetrics()); - $style = (new InlineStyleParser())->parse( - 'font-size:10;overflow:hidden;text-overflow:ellipsis;height:12', - ); - - $result = $layouter->layout( - 'Wrap this text nicely', - $style, - ['x' => 0.0, 'y' => 0.0, 'width' => 50.0, 'height' => 12.0], - 100.0, - ); - - self::assertCount(1, $result['lines']); - self::assertTrue($result['truncated']); - self::assertStringEndsWith('...', $result['lines'][0]->text); - self::assertSame(['x' => 0.0, 'y' => 88.0, 'width' => 50.0, 'height' => 12.0], $result['lines'][0]->clipBox); - } - - public function testLayoutTruncatesWithUppercaseTrimmedOverflowAndEllipsis(): void - { - $layouter = new TextBoxLayouter(new LayoutStyleResolver(), new StandardFontMetrics()); - $style = (new InlineStyleParser())->parse( - 'font-size:10;overflow: HIDDEN ;text-overflow: ELLIPSIS ;height:12', - ); - - $result = $layouter->layout( - 'Wrap this text nicely', - $style, - ['x' => 0.0, 'y' => 0.0, 'width' => 50.0, 'height' => 12.0], - 100.0, - ); - - self::assertCount(1, $result['lines']); - self::assertTrue($result['truncated']); - self::assertStringEndsWith('...', $result['lines'][0]->text); - self::assertSame(['x' => 0.0, 'y' => 88.0, 'width' => 50.0, 'height' => 12.0], $result['lines'][0]->clipBox); - } - public function testLayoutTruncatesToTwoVisibleLinesWhenHeightRoundsUp(): void { $layouter = new TextBoxLayouter(new LayoutStyleResolver(), new StandardFontMetrics()); @@ -478,9 +397,7 @@ public function testLayoutKeepsWordSpacingAtZeroWhenJustifiedLineFitsExactly(): self::assertSame(0.0, $result['lines'][0]->wordSpacing); } - /** - * @dataProvider alignmentProvider - */ + #[DataProvider('alignmentProvider')] public function testLayoutResolvesAlignedXPositions(string $alignment): void { $fontMetrics = new StandardFontMetrics(); @@ -505,9 +422,7 @@ public function testLayoutResolvesAlignedXPositions(string $alignment): void self::assertEqualsWithDelta($expectedX, $line->x, 0.0001); } - /** - * @dataProvider alignmentProvider - */ + #[DataProvider('alignmentProvider')] public function testLayoutClampsOverflowedAlignedLineToBoxX(string $alignment): void { $layouter = new TextBoxLayouter(new LayoutStyleResolver(), new StandardFontMetrics()); @@ -523,4 +438,71 @@ public function testLayoutClampsOverflowedAlignedLineToBoxX(string $alignment): self::assertCount(1, $result['lines']); self::assertSame(7.0, $result['lines'][0]->x); } + + private function assertAutoHyphenation(string $inlineStyle): void + { + $layouter = new TextBoxLayouter(new LayoutStyleResolver(), new StandardFontMetrics()); + $style = (new InlineStyleParser())->parse($inlineStyle); + + $result = $layouter->layout( + 'Supercalifragilistic', + $style, + ['x' => 0.0, 'y' => 0.0, 'width' => 35.0, 'height' => 0.0], + 100.0, + ); + + self::assertGreaterThan(1, count($result['lines'])); + self::assertStringEndsWith('-', $result['lines'][0]->text); + } + + private function assertIntermediateJustifiedLineSpacing(string $inlineStyle): void + { + $layouter = new TextBoxLayouter(new LayoutStyleResolver(), new StandardFontMetrics()); + $style = (new InlineStyleParser())->parse($inlineStyle); + + $result = $layouter->layout( + 'Wrap this text nicely', + $style, + ['x' => 0.0, 'y' => 0.0, 'width' => 50.0, 'height' => 0.0], + 100.0, + ); + + self::assertCount(2, $result['lines']); + self::assertGreaterThan(0.0, $result['lines'][0]->wordSpacing); + self::assertSame(0.0, $result['lines'][1]->wordSpacing); + } + + /** + * @return array{lines: list<\LibreSign\XObjectTemplate\Layout\LayoutLine>, consumedHeight: float, truncated: bool} + */ + private function layoutWrappedText(): array + { + $layouter = new TextBoxLayouter(new LayoutStyleResolver(), new StandardFontMetrics()); + $style = (new InlineStyleParser())->parse('font-size:10'); + + return $layouter->layout( + 'Wrap this text', + $style, + ['x' => 0.0, 'y' => 0.0, 'width' => 50.0, 'height' => 0.0], + 100.0, + ); + } + + private function assertSingleLineEllipsisTruncation(string $inlineStyle): void + { + $layouter = new TextBoxLayouter(new LayoutStyleResolver(), new StandardFontMetrics()); + $style = (new InlineStyleParser())->parse($inlineStyle); + + $result = $layouter->layout( + 'Wrap this text nicely', + $style, + ['x' => 0.0, 'y' => 0.0, 'width' => 50.0, 'height' => 12.0], + 100.0, + ); + + self::assertCount(1, $result['lines']); + self::assertTrue($result['truncated']); + self::assertStringEndsWith('...', $result['lines'][0]->text); + self::assertSame(['x' => 0.0, 'y' => 88.0, 'width' => 50.0, 'height' => 12.0], $result['lines'][0]->clipBox); + } } diff --git a/tests/Unit/Pdf/CompileResultResourceExtractorTest.php b/tests/Unit/Pdf/CompileResultResourceExtractorTest.php new file mode 100644 index 0000000..c5aa055 --- /dev/null +++ b/tests/Unit/Pdf/CompileResultResourceExtractorTest.php @@ -0,0 +1,99 @@ + []], + bbox: [0.0, 0.0, 40.0, 40.0], + ); + + self::assertSame([], $extractor->extract($result, 'XObject', 'XObject resource "%s" must be an array.')); + } + + public function testExtractRejectsCollectionsWithNonStringAliases(): void + { + $extractor = new CompileResultResourceExtractor(); + $result = new CompileResult( + contentStream: 'BT ET', + resources: [ + 'Font' => [ + 0 => [ + 'Type' => '/Font', + ], + ], + ], + bbox: [0.0, 0.0, 40.0, 40.0], + ); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Font resources must be an array.'); + + $extractor->extract($result, 'Font', 'Font resource "%s" must be an array.'); + } + + public function testExtractRejectsResourceDictionariesWithNonStringKeys(): void + { + $extractor = new CompileResultResourceExtractor(); + $result = new CompileResult( + contentStream: 'BT ET', + resources: [ + 'Font' => [ + 'F1' => [ + 0 => '/Font', + ], + ], + ], + bbox: [0.0, 0.0, 40.0, 40.0], + ); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Font resource "F1" must be an array.'); + + $extractor->extract($result, 'Font', 'Font resource "%s" must be an array.'); + } + + public function testExtractReturnsNormalizedResourcesWhenInputIsValid(): void + { + $extractor = new CompileResultResourceExtractor(); + $result = new CompileResult( + contentStream: 'BT ET', + resources: [ + 'Font' => [ + 'F1' => [ + 'Type' => '/Font', + 'Subtype' => '/Type1', + 'BaseFont' => '/Helvetica', + ], + ], + ], + bbox: [0.0, 0.0, 40.0, 40.0], + ); + + self::assertSame( + [ + 'F1' => [ + 'Type' => '/Font', + 'Subtype' => '/Type1', + 'BaseFont' => '/Helvetica', + ], + ], + $extractor->extract($result, 'Font', 'Font resource "%s" must be an array.'), + ); + } +} diff --git a/tests/Unit/Pdf/FilesystemImageSourceReaderTest.php b/tests/Unit/Pdf/FilesystemImageSourceReaderTest.php index e434fb1..1342035 100644 --- a/tests/Unit/Pdf/FilesystemImageSourceReaderTest.php +++ b/tests/Unit/Pdf/FilesystemImageSourceReaderTest.php @@ -42,6 +42,22 @@ public function run(callable $operation, string $message): mixed self::assertSame('converted-contents', $reader->read($path)); } + public function testReadRejectsNonStringWarningConverterResult(): void + { + $reader = new FilesystemImageSourceReader(new class implements WarningToExceptionConverterInterface { + public function run(callable $operation, string $message): mixed + { + return false; + } + }); + $path = $this->createTemporaryFile('png', 'disk-contents'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf('Failed to read image source "%s".', $path)); + + $reader->read($path); + } + public function testReadRejectsMissingSources(): void { $reader = new FilesystemImageSourceReader(); diff --git a/tests/Unit/Pdf/Png/PhpPngHeaderUnpackerTest.php b/tests/Unit/Pdf/Png/PhpPngHeaderUnpackerTest.php new file mode 100644 index 0000000..a28e451 --- /dev/null +++ b/tests/Unit/Pdf/Png/PhpPngHeaderUnpackerTest.php @@ -0,0 +1,39 @@ + 3, + 'height' => 2, + 'bitDepth' => 8, + 'colorType' => 6, + 'compression' => 0, + 'filter' => 0, + 'interlace' => 0, + ], + $unpacker->unpack(pack('NNCCCCC', 3, 2, 8, 6, 0, 0, 0)), + ); + } + + public function testUnpackReturnsFalseWhenHeaderBytesAreIncomplete(): void + { + $unpacker = new PhpPngHeaderUnpacker(); + + self::assertFalse($unpacker->unpack("\x00\x00\x00")); + } +} diff --git a/tests/Unit/Pdf/Png/PhpPngScanlineCompressorTest.php b/tests/Unit/Pdf/Png/PhpPngScanlineCompressorTest.php new file mode 100644 index 0000000..06aa237 --- /dev/null +++ b/tests/Unit/Pdf/Png/PhpPngScanlineCompressorTest.php @@ -0,0 +1,25 @@ +compress($scanlines); + + self::assertIsString($compressed); + self::assertSame($scanlines, gzuncompress($compressed)); + } +} diff --git a/tests/Unit/Pdf/Png/PngParserTest.php b/tests/Unit/Pdf/Png/PngParserTest.php index 3ca591e..114489c 100644 --- a/tests/Unit/Pdf/Png/PngParserTest.php +++ b/tests/Unit/Pdf/Png/PngParserTest.php @@ -7,6 +7,7 @@ namespace LibreSign\XObjectTemplate\Tests\Unit\Pdf\Png; +use LibreSign\XObjectTemplate\Pdf\Png\PngHeaderUnpackerInterface; use LibreSign\XObjectTemplate\Pdf\Png\PngParser; use LibreSign\XObjectTemplate\Tests\Support\PngFixtureFactory; use PHPUnit\Framework\TestCase; @@ -149,7 +150,12 @@ public function testReadChunkRejectsTruncatedChunkPayload(): void public function testParseHeaderRejectsUnexpectedHeaderLength(): void { - $parser = new PngParser(); + $parser = new PngParser(new class implements PngHeaderUnpackerInterface { + public function unpack(string $data): array|false + { + throw new \LogicException('The header unpacker must not be called for invalid IHDR lengths.'); + } + }); $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Unable to parse the PNG IHDR chunk.'); @@ -157,6 +163,21 @@ public function testParseHeaderRejectsUnexpectedHeaderLength(): void $parser->parseHeader('short-header'); } + public function testParseHeaderRejectsUnpackFailures(): void + { + $parser = new PngParser(new class implements PngHeaderUnpackerInterface { + public function unpack(string $data): array|false + { + return false; + } + }); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unable to parse the PNG IHDR chunk.'); + + $parser->parseHeader(pack('NNCCCCC', 1, 1, 8, 2, 0, 0, 0)); + } + public function testParseRejectsMissingTrailerChunkWhenTrailingBytesAreTooShort(): void { $parser = new PngParser(); diff --git a/tests/Unit/Pdf/Png/PngPdfImageFactoryTest.php b/tests/Unit/Pdf/Png/PngPdfImageFactoryTest.php index f767cfa..f4c0d9b 100644 --- a/tests/Unit/Pdf/Png/PngPdfImageFactoryTest.php +++ b/tests/Unit/Pdf/Png/PngPdfImageFactoryTest.php @@ -10,6 +10,7 @@ use LibreSign\XObjectTemplate\Pdf\Png\ParsedPngImage; use LibreSign\XObjectTemplate\Pdf\Png\PngParserInterface; use LibreSign\XObjectTemplate\Pdf\Png\PngPdfImageFactory; +use LibreSign\XObjectTemplate\Pdf\Png\PngScanlineCompressorInterface; use LibreSign\XObjectTemplate\Pdf\Png\PngScanlineUnfiltererInterface; use PHPUnit\Framework\TestCase; @@ -85,4 +86,56 @@ public function unfilter(string $idat, int $height, int $rowLength, int $bytesPe $factory->create('not-a-real-png'); } + + public function testCreateRejectsTruncatedAlphaPixelData(): void + { + $factory = new PngPdfImageFactory( + new class implements PngParserInterface { + public function parse(string $contents): ParsedPngImage + { + return new ParsedPngImage(1, 1, 6, 'ignored-compressed-idat'); + } + }, + new class implements PngScanlineUnfiltererInterface { + public function unfilter(string $idat, int $height, int $rowLength, int $bytesPerPixel): array + { + return ["\xff\x00\x00"]; + } + }, + ); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('PNG row data is truncated.'); + + $factory->create('not-a-real-png'); + } + + public function testCreateRejectsCompressionFailuresForAlphaImages(): void + { + $factory = new PngPdfImageFactory( + new class implements PngParserInterface { + public function parse(string $contents): ParsedPngImage + { + return new ParsedPngImage(1, 1, 6, 'ignored-compressed-idat'); + } + }, + new class implements PngScanlineUnfiltererInterface { + public function unfilter(string $idat, int $height, int $rowLength, int $bytesPerPixel): array + { + return ["\xff\x00\x00\x80"]; + } + }, + new class implements PngScanlineCompressorInterface { + public function compress(string $scanlines): string|false + { + return false; + } + }, + ); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('PNG scanlines could not be compressed.'); + + $factory->create('not-a-real-png'); + } } diff --git a/tests/Unit/Pdf/Png/PngScanlineUnfiltererTest.php b/tests/Unit/Pdf/Png/PngScanlineUnfiltererTest.php index 111ea11..7dc0d5f 100644 --- a/tests/Unit/Pdf/Png/PngScanlineUnfiltererTest.php +++ b/tests/Unit/Pdf/Png/PngScanlineUnfiltererTest.php @@ -35,6 +35,21 @@ public function run(callable $operation, string $message): mixed self::assertSame(["\x7f"], $unfilterer->unfilter('ignored-idat', 1, 1, 1)); } + public function testUnfilterRejectsNonStringWarningConverterResult(): void + { + $unfilterer = new PngScanlineUnfilterer(new class implements WarningToExceptionConverterInterface { + public function run(callable $operation, string $message): mixed + { + return false; + } + }); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('PNG image data could not be decompressed.'); + + $unfilterer->unfilter('ignored-idat', 1, 1, 1); + } + public function testUnfilterRejectsMissingRowFilterBytes(): void { $unfilterer = new PngScanlineUnfilterer(); diff --git a/tests/Unit/Pdf/SinglePagePdfExporterTest.php b/tests/Unit/Pdf/SinglePagePdfExporterTest.php index 6f31680..18583da 100644 --- a/tests/Unit/Pdf/SinglePagePdfExporterTest.php +++ b/tests/Unit/Pdf/SinglePagePdfExporterTest.php @@ -8,9 +8,11 @@ namespace LibreSign\XObjectTemplate\Tests\Unit\Pdf; use LibreSign\XObjectTemplate\Dto\CompileResult; +use LibreSign\XObjectTemplate\Pdf\CompileResultResourceExtractor; use LibreSign\XObjectTemplate\Pdf\EmbeddedPdfImage; use LibreSign\XObjectTemplate\Pdf\PdfImageEmbedderInterface; use LibreSign\XObjectTemplate\Pdf\SinglePagePdfExporter; +use LibreSign\XObjectTemplate\Tests\Support\Pdf\RecordingPdfImageEmbedder; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use ReflectionMethod; @@ -54,35 +56,24 @@ public function embed(string $source): EmbeddedPdfImage public function testExportUsesInjectedImageEmbedderForImageResources(): void { - $embedder = new class () implements PdfImageEmbedderInterface - { - /** @var list */ - public array $sources = []; - - public function embed(string $source): EmbeddedPdfImage - { - $this->sources[] = $source; - - return new EmbeddedPdfImage( - dictionary: [ - 'Type' => '/XObject', - 'Subtype' => '/Image', - 'Width' => 1, - 'Height' => 1, - 'ColorSpace' => '/DeviceRGB', - 'BitsPerComponent' => 8, - 'Filter' => '/FlateDecode', - 'DecodeParms' => [ - 'Predictor' => 15, - 'Colors' => 3, - 'BitsPerComponent' => 8, - 'Columns' => 1, - ], - ], - stream: gzcompress("\x00\xff\x00\x00"), - ); - } - }; + $embedder = new RecordingPdfImageEmbedder( + dictionary: [ + 'Type' => '/XObject', + 'Subtype' => '/Image', + 'Width' => 1, + 'Height' => 1, + 'ColorSpace' => '/DeviceRGB', + 'BitsPerComponent' => 8, + 'Filter' => '/FlateDecode', + 'DecodeParms' => [ + 'Predictor' => 15, + 'Colors' => 3, + 'BitsPerComponent' => 8, + 'Columns' => 1, + ], + ], + stream: gzcompress("\x00\xff\x00\x00"), + ); $exporter = new SinglePagePdfExporter($embedder); @@ -173,29 +164,18 @@ public function embed(string $source): EmbeddedPdfImage public function testExportPreservesAllFontAndImageReferencesInPageTreeAndTrailer(): void { - $embedder = new class () implements PdfImageEmbedderInterface - { - /** @var list */ - public array $sources = []; - - public function embed(string $source): EmbeddedPdfImage - { - $this->sources[] = $source; - - return new EmbeddedPdfImage( - dictionary: [ - 'Type' => '/XObject', - 'Subtype' => '/Image', - 'Width' => 1, - 'Height' => 1, - 'ColorSpace' => '/DeviceRGB', - 'BitsPerComponent' => 8, - 'Filter' => '/FlateDecode', - ], - stream: 'RGB', - ); - } - }; + $embedder = new RecordingPdfImageEmbedder( + dictionary: [ + 'Type' => '/XObject', + 'Subtype' => '/Image', + 'Width' => 1, + 'Height' => 1, + 'ColorSpace' => '/DeviceRGB', + 'BitsPerComponent' => 8, + 'Filter' => '/FlateDecode', + ], + stream: 'RGB', + ); $exporter = new SinglePagePdfExporter($embedder); @@ -309,6 +289,61 @@ public function embed(string $source): EmbeddedPdfImage self::assertStringContainsString($expectedFormStreamFragment, $pdf); } + public function testExportUsesInjectedResourceExtractorWhenProvided(): void + { + $calls = []; + $resourceExtractor = new class ($calls) extends CompileResultResourceExtractor + { + /** @var list */ + public array $calls; + + /** + * @param list $calls + */ + public function __construct(array &$calls) + { + $this->calls = &$calls; + } + + public function extract(CompileResult $result, string $resourceType, string $itemMessageTemplate): array + { + $this->calls[] = $resourceType; + + if ($resourceType === 'Font') { + return [ + 'Z9' => [ + 'Type' => '/Font', + 'Subtype' => '/Type1', + 'BaseFont' => '/Courier', + ], + ]; + } + + return []; + } + }; + + $exporter = new SinglePagePdfExporter( + new class () implements PdfImageEmbedderInterface + { + public function embed(string $source): EmbeddedPdfImage + { + throw new \LogicException(sprintf('Image embedding should not be called for %s.', $source)); + } + }, + $resourceExtractor, + ); + + $pdf = $exporter->export(new CompileResult( + contentStream: 'BT ET', + resources: ['Font' => []], + bbox: [0.0, 0.0, 40.0, 20.0], + )); + + self::assertSame(['Font', 'XObject'], $calls); + self::assertStringContainsString('/BaseFont /Courier', $pdf); + } + public function testSerializeValueFormatsNumbersListsAndRawPdfValues(): void { $exporter = new SinglePagePdfExporter(); @@ -349,6 +384,31 @@ public function testRenderDocumentSortsObjectsAndRejectsReservedGaps(): void $this->invokeExporterMethod($exporter, 'renderDocument', [1 => '<< /Type /Catalog >>', 2 => null], 1); } + public function testSerializeValueRejectsAssociativeArraysWithNonStringKeys(): void + { + $exporter = new SinglePagePdfExporter(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('PDF dictionaries must use string keys.'); + + $this->invokeExporterMethod($exporter, 'serializeValue', [0 => 'alpha', 2 => 'beta']); + } + + public function testRequireStringKeyedArrayRejectsNonArrayValues(): void + { + $exporter = new SinglePagePdfExporter(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('PDF dictionaries must use string keys.'); + + $this->invokeExporterMethod( + $exporter, + 'requireStringKeyedArray', + 'not-an-array', + 'PDF dictionaries must use string keys.', + ); + } + #[DataProvider('rawPdfValueProvider')] public function testIsRawPdfValueMatchesOnlyPdfNamesAndReferences(string $value, bool $expected): void { @@ -417,6 +477,15 @@ public static function invalidCompileResultProvider(): iterable 'expectedMessage' => 'Font resource "F1" must be an array.', ]; + yield 'font collection must be an array' => [ + 'result' => new CompileResult( + contentStream: 'BT ET', + resources: ['Font' => '/Helvetica'], + bbox: [0.0, 0.0, 40.0, 40.0], + ), + 'expectedMessage' => 'Font resources must be an array.', + ]; + yield 'unsupported dictionary value type' => [ 'result' => new CompileResult( contentStream: 'BT ET', @@ -446,6 +515,18 @@ public static function invalidCompileResultProvider(): iterable 'expectedMessage' => 'XObject resource "Im0" must be an array.', ]; + yield 'xobject collection must be an array' => [ + 'result' => new CompileResult( + contentStream: 'BT ET', + resources: [ + 'Font' => [], + 'XObject' => '/Image', + ], + bbox: [0.0, 0.0, 40.0, 40.0], + ), + 'expectedMessage' => 'XObject resources must be an array.', + ]; + yield 'unsupported xobject subtype' => [ 'result' => new CompileResult( contentStream: 'BT ET',