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' => [
+ '',
+ 'Tag is not supported.',
+ ];
+
+ yield 'unordered lists are outside the supported subset' => [
+ '',
+ 'Tag is not supported.',
+ ];
+
+ yield 'semantic strong tags are outside the supported subset' => [
+ 'x',
+ 'Tag is not supported.',
+ ];
+ }
+
+ /**
+ * @return iterable}>
+ */
+ public static function inheritableStyleProvider(): iterable
+ {
+ yield 'layout-only styles stay on parent while text styles inherit' => [
+ '',
+ 'width:58%;height:100%;padding:18 24;font-size:20;color:#123456',
+ 'font-size:20;color:#123456;font-weight:700',
+ 'font-size:20;color:#123456;font-weight:700',
+ ['width:58%', 'height:100%', 'padding:18 24'],
+ ];
+
+ yield 'text alignment white-space and color inherit together' => [
+ ''
+ . 'Aligned'
+ . '
',
+ 'text-align:right;white-space:nowrap;color:#222222;font-size:11',
+ 'text-align:right;white-space:nowrap;color:#222222;font-size:11',
+ 'text-align:right;white-space:nowrap;color:#222222;font-size:11',
+ [],
+ ];
+
+ yield 'malformed declarations preserve last inheritable values and colon values' => [
+ ''
+ . 'Hello'
+ . '
',
+ '; COLOR : #fff ; broken ; font-family : Times:Bold ; invalid: ; white-space : nowrap ; '
+ . 'hyphens : auto ; color : #abc ; line-height : 12 ;',
+ 'color:#abc;font-family:Times:Bold;white-space:nowrap;hyphens:auto;line-height:12;font-weight:bold',
+ 'color:#abc;font-family:Times:Bold;white-space:nowrap;hyphens:auto;line-height:12;font-weight:bold',
+ [],
+ ];
+ }
+
+ #[DataProvider('unsupportedTagProvider')]
+ public function testUnsupportedTagThrowsException(string $html, string $expectedMessage): void
{
$parser = new SubsetHtmlParser();
$this->expectException(UnsupportedSubsetException::class);
- $this->expectExceptionMessage('Tag is not supported.');
- $parser->parse('');
+ $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(
- '',
- );
+ $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));
}