diff --git a/tests/Unit/Html/SubsetHtmlParserTest.php b/tests/Unit/Html/SubsetHtmlParserTest.php index 362f628..72590ff 100644 --- a/tests/Unit/Html/SubsetHtmlParserTest.php +++ b/tests/Unit/Html/SubsetHtmlParserTest.php @@ -10,17 +10,78 @@ use DOMDocument; use LibreSign\XObjectTemplate\Exception\UnsupportedSubsetException; use LibreSign\XObjectTemplate\Html\SubsetHtmlParser; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; final class SubsetHtmlParserTest extends TestCase { - public function testUnsupportedTagThrowsException(): void + /** + * @return iterable + */ + public static function unsupportedTagProvider(): iterable + { + yield 'table is outside the supported subset' => [ + '
x
', + 'Tag is not supported.', + ]; + + yield 'unordered lists are outside the supported subset' => [ + '', + 'Tag
is not supported.'); - $parser->parse('
x
'); + $this->expectExceptionMessage($expectedMessage); + $parser->parse($html); } public function testParseNormalizesAttributesAndTrimsTextNodes(): void @@ -62,31 +123,25 @@ public function testParseMergesInheritedStylesAndKeepsAllowedTags(): void self::assertSame('font-size:10; margin:2', $nodes[0]->children[2]->attributes['style']); } - public function testParseOnlyInheritsTextualStylesToDescendants(): void - { + #[DataProvider('inheritableStyleProvider')] + public function testParseOnlyInheritsTextualStylesToDescendants( + string $html, + string $expectedRootStyle, + string $expectedChildStyle, + string $expectedTextStyle, + array $excludedFragments, + ): void { $parser = new SubsetHtmlParser(); - $nodes = $parser->parse( - '
' - . '
Title
' - . '
', - ); + $nodes = $parser->parse($html); - self::assertSame( - 'width:58%;height:100%;padding:18 24;font-size:20;color:#123456', - $nodes[0]->attributes['style'], - ); - self::assertSame( - 'font-size:20;color:#123456;font-weight:700', - $nodes[0]->children[0]->attributes['style'], - ); - self::assertSame( - 'font-size:20;color:#123456;font-weight:700', - $nodes[0]->children[0]->children[0]->attributes['style'], - ); - self::assertStringNotContainsString('width:58%', $nodes[0]->children[0]->attributes['style']); - self::assertStringNotContainsString('height:100%', $nodes[0]->children[0]->attributes['style']); - self::assertStringNotContainsString('padding:18 24', $nodes[0]->children[0]->attributes['style']); + self::assertSame($expectedRootStyle, $nodes[0]->attributes['style']); + self::assertSame($expectedChildStyle, $nodes[0]->children[0]->attributes['style']); + self::assertSame($expectedTextStyle, $nodes[0]->children[0]->children[0]->attributes['style']); + + foreach ($excludedFragments as $excludedFragment) { + self::assertStringNotContainsString($excludedFragment, $nodes[0]->children[0]->attributes['style']); + } } public function testParseNormalizesTagAndAttributeNamesAndKeepsAllAttributes(): void @@ -178,32 +233,6 @@ public function testParseKeepsAllTopLevelNodesInOrder(): void $this->assertSame('Second', $nodes[1]->children[0]->text); } - public function testParseFiltersInheritedStylesAfterMalformedDeclarationsAndPreservesColonValues(): void - { - $parser = new SubsetHtmlParser(); - - $nodes = $parser->parse( - '
' - . 'Hello' - . '
', - ); - - $this->assertSame( - '; COLOR : #fff ; broken ; font-family : Times:Bold ; invalid: ; white-space : nowrap ; ' - . 'hyphens : auto ; color : #abc ; line-height : 12 ;', - $nodes[0]->attributes['style'], - ); - $this->assertSame( - 'color:#abc;font-family:Times:Bold;white-space:nowrap;hyphens:auto;line-height:12;font-weight:bold', - $nodes[0]->children[0]->attributes['style'], - ); - $this->assertSame( - 'color:#abc;font-family:Times:Bold;white-space:nowrap;hyphens:auto;line-height:12;font-weight:bold', - $nodes[0]->children[0]->children[0]->attributes['style'], - ); - } - public function testParseClearsPreExistingLibxmlErrorBuffer(): void { $parser = new SubsetHtmlParser(); diff --git a/tests/Unit/Integration/XObjectPlacementCalculatorTest.php b/tests/Unit/Integration/XObjectPlacementCalculatorTest.php index 79e3aae..f133f21 100644 --- a/tests/Unit/Integration/XObjectPlacementCalculatorTest.php +++ b/tests/Unit/Integration/XObjectPlacementCalculatorTest.php @@ -7,152 +7,248 @@ namespace LibreSign\XObjectTemplate\Tests\Unit\Integration; +use InvalidArgumentException; use LibreSign\XObjectTemplate\Dto\CompileResult; use LibreSign\XObjectTemplate\Integration\XObjectPlacementCalculator; -use InvalidArgumentException; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; final class XObjectPlacementCalculatorTest extends TestCase { - public function testFromWidthCalculatesUniformPlacementAndPdfCommand(): void + /** + * @return iterable + */ + public static function placementProvider(): iterable { - $calculator = new XObjectPlacementCalculator(); - $placement = $calculator->fromWidth( - new CompileResult(contentStream: 'BT ET', resources: [], bbox: [12.5, 4.0, 252.5, 88.0]), + yield 'fromWidth compensates bbox origin and formats pdf command' => [ + 'fromWidth', + [12.5, 4.0, 252.5, 88.0], 120.0, - 20.0, - 30.0, - ); - - self::assertEqualsWithDelta(0.5, $placement->scaleX, 0.0001); - self::assertEqualsWithDelta(0.5, $placement->scaleY, 0.0001); - self::assertEqualsWithDelta(120.0, $placement->width, 0.0001); - self::assertEqualsWithDelta(42.0, $placement->height, 0.0001); - self::assertEqualsWithDelta(13.75, $placement->translateX, 0.0001); - self::assertEqualsWithDelta(28.0, $placement->translateY, 0.0001); - self::assertSame('q 0.5 0 0 0.5 13.75 28 cm /Fm0 Do Q', $placement->toPdfCommand('Fm0')); - } - - public function testFromHeightCalculatesUniformPlacementFromBoundingBoxHeight(): void - { - $calculator = new XObjectPlacementCalculator(); - $placement = $calculator->fromHeight( - new CompileResult(contentStream: 'BT ET', resources: [], bbox: [0.0, 0.0, 240.0, 84.0]), + ['x' => 20.0, 'y' => 30.0], + [ + 'scaleX' => 0.5, + 'scaleY' => 0.5, + 'width' => 120.0, + 'height' => 42.0, + 'translateX' => 13.75, + 'translateY' => 28.0, + ], + 'Fm0', + 'q 0.5 0 0 0.5 13.75 28 cm /Fm0 Do Q', + ]; + + yield 'fromHeight scales from bbox height with explicit coordinates' => [ + 'fromHeight', + [0.0, 0.0, 240.0, 84.0], 168.0, - 15.0, - 25.0, - ); - - self::assertEqualsWithDelta(2.0, $placement->scaleX, 0.0001); - self::assertEqualsWithDelta(2.0, $placement->scaleY, 0.0001); - self::assertEqualsWithDelta(480.0, $placement->width, 0.0001); - self::assertEqualsWithDelta(168.0, $placement->height, 0.0001); - self::assertEqualsWithDelta(15.0, $placement->translateX, 0.0001); - self::assertEqualsWithDelta(25.0, $placement->translateY, 0.0001); - } - - public function testFromWidthUsesOriginDefaultsWhenCoordinatesAreOmitted(): void - { - $calculator = new XObjectPlacementCalculator(); - - $placement = $calculator->fromWidth( - new CompileResult(contentStream: 'BT ET', resources: [], bbox: [10.0, 20.0, 110.0, 70.0]), + ['x' => 15.0, 'y' => 25.0], + [ + 'scaleX' => 2.0, + 'scaleY' => 2.0, + 'width' => 480.0, + 'height' => 168.0, + 'translateX' => 15.0, + 'translateY' => 25.0, + ], + ]; + + yield 'fromWidth defaults omitted coordinates to origin-compensated translation' => [ + 'fromWidth', + [10.0, 20.0, 110.0, 70.0], 50.0, - ); - - self::assertEqualsWithDelta(0.5, $placement->scaleX, 0.0001); - self::assertEqualsWithDelta(0.5, $placement->scaleY, 0.0001); - self::assertEqualsWithDelta(-5.0, $placement->translateX, 0.0001); - self::assertEqualsWithDelta(-10.0, $placement->translateY, 0.0001); - } - - public function testFromHeightUsesOriginDefaultsWhenCoordinatesAreOmitted(): void - { - $calculator = new XObjectPlacementCalculator(); - - $placement = $calculator->fromHeight( - new CompileResult(contentStream: 'BT ET', resources: [], bbox: [10.0, 20.0, 110.0, 70.0]), + null, + [ + 'scaleX' => 0.5, + 'scaleY' => 0.5, + 'width' => 50.0, + 'height' => 25.0, + 'translateX' => -5.0, + 'translateY' => -10.0, + ], + ]; + + yield 'fromHeight defaults omitted coordinates to origin-compensated translation' => [ + 'fromHeight', + [10.0, 20.0, 110.0, 70.0], 25.0, - ); - - self::assertEqualsWithDelta(0.5, $placement->scaleX, 0.0001); - self::assertEqualsWithDelta(0.5, $placement->scaleY, 0.0001); - self::assertEqualsWithDelta(50.0, $placement->width, 0.0001); - self::assertEqualsWithDelta(25.0, $placement->height, 0.0001); - self::assertEqualsWithDelta(-5.0, $placement->translateX, 0.0001); - self::assertEqualsWithDelta(-10.0, $placement->translateY, 0.0001); - } - - public function testFromScaleUsesOriginDefaultsWhenCoordinatesAreOmitted(): void - { - $calculator = new XObjectPlacementCalculator(); - - $placement = $calculator->fromScale( - new CompileResult(contentStream: 'BT ET', resources: [], bbox: [10.0, 20.0, 110.0, 70.0]), + null, + [ + 'scaleX' => 0.5, + 'scaleY' => 0.5, + 'width' => 50.0, + 'height' => 25.0, + 'translateX' => -5.0, + 'translateY' => -10.0, + ], + ]; + + yield 'fromScale defaults omitted coordinates to origin-compensated translation' => [ + 'fromScale', + [10.0, 20.0, 110.0, 70.0], 2.0, - ); - - self::assertEqualsWithDelta(2.0, $placement->scaleX, 0.0001); - self::assertEqualsWithDelta(2.0, $placement->scaleY, 0.0001); - self::assertEqualsWithDelta(200.0, $placement->width, 0.0001); - self::assertEqualsWithDelta(100.0, $placement->height, 0.0001); - self::assertEqualsWithDelta(-20.0, $placement->translateX, 0.0001); - self::assertEqualsWithDelta(-40.0, $placement->translateY, 0.0001); + null, + [ + 'scaleX' => 2.0, + 'scaleY' => 2.0, + 'width' => 200.0, + 'height' => 100.0, + 'translateX' => -20.0, + 'translateY' => -40.0, + ], + ]; + + yield 'fromScale repositions negative bbox origins into user space' => [ + 'fromScale', + [-10.0, -5.0, 90.0, 45.0], + 1.5, + ['x' => 2.0, 'y' => 3.0], + [ + 'scaleX' => 1.5, + 'scaleY' => 1.5, + 'width' => 150.0, + 'height' => 75.0, + 'translateX' => 17.0, + 'translateY' => 10.5, + ], + ' /Fm7 ', + 'q 1.5 0 0 1.5 17 10.5 cm /Fm7 Do Q', + ]; } - public function testFromWidthRejectsZeroTargetWidth(): void + /** + * @return iterable + */ + public static function invalidTargetProvider(): iterable { - $calculator = new XObjectPlacementCalculator(); + yield 'fromWidth rejects zero target width' => [ + 'fromWidth', + 0.0, + 'Placement target width must be greater than zero.', + ]; - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Placement target width must be greater than zero.'); + yield 'fromWidth rejects negative target width' => [ + 'fromWidth', + -1.0, + 'Placement target width must be greater than zero.', + ]; - $calculator->fromWidth( - new CompileResult(contentStream: 'BT ET', resources: [], bbox: [0.0, 0.0, 240.0, 84.0]), + yield 'fromHeight rejects zero target height' => [ + 'fromHeight', 0.0, - ); - } + 'Placement target height must be greater than zero.', + ]; - public function testFromHeightRejectsZeroTargetHeight(): void - { - $calculator = new XObjectPlacementCalculator(); + yield 'fromHeight rejects negative target height' => [ + 'fromHeight', + -1.0, + 'Placement target height must be greater than zero.', + ]; - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Placement target height must be greater than zero.'); - - $calculator->fromHeight( - new CompileResult(contentStream: 'BT ET', resources: [], bbox: [0.0, 0.0, 240.0, 84.0]), + yield 'fromScale rejects zero scale' => [ + 'fromScale', 0.0, - ); + 'Placement scale must be greater than zero.', + ]; + + yield 'fromScale rejects negative scale' => [ + 'fromScale', + -1.0, + 'Placement scale must be greater than zero.', + ]; } - public function testFromScaleRejectsNonPositiveScale(): void + /** + * @return iterable + */ + public static function invalidBoundingBoxProvider(): iterable { - $calculator = new XObjectPlacementCalculator(); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Placement scale must be greater than zero.'); + yield 'zero width bbox is rejected' => [[10.0, 20.0, 10.0, 70.0]]; + yield 'zero height bbox is rejected' => [[10.0, 20.0, 110.0, 20.0]]; + yield 'negative width bbox is rejected' => [[110.0, 20.0, 10.0, 70.0]]; + yield 'negative height bbox is rejected' => [[10.0, 70.0, 110.0, 20.0]]; + } - $calculator->fromScale( - new CompileResult(contentStream: 'BT ET', resources: [], bbox: [0.0, 0.0, 240.0, 84.0]), - 0.0, - ); + #[DataProvider('placementProvider')] + public function testPlacementStrategiesReturnExpectedGeometry( + string $strategy, + array $bbox, + float $targetValue, + ?array $coordinates, + array $expectedPlacement, + ?string $pdfAlias = null, + ?string $expectedPdfCommand = null, + ): void { + $calculator = new XObjectPlacementCalculator(); + $result = new CompileResult(contentStream: 'BT ET', resources: [], bbox: $bbox); + + $placement = match ([$strategy, $coordinates === null]) { + ['fromWidth', true] => $calculator->fromWidth($result, $targetValue), + ['fromHeight', true] => $calculator->fromHeight($result, $targetValue), + ['fromScale', true] => $calculator->fromScale($result, $targetValue), + ['fromWidth', false] => $calculator->fromWidth( + $result, + $targetValue, + $coordinates['x'], + $coordinates['y'], + ), + ['fromHeight', false] => $calculator->fromHeight( + $result, + $targetValue, + $coordinates['x'], + $coordinates['y'], + ), + ['fromScale', false] => $calculator->fromScale( + $result, + $targetValue, + $coordinates['x'], + $coordinates['y'], + ), + default => throw new InvalidArgumentException('Unknown placement strategy.'), + }; + + self::assertEqualsWithDelta($expectedPlacement['scaleX'], $placement->scaleX, 0.0001); + self::assertEqualsWithDelta($expectedPlacement['scaleY'], $placement->scaleY, 0.0001); + self::assertEqualsWithDelta($expectedPlacement['width'], $placement->width, 0.0001); + self::assertEqualsWithDelta($expectedPlacement['height'], $placement->height, 0.0001); + self::assertEqualsWithDelta($expectedPlacement['translateX'], $placement->translateX, 0.0001); + self::assertEqualsWithDelta($expectedPlacement['translateY'], $placement->translateY, 0.0001); + + if ($expectedPdfCommand !== null) { + self::assertSame($expectedPdfCommand, $placement->toPdfCommand($pdfAlias ?? 'Fm0')); + } } - public function testPlacementRejectsBoundingBoxesWithoutPositiveArea(): void - { + #[DataProvider('invalidTargetProvider')] + public function testPlacementStrategiesRejectNonPositiveTargets( + string $strategy, + float $targetValue, + string $expectedMessage, + ): void { $calculator = new XObjectPlacementCalculator(); $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('CompileResult bbox must describe a positive area.'); + $this->expectExceptionMessage($expectedMessage); - $calculator->fromScale( - new CompileResult(contentStream: 'BT ET', resources: [], bbox: [10.0, 20.0, 10.0, 70.0]), - 1.0, - ); + $result = new CompileResult(contentStream: 'BT ET', resources: [], bbox: [0.0, 0.0, 240.0, 84.0]); + + match ($strategy) { + 'fromWidth' => $calculator->fromWidth($result, $targetValue), + 'fromHeight' => $calculator->fromHeight($result, $targetValue), + 'fromScale' => $calculator->fromScale($result, $targetValue), + default => throw new InvalidArgumentException('Unknown placement strategy.'), + }; } - public function testPlacementRejectsBoundingBoxesWithZeroHeight(): void + #[DataProvider('invalidBoundingBoxProvider')] + public function testPlacementRejectsBoundingBoxesWithoutPositiveArea(array $bbox): void { $calculator = new XObjectPlacementCalculator(); @@ -160,7 +256,7 @@ public function testPlacementRejectsBoundingBoxesWithZeroHeight(): void $this->expectExceptionMessage('CompileResult bbox must describe a positive area.'); $calculator->fromScale( - new CompileResult(contentStream: 'BT ET', resources: [], bbox: [10.0, 20.0, 110.0, 20.0]), + new CompileResult(contentStream: 'BT ET', resources: [], bbox: $bbox), 1.0, ); } diff --git a/tests/Unit/Layout/LayoutStyleResolverTest.php b/tests/Unit/Layout/LayoutStyleResolverTest.php index d87dc4f..bd09a14 100644 --- a/tests/Unit/Layout/LayoutStyleResolverTest.php +++ b/tests/Unit/Layout/LayoutStyleResolverTest.php @@ -9,62 +9,180 @@ use LibreSign\XObjectTemplate\Css\InlineStyleParser; use LibreSign\XObjectTemplate\Layout\LayoutStyleResolver; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; final class LayoutStyleResolverTest extends TestCase { - public function testToPointsAndRelativeDimensionsNormalizeTrimmedUppercaseValues(): void + /** + * @return iterable + */ + public static function toPointsProvider(): iterable { - $resolver = new LayoutStyleResolver(); - - self::assertSame(7.5, $resolver->toPoints('10PX')); - self::assertSame(10.0, $resolver->toPoints('10')); - self::assertSame(20.0, $resolver->resolveRelativeDimension(' 25% ', 80.0)); - self::assertSame(7.5, $resolver->resolveRelativeDimension(' 10PX ', 80.0)); - self::assertSame(0.0, $resolver->resolveRelativeDimension(' ', 80.0)); + yield 'uppercase px unit converts to points' => ['10PX', 7.5]; + yield 'unitless value stays absolute' => ['10', 10.0]; + yield 'decimal px value preserves precision' => ['12.5PX', 9.375]; } - public function testParseBoxSpacingRelativeUsesAxisSpecificReferencesAcrossShorthandVariants(): void + /** + * @return iterable + */ + public static function relativeDimensionProvider(): iterable { - $resolver = new LayoutStyleResolver(); + yield 'percentage uses provided reference' => [' 25% ', 80.0, 20.0]; + yield 'uppercase px unit converts through point normalization' => [' 10PX ', 80.0, 7.5]; + yield 'whitespace resolves to zero' => [' ', 80.0, 0.0]; + yield 'unitless decimals remain absolute' => ['12.5', 80.0, 12.5]; + } - self::assertSame( + /** + * @return iterable + */ + public static function relativeBoxSpacingProvider(): iterable + { + yield 'single percentage applies to all sides' => [ + '10%', + 200.0, + 100.0, ['top' => 10.0, 'right' => 20.0, 'bottom' => 10.0, 'left' => 20.0], - $resolver->parseBoxSpacingRelative('10%', 200.0, 100.0), - ); - self::assertSame( + ]; + + yield 'two-value shorthand mirrors horizontal slot' => [ + '10% 20%', + 200.0, + 100.0, ['top' => 10.0, 'right' => 40.0, 'bottom' => 10.0, 'left' => 40.0], - $resolver->parseBoxSpacingRelative('10% 20%', 200.0, 100.0), - ); - self::assertSame( + ]; + + yield 'three-value shorthand copies second token to left' => [ + '10% 20% 30%', + 200.0, + 100.0, ['top' => 10.0, 'right' => 40.0, 'bottom' => 30.0, 'left' => 40.0], - $resolver->parseBoxSpacingRelative('10% 20% 30%', 200.0, 100.0), - ); - self::assertSame( + ]; + + yield 'four-value shorthand keeps every slot distinct' => [ + '10% 20% 30% 40%', + 200.0, + 100.0, ['top' => 10.0, 'right' => 40.0, 'bottom' => 30.0, 'left' => 80.0], - $resolver->parseBoxSpacingRelative('10% 20% 30% 40%', 200.0, 100.0), - ); + ]; + + yield 'mixed units normalize points and percentages together' => [ + '8PX 10% 12PX 5%', + 200.0, + 100.0, + ['top' => 6.0, 'right' => 20.0, 'bottom' => 9.0, 'left' => 10.0], + ]; + + yield 'whitespace input returns zero slots' => [ + " \t\n ", + 200.0, + 100.0, + ['top' => 0.0, 'right' => 0.0, 'bottom' => 0.0, 'left' => 0.0], + ]; + } + + /** + * @return iterable + */ + public static function absolutePositionProvider(): iterable + { + yield 'trimmed uppercase absolute is detected' => ['position: ABSOLUTE ', true]; + yield 'relative positioning is not treated as absolute' => ['position: relative', false]; + yield 'missing position stays non-absolute' => ['display:flex', false]; } - public function testParseBoxSpacingRelativeReturnsZeroSlotsForWhitespaceOnlyInput(): void + /** + * @return iterable + */ + public static function fontAliasProvider(): iterable + { + yield 'semibold courier maps to bold courier alias' => ['Courier New', '600', 'F6']; + yield 'bold times maps to bold times alias' => ['Times New Roman', 'BOLD', 'F4']; + yield 'weights below threshold keep regular helvetica alias' => ['Helvetica', '599', 'F1']; + yield 'quoted times family still resolves to times alias' => [ + '"Times New Roman", serif', + '400', + 'F3', + ]; + yield 'bolder courier family keeps monospace alias' => ['Courier, monospace', 'bolder', 'F6']; + yield 'bold helvetica fallback uses bold helvetica alias' => [ + 'Helvetica, Arial, sans-serif', + '700', + 'F2', + ]; + } + + #[DataProvider('toPointsProvider')] + public function testToPointsNormalizesUnitsAndPrecision(string $value, float $expectedPoints): void { $resolver = new LayoutStyleResolver(); + self::assertSame($expectedPoints, $resolver->toPoints($value)); + } + + #[DataProvider('relativeDimensionProvider')] + public function testResolveRelativeDimensionUsesExpectedReferenceRule( + string $value, + float $reference, + float $expectedDimension, + ): void { + $resolver = new LayoutStyleResolver(); + self::assertSame( - ['top' => 0.0, 'right' => 0.0, 'bottom' => 0.0, 'left' => 0.0], - $resolver->parseBoxSpacingRelative(" \t\n ", 200.0, 100.0), + $expectedDimension, + $resolver->resolveRelativeDimension($value, $reference), ); } - public function testPositionAndFontResolutionNormalizeWhitespaceAndCase(): void - { + #[DataProvider('relativeBoxSpacingProvider')] + public function testParseBoxSpacingRelativeExpandsShorthandSlots( + string $value, + float $widthReference, + float $heightReference, + array $expectedSpacing, + ): void { + $resolver = new LayoutStyleResolver(); + + self::assertSame( + $expectedSpacing, + $resolver->parseBoxSpacingRelative($value, $widthReference, $heightReference), + ); + } + + #[DataProvider('absolutePositionProvider')] + public function testIsAbsolutelyPositionedNormalizesWhitespaceAndCase( + string $inlineStyle, + bool $expectedAbsolute, + ): void { $parser = new InlineStyleParser(); $resolver = new LayoutStyleResolver(); - self::assertTrue($resolver->isAbsolutelyPositioned($parser->parse('position: ABSOLUTE '))); - self::assertFalse($resolver->isAbsolutelyPositioned($parser->parse('position: relative'))); - self::assertSame('F6', $resolver->resolveFontAlias('Courier New', '600')); - self::assertSame('F4', $resolver->resolveFontAlias('Times New Roman', 'BOLD')); - self::assertSame('F1', $resolver->resolveFontAlias('Helvetica', '500')); + self::assertSame( + $expectedAbsolute, + $resolver->isAbsolutelyPositioned($parser->parse($inlineStyle)), + ); + } + + #[DataProvider('fontAliasProvider')] + public function testResolveFontAliasMapsSupportedPdfFamiliesAndWeights( + string $fontFamily, + string $fontWeight, + string $expectedAlias, + ): void { + $resolver = new LayoutStyleResolver(); + + self::assertSame($expectedAlias, $resolver->resolveFontAlias($fontFamily, $fontWeight)); } } diff --git a/tests/Unit/Layout/StructuredFlexLayoutPlannerTest.php b/tests/Unit/Layout/StructuredFlexLayoutPlannerTest.php index 4a8043d..d60f70e 100644 --- a/tests/Unit/Layout/StructuredFlexLayoutPlannerTest.php +++ b/tests/Unit/Layout/StructuredFlexLayoutPlannerTest.php @@ -36,6 +36,16 @@ public static function normalizedDirectionProvider(): iterable 'expectedDirection' => 'column', ]; + yield 'mixed-case column stays column' => [ + 'direction' => 'CoLuMn', + 'expectedDirection' => 'column', + ]; + + yield 'trimmed row stays row' => [ + 'direction' => ' row ', + 'expectedDirection' => 'row', + ]; + yield 'unexpected value falls back to row' => [ 'direction' => ' unexpected ', 'expectedDirection' => 'row', @@ -44,6 +54,7 @@ public static function normalizedDirectionProvider(): iterable /** * @return iterable [ + 'inlineStyle' => 'gap:10%', 'direction' => 'row', 'contentBox' => ['x' => 0.0, 'y' => 0.0, 'width' => 200.0, 'height' => 50.0], 'expectedGap' => 20.0, ]; yield 'column gap uses container height' => [ + 'inlineStyle' => 'gap:10%', 'direction' => 'column', 'contentBox' => ['x' => 0.0, 'y' => 0.0, 'width' => 200.0, 'height' => 50.0], 'expectedGap' => 5.0, ]; + + yield 'pixel gap converts through point normalization' => [ + 'inlineStyle' => 'gap:16PX', + 'direction' => 'row', + 'contentBox' => ['x' => 0.0, 'y' => 0.0, 'width' => 200.0, 'height' => 50.0], + 'expectedGap' => 12.0, + ]; + + yield 'missing gap defaults to zero' => [ + 'inlineStyle' => '', + 'direction' => 'row', + 'contentBox' => ['x' => 0.0, 'y' => 0.0, 'width' => 200.0, 'height' => 50.0], + 'expectedGap' => 0.0, + ]; + + yield 'percentage gap on zero-width row resolves to zero' => [ + 'inlineStyle' => 'gap:10%', + 'direction' => 'row', + 'contentBox' => ['x' => 0.0, 'y' => 0.0, 'width' => 0.0, 'height' => 50.0], + 'expectedGap' => 0.0, + ]; } /** @@ -115,6 +149,34 @@ public static function measureItemProvider(): iterable 'container' => ['x' => 0.0, 'y' => 0.0, 'width' => 100.0, 'height' => 0.0], 'expectedSize' => ['width' => 24.46, 'height' => 12.0], ]; + + yield 'explicit percentage size uses container references' => [ + 'node' => new Node(tag: 'span', text: 'Label', attributes: []), + 'inlineStyle' => 'font-size:10;width:50%;height:25%', + 'container' => ['x' => 0.0, 'y' => 0.0, 'width' => 120.0, 'height' => 40.0], + 'expectedSize' => ['width' => 60.0, 'height' => 10.0], + ]; + + yield 'configured line height can grow measured text height' => [ + 'node' => new Node(tag: 'span', text: 'Label', attributes: []), + 'inlineStyle' => 'font-size:10;line-height:18', + 'container' => ['x' => 0.0, 'y' => 0.0, 'width' => 100.0, 'height' => 40.0], + 'expectedSize' => ['width' => 24.46, 'height' => 18.0], + ]; + + yield 'configured line height does not shrink below font default' => [ + 'node' => new Node(tag: 'span', text: 'Label', attributes: []), + 'inlineStyle' => 'font-size:10;line-height:8', + 'container' => ['x' => 0.0, 'y' => 0.0, 'width' => 100.0, 'height' => 40.0], + 'expectedSize' => ['width' => 24.46, 'height' => 12.0], + ]; + + yield 'image respects explicit pixel dimensions after point conversion' => [ + 'node' => new Node(tag: 'img', text: '', attributes: ['src' => '/icon.png']), + 'inlineStyle' => 'width:40px;height:16PX', + 'container' => ['x' => 0.0, 'y' => 0.0, 'width' => 100.0, 'height' => 40.0], + 'expectedSize' => ['width' => 30.0, 'height' => 12.0], + ]; } /** @@ -216,6 +278,55 @@ public static function calculateMetricsProvider(): iterable 'mainAxisOffset' => 0.0, ], ]; + + yield 'center alignment uses half of remaining row space as offset' => [ + 'itemSizes' => [ + ['width' => 20.0, 'height' => 10.0], + ['width' => 30.0, 'height' => 12.0], + ], + 'direction' => 'row', + 'justifyContent' => 'center', + 'gap' => 0.0, + 'contentBox' => ['x' => 0.0, 'y' => 0.0, 'width' => 100.0, 'height' => 50.0], + 'expectedMetrics' => [ + 'mainAxisOffset' => 25.0, + 'totalMainAxisSize' => 50.0, + 'crossAxisSize' => 12.0, + 'crossContainerSize' => 50.0, + ], + ]; + + yield 'flex-end alignment uses all remaining row space as offset' => [ + 'itemSizes' => [ + ['width' => 20.0, 'height' => 10.0], + ['width' => 30.0, 'height' => 12.0], + ], + 'direction' => 'row', + 'justifyContent' => 'flex-end', + 'gap' => 0.0, + 'contentBox' => ['x' => 0.0, 'y' => 0.0, 'width' => 100.0, 'height' => 50.0], + 'expectedMetrics' => [ + 'mainAxisOffset' => 50.0, + 'totalMainAxisSize' => 50.0, + ], + ]; + + yield 'column space-between expands vertical gap across remaining space' => [ + 'itemSizes' => [ + ['width' => 20.0, 'height' => 10.0], + ['width' => 15.0, 'height' => 20.0], + ], + 'direction' => 'column', + 'justifyContent' => 'space-between', + 'gap' => 5.0, + 'contentBox' => ['x' => 0.0, 'y' => 0.0, 'width' => 80.0, 'height' => 100.0], + 'expectedMetrics' => [ + 'gap' => 70.0, + 'totalMainAxisSize' => 100.0, + 'crossAxisSize' => 20.0, + 'crossContainerSize' => 80.0, + ], + ]; } /** @@ -290,6 +401,16 @@ public static function createChildBoxProvider(): iterable 'cursor' => 15.0, 'expectedChildBox' => ['x' => 10.0], ]; + + yield 'column layout positions centered child within cross axis' => [ + 'itemSize' => ['width' => 50.0, 'height' => 20.0], + 'direction' => 'column', + 'alignItems' => 'center', + 'contentBox' => ['x' => 10.0, 'y' => 20.0, 'width' => 200.0, 'height' => 100.0], + 'crossContainerSize' => 200.0, + 'cursor' => 15.0, + 'expectedChildBox' => ['x' => 85.0, 'y' => 35.0, 'width' => 50.0, 'height' => 20.0], + ]; } /** @@ -329,12 +450,13 @@ public function testNormalizeDirectionReturnsExpectedValue( #[DataProvider('resolveGapProvider')] public function testResolveGapUsesExpectedAxis( + string $inlineStyle, string $direction, array $contentBox, float $expectedGap, ): void { $planner = new StructuredFlexLayoutPlanner(new LayoutStyleResolver()); - $style = (new InlineStyleParser())->parse('gap:10%'); + $style = (new InlineStyleParser())->parse($inlineStyle); self::assertSame($expectedGap, $planner->resolveGap($style, $direction, $contentBox)); }