Skip to content

Commit c628e31

Browse files
committed
test: raise mutation coverage for parser and layout flows
Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com>
1 parent c4b0f23 commit c628e31

9 files changed

Lines changed: 287 additions & 1 deletion

tests/Unit/ColorParserTest.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,24 @@ public static function colorProvider(): iterable
3636
'expected' => '0.6667 0.7333 0.8 rg',
3737
];
3838

39+
yield 'trim and uppercase are supported' => [
40+
'input' => ' #AaBbCc ',
41+
'expected' => '0.6667 0.7333 0.8 rg',
42+
];
43+
3944
yield 'invalid color falls back to black' => [
4045
'input' => 'not-a-color',
4146
'expected' => '0 0 0 rg',
4247
];
48+
49+
yield 'rejects extra trailing digits' => [
50+
'input' => '#1234567',
51+
'expected' => '0 0 0 rg',
52+
];
53+
54+
yield 'rejects prefixed six-digit tail' => [
55+
'input' => 'x123456',
56+
'expected' => '0 0 0 rg',
57+
];
4358
}
4459
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php
2+
3+
// SPDX-FileCopyrightText: 2026 LibreSign
4+
// SPDX-License-Identifier: AGPL-3.0-or-later
5+
6+
declare(strict_types=1);
7+
8+
namespace LibreSign\XObjectTemplate\Tests\Unit;
9+
10+
use LibreSign\XObjectTemplate\Css\InlineStyleParser;
11+
use PHPUnit\Framework\TestCase;
12+
13+
final class InlineStyleParserTest extends TestCase
14+
{
15+
public function testParseNormalizesNamesAndKeepsLaterValidChunksAfterMalformedOnes(): void
16+
{
17+
$parser = new InlineStyleParser();
18+
19+
$map = $parser->parse('broken; COLOR : #fff ; bad:value:extra ; font-weight : 700 ; :missing-name; empty:');
20+
21+
self::assertSame('#fff', $map->get('color'));
22+
self::assertSame('700', $map->get('font-weight'));
23+
self::assertNull($map->get('COLOR'));
24+
}
25+
26+
public function testParseUsesSingleSplitForValuesContainingColons(): void
27+
{
28+
$parser = new InlineStyleParser();
29+
30+
$map = $parser->parse('background:url(http://example.test/a:b.png);font-size:10');
31+
32+
self::assertSame('url(http://example.test/a:b.png)', $map->get('background'));
33+
self::assertSame('10', $map->get('font-size'));
34+
}
35+
36+
public function testParseSkipsEmptyNameOrValueWithoutStoppingTheLoop(): void
37+
{
38+
$parser = new InlineStyleParser();
39+
40+
$map = $parser->parse(':bad;color:red;font-size:;padding:4');
41+
42+
self::assertSame('red', $map->get('color'));
43+
self::assertSame('4', $map->get('padding'));
44+
self::assertNull($map->get('font-size'));
45+
}
46+
}

tests/Unit/LinearLayoutEngineTest.php

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,4 +66,130 @@ public function testLayoutUsesBreakNodesToMoveToTheNextLine(): void
6666
self::assertSame('Second line', $result->lines[1]->text);
6767
self::assertLessThan($result->lines[0]->y, $result->lines[1]->y);
6868
}
69+
70+
public function testLayoutSupportsRightAndCenterAlignmentWithFallbackWidth(): void
71+
{
72+
$engine = new LinearLayoutEngine();
73+
74+
$result = $engine->layout([
75+
new Node(
76+
tag: 'p',
77+
text: 'Right',
78+
attributes: [
79+
'style' => 'text-align:right;margin:1 2 3 4;padding:5 6 7 8;width:0;font-size:10',
80+
],
81+
),
82+
new Node(
83+
tag: 'p',
84+
text: 'Center',
85+
attributes: [
86+
'style' => 'text-align:center;margin:1 2 3 4;padding:5 6 7 8;width:0;font-size:10',
87+
],
88+
),
89+
], 100.0, 100.0);
90+
91+
self::assertCount(2, $result->lines);
92+
self::assertSame('Right', $result->lines[0]->text);
93+
self::assertSame('Center', $result->lines[1]->text);
94+
self::assertEqualsWithDelta(84.0, $result->lines[0]->x, 0.0001);
95+
self::assertEqualsWithDelta(52.0, $result->lines[1]->x, 0.0001);
96+
self::assertEqualsWithDelta(82.0, $result->lines[0]->y, 0.0001);
97+
}
98+
99+
public function testLayoutTreatsNonPositiveImageDimensionsAsDefaultsAndClampsToCanvas(): void
100+
{
101+
$engine = new LinearLayoutEngine();
102+
103+
$result = $engine->layout([
104+
new Node(
105+
tag: 'img',
106+
text: '',
107+
attributes: [
108+
'src' => '/fixture/signature.png',
109+
'style' => 'width:0;height:-10',
110+
],
111+
),
112+
], 20.0, 15.0);
113+
114+
self::assertCount(1, $result->images);
115+
self::assertSame('/fixture/signature.png', $result->images[0]->source);
116+
self::assertSame('Im0', $result->images[0]->alias);
117+
self::assertEqualsWithDelta(20.0, $result->images[0]->width, 0.0001);
118+
self::assertEqualsWithDelta(15.0, $result->images[0]->height, 0.0001);
119+
self::assertEqualsWithDelta(0.0, $result->images[0]->y, 0.0001);
120+
}
121+
122+
public function testLayoutResolvesQuotedFontFamilyAndNumericBoldWeight(): void
123+
{
124+
$engine = new LinearLayoutEngine();
125+
126+
$result = $engine->layout([
127+
new Node(
128+
tag: 'p',
129+
text: 'Times bold',
130+
attributes: ['style' => 'font-family: "Times New Roman" ; font-weight:600; font-size:10'],
131+
),
132+
new Node(
133+
tag: 'p',
134+
text: 'Helvetica regular',
135+
attributes: ['style' => 'font-family: Helvetica; font-weight:599; font-size:10'],
136+
),
137+
], 120.0, 90.0);
138+
139+
self::assertCount(2, $result->lines);
140+
self::assertSame('F4', $result->lines[0]->fontAlias);
141+
self::assertSame('F1', $result->lines[1]->fontAlias);
142+
}
143+
144+
public function testLayoutImageFlowAdvancesCursorAndAliasForSubsequentNodes(): void
145+
{
146+
$engine = new LinearLayoutEngine();
147+
148+
$result = $engine->layout([
149+
new Node(tag: 'img', text: '', attributes: ['src' => '/a.png', 'style' => 'width:10px;height:8px;margin:0 0 3 0;padding:0 0 4 0']),
150+
new Node(tag: 'img', text: '', attributes: ['src' => '/b.png', 'style' => 'width:10px;height:8px;margin:0 0 3 0;padding:0 0 4 0']),
151+
new Node(tag: 'p', text: 'After images', attributes: ['style' => 'font-size:10']),
152+
], 100.0, 100.0);
153+
154+
self::assertCount(2, $result->images);
155+
self::assertSame('Im0', $result->images[0]->alias);
156+
self::assertSame('Im1', $result->images[1]->alias);
157+
self::assertEqualsWithDelta(82.0, $result->images[0]->y, 0.0001);
158+
self::assertEqualsWithDelta(67.0, $result->images[1]->y, 0.0001);
159+
self::assertCount(1, $result->lines);
160+
self::assertSame('After images', $result->lines[0]->text);
161+
self::assertEqualsWithDelta(58.0, $result->lines[0]->y, 0.0001);
162+
}
163+
164+
public function testLayoutUsesCssSpacingShorthandSemanticsForTwoThreeAndFourValues(): void
165+
{
166+
$engine = new LinearLayoutEngine();
167+
168+
$result = $engine->layout([
169+
new Node(tag: 'p', text: 'Two', attributes: ['style' => 'font-size:10;margin:1 2']),
170+
new Node(tag: 'p', text: 'Three', attributes: ['style' => 'font-size:10;margin:1 2 3']),
171+
new Node(tag: 'p', text: 'Four', attributes: ['style' => 'font-size:10;margin:1 2 3 4']),
172+
], 100.0, 100.0);
173+
174+
self::assertCount(3, $result->lines);
175+
self::assertEqualsWithDelta(10.0, $result->lines[0]->x, 0.0001);
176+
self::assertEqualsWithDelta(10.0, $result->lines[1]->x, 0.0001);
177+
self::assertEqualsWithDelta(12.0, $result->lines[2]->x, 0.0001);
178+
self::assertEqualsWithDelta(87.0, $result->lines[0]->y, 0.0001);
179+
self::assertEqualsWithDelta(73.0, $result->lines[1]->y, 0.0001);
180+
self::assertEqualsWithDelta(57.0, $result->lines[2]->y, 0.0001);
181+
}
182+
183+
public function testLayoutTreatsZeroImageHeightAsDefaultDimension(): void
184+
{
185+
$engine = new LinearLayoutEngine();
186+
187+
$result = $engine->layout([
188+
new Node(tag: 'img', text: '', attributes: ['src' => '/zero.png', 'style' => 'width:12;height:0']),
189+
], 200.0, 120.0);
190+
191+
self::assertCount(1, $result->images);
192+
self::assertEqualsWithDelta(12.0, $result->images[0]->width, 0.0001);
193+
self::assertEqualsWithDelta(32.0, $result->images[0]->height, 0.0001);
194+
}
69195
}

tests/Unit/SignatureAppearanceXObjectAdapterTest.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ public function testAdapterMapsToExpectedPayload(): void
2525
$payload = $adapter->toPdfSignerPayload($result);
2626

2727
self::assertSame('BT\n(Foo) Tj\nET', $payload['stream']);
28+
self::assertSame(['Font' => ['F1' => ['BaseFont' => '/Helvetica']]], $payload['resources']);
2829
self::assertSame([0.0, 0.0, 240.0, 84.0], $payload['bbox']);
2930
}
3031
}

tests/Unit/SubsetHtmlParserAdvancedTest.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,35 @@ public function testParseMergesInheritedStylesAndKeepsAllowedTags(): void
3737
self::assertSame('World', $nodes[0]->children[2]->text);
3838
self::assertSame('font-size:10; margin:2', $nodes[0]->children[2]->attributes['style']);
3939
}
40+
41+
public function testParseNormalizesTagAndAttributeNamesAndKeepsAllAttributes(): void
42+
{
43+
$parser = new SubsetHtmlParser();
44+
45+
$nodes = $parser->parse('<DIV STYLE="font-size:10" DATA-ID="42">Hi</DIV>');
46+
47+
self::assertCount(1, $nodes);
48+
self::assertSame('div', $nodes[0]->tag);
49+
self::assertArrayHasKey('style', $nodes[0]->attributes);
50+
self::assertArrayHasKey('data-id', $nodes[0]->attributes);
51+
self::assertSame('font-size:10', $nodes[0]->attributes['style']);
52+
self::assertSame('42', $nodes[0]->attributes['data-id']);
53+
}
54+
55+
public function testParseTrimsInheritedStyleAndRestoresLibxmlInternalErrorFlag(): void
56+
{
57+
$parser = new SubsetHtmlParser();
58+
$previous = libxml_use_internal_errors(false);
59+
60+
try {
61+
$nodes = $parser->parse('<div style=" font-size:10 "><span>Hi</span></div>');
62+
} finally {
63+
$current = libxml_use_internal_errors(false);
64+
libxml_use_internal_errors($previous);
65+
}
66+
67+
self::assertFalse($current);
68+
self::assertSame('font-size:10', $nodes[0]->attributes['style']);
69+
self::assertSame('font-size:10', $nodes[0]->children[0]->children[0]->attributes['style']);
70+
}
4071
}

tests/Unit/SubsetHtmlParserTest.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,19 @@ public function testUnsupportedTagThrowsException(): void
1818
$parser = new SubsetHtmlParser();
1919

2020
$this->expectException(UnsupportedSubsetException::class);
21+
$this->expectExceptionMessage('Tag <table> is not supported.');
2122
$parser->parse('<table><tr><td>x</td></tr></table>');
2223
}
24+
25+
public function testParseNormalizesAttributesAndTrimsTextNodes(): void
26+
{
27+
$parser = new SubsetHtmlParser();
28+
29+
$nodes = $parser->parse('<span STYLE=" color:#fff "> Hello </span>');
30+
31+
self::assertCount(1, $nodes);
32+
self::assertSame('span', $nodes[0]->tag);
33+
self::assertSame('color:#fff', $nodes[0]->attributes['style']);
34+
self::assertSame('Hello', $nodes[0]->children[0]->text);
35+
}
2336
}

tests/Unit/TemplateDocumentBuilderCompatibilityTest.php

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,13 @@ public function testCompilerConstructorStillAcceptsLegacyPdfDependencies(): void
2424
null,
2525
new PdfEscaper(),
2626
new ColorParser(),
27-
new TemplateDocumentBuilder(),
2827
);
2928

3029
$result = $compiler->compile(new CompileRequest(html: '<p>Hello</p>'));
3130

3231
self::assertStringContainsString('(Hello) Tj', $result->contentStream);
3332
self::assertSame([0.0, 0.0, 240.0, 84.0], $result->bbox);
33+
self::assertArrayHasKey('Font', $result->resources);
3434
}
3535

3636
public function testBuilderBuildsPayloadWithCustomMetadataCount(): void
@@ -45,5 +45,20 @@ public function testBuilderBuildsPayloadWithCustomMetadataCount(): void
4545

4646
self::assertSame([0.0, 0.0, 100.0, 50.0], $result->bbox);
4747
self::assertSame(7, $result->metadata['node_count']);
48+
self::assertSame(0, $result->metadata['line_count']);
49+
self::assertSame(0, $result->metadata['image_count']);
50+
}
51+
52+
public function testBuilderBuildUsesDefaultNodeCountWhenNotProvided(): void
53+
{
54+
$builder = new TemplateDocumentBuilder();
55+
56+
$result = $builder->build(
57+
new CompileRequest(html: '<p>Hello</p>', width: 100.0, height: 50.0),
58+
new LayoutResult(lines: [], images: []),
59+
hrtime(true),
60+
);
61+
62+
self::assertSame(0, $result->metadata['node_count']);
4863
}
4964
}

tests/Unit/TemplateDocumentBuilderTest.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,17 +44,38 @@ public function testBuildCreatesDocumentPayloadParts(): void
4444

4545
$result = $builder->build(new CompileRequest(html: '<p>unused</p>'), $layout, 1_000_000_000, 2);
4646

47+
self::assertStringStartsWith("q\nq ", $result->contentStream);
4748
self::assertStringContainsString('/F1 10.000000 Tf', $result->contentStream);
4849
self::assertStringContainsString('/Im0 Do', $result->contentStream);
50+
self::assertStringContainsString("\nET\nQ", $result->contentStream);
4951
self::assertSame([0.0, 0.0, 240.0, 84.0], $result->bbox);
5052
self::assertSame('Signed by Alice', $layout->lines[0]->text);
5153
self::assertArrayHasKey('Font', $result->resources);
5254
self::assertArrayHasKey('XObject', $result->resources);
5355
self::assertSame('/Helvetica', $result->resources['Font']['F1']['BaseFont']);
56+
self::assertSame('/XObject', $result->resources['XObject']['Im0']['Type']);
57+
self::assertSame('/Image', $result->resources['XObject']['Im0']['Subtype']);
58+
self::assertSame(24.0, $result->resources['XObject']['Im0']['Width']);
59+
self::assertSame(24.0, $result->resources['XObject']['Im0']['Height']);
5460
self::assertSame('/tmp/signature.png', $result->resources['XObject']['Im0']['Source']);
5561
self::assertSame(1, $result->metadata['line_count']);
5662
self::assertSame(1, $result->metadata['image_count']);
5763
self::assertSame(2, $result->metadata['node_count']);
5864
self::assertArrayHasKey('render_ms', $result->metadata);
5965
}
66+
67+
public function testBuildMetadataDefaultsNodeCountAndUsesRoundedMilliseconds(): void
68+
{
69+
$builder = new TemplateDocumentBuilder();
70+
$layout = new LayoutResult(lines: [], images: []);
71+
$startedAtNs = hrtime(true) - 2_000_000;
72+
73+
$metadata = $builder->buildMetadata($layout, $startedAtNs);
74+
75+
self::assertSame(0, $metadata['line_count']);
76+
self::assertSame(0, $metadata['image_count']);
77+
self::assertSame(0, $metadata['node_count']);
78+
self::assertGreaterThan(0.5, $metadata['render_ms']);
79+
self::assertLessThan(1000.0, $metadata['render_ms']);
80+
}
6081
}

tests/Unit/XObjectTemplateCompilerTest.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
namespace LibreSign\XObjectTemplate\Tests\Unit;
99

1010
use LibreSign\XObjectTemplate\Dto\CompileRequest;
11+
use LibreSign\XObjectTemplate\Pdf\TemplateDocumentBuilder;
1112
use LibreSign\XObjectTemplate\XObjectTemplateCompiler;
1213
use PHPUnit\Framework\Attributes\DataProvider;
1314
use PHPUnit\Framework\TestCase;
@@ -62,4 +63,21 @@ public static function htmlProvider(): iterable
6263
'expectedSnippet' => '20.000000 48.000000 Td',
6364
];
6465
}
66+
67+
public function testCompileUsesProvidedTemplateDocumentBuilderInstance(): void
68+
{
69+
$builder = (new TemplateDocumentBuilder())->withFontResources([
70+
'Z1' => [
71+
'Type' => '/Font',
72+
'Subtype' => '/Type1',
73+
'BaseFont' => '/Helvetica',
74+
],
75+
]);
76+
$compiler = new XObjectTemplateCompiler(null, null, null, null, $builder);
77+
78+
$result = $compiler->compile(new CompileRequest(html: '<p>Hello</p>'));
79+
80+
self::assertArrayHasKey('Z1', $result->resources['Font']);
81+
self::assertArrayNotHasKey('F1', $result->resources['Font']);
82+
}
6583
}

0 commit comments

Comments
 (0)