Skip to content

Commit 5df6298

Browse files
committed
chore: increase coverage
Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com>
1 parent 4563d12 commit 5df6298

4 files changed

Lines changed: 348 additions & 23 deletions

File tree

src/Pdf/Svg/SvgColorResolver.php

Lines changed: 77 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
namespace LibreSign\XObjectTemplate\Pdf\Svg;
99

10+
use function array_filter;
11+
use function array_map;
1012
use DOMElement;
1113

1214
/**
@@ -92,10 +94,7 @@ public function resolveColorAttribute(
9294
}
9395

9496
// Check CSS classes
95-
$classes = preg_split('/\s+/', trim($element->getAttribute('class')), -1, PREG_SPLIT_NO_EMPTY);
96-
if ($classes === false) {
97-
$classes = [];
98-
}
97+
$classes = $this->extractClasses($element->getAttribute('class'));
9998

10099
foreach ($classes as $class) {
101100
if (isset($classColors[$class])) {
@@ -174,15 +173,18 @@ public function extractValueFromStyleAttribute(string $style, string $property):
174173
}
175174

176175
foreach ($declarations as $declaration) {
177-
$declaration = trim($declaration);
178-
if ($declaration === '') {
176+
if (trim($declaration) === '') {
179177
continue;
180178
}
181179

182-
if (
183-
preg_match('/^' . preg_quote($property) . '\s*:\s*(.+)$/i', $declaration, $matches) === 1
184-
) {
185-
return trim($matches[1]);
180+
$parts = explode(':', $declaration, 2);
181+
if (count($parts) !== 2) {
182+
continue;
183+
}
184+
185+
[$candidateProperty, $candidateValue] = array_map(trim(...), $parts);
186+
if (strcasecmp($candidateProperty, $property) === 0) {
187+
return $candidateValue;
186188
}
187189
}
188190

@@ -209,16 +211,13 @@ public function normalizeColor(string $color): ?string
209211
return 'none';
210212
}
211213

212-
if (preg_match('/^#[0-9a-f]{3}([0-9a-f]{3})?$/i', $trimmed) === 1) {
214+
if ($this->isHexColor($trimmed)) {
213215
return $trimmed;
214216
}
215217

216-
if (preg_match('/^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/', $trimmed, $matches) === 1) {
217-
$red = max(0, min(255, (int) $matches[1]));
218-
$green = max(0, min(255, (int) $matches[2]));
219-
$blue = max(0, min(255, (int) $matches[3]));
220-
221-
return sprintf('#%02x%02x%02x', $red, $green, $blue);
218+
$rgb = $this->parseRgbColor($trimmed);
219+
if ($rgb !== null) {
220+
return sprintf('#%02x%02x%02x', $rgb[0], $rgb[1], $rgb[2]);
222221
}
223222

224223
return match ($trimmed) {
@@ -232,4 +231,65 @@ public function normalizeColor(string $color): ?string
232231
default => null,
233232
};
234233
}
234+
235+
/**
236+
* @return list<string>
237+
*/
238+
private function extractClasses(string $classAttribute): array
239+
{
240+
$normalized = preg_replace('/\s+/', ' ', trim($classAttribute));
241+
if (!is_string($normalized) || $normalized === '') {
242+
return [];
243+
}
244+
245+
return array_values(array_filter(explode(' ', $normalized), static fn (string $class): bool => $class !== ''));
246+
}
247+
248+
private function isHexColor(string $color): bool
249+
{
250+
if (!str_starts_with($color, '#')) {
251+
return false;
252+
}
253+
254+
$hex = substr($color, 1);
255+
$length = strlen($hex);
256+
257+
return ($length === 3 || $length === 6) && ctype_xdigit($hex);
258+
}
259+
260+
/**
261+
* @return array{0: int, 1: int, 2: int}|null
262+
*/
263+
private function parseRgbColor(string $color): ?array
264+
{
265+
if (!str_starts_with($color, 'rgb(') || !str_ends_with($color, ')')) {
266+
return null;
267+
}
268+
269+
$parts = array_map(
270+
static fn (string $part): string => trim($part),
271+
explode(',', substr($color, 4, -1)),
272+
);
273+
274+
if (count($parts) !== 3) {
275+
return null;
276+
}
277+
278+
$channels = [];
279+
280+
foreach ($parts as $part) {
281+
if ($part === '' || preg_match('/^\d+$/', $part) !== 1) {
282+
return null;
283+
}
284+
285+
$channel = filter_var($part, FILTER_VALIDATE_INT);
286+
if (!is_int($channel) || $channel < 0) {
287+
return null;
288+
}
289+
290+
$channels[] = min(255, $channel);
291+
}
292+
293+
return [$channels[0], $channels[1], $channels[2]];
294+
}
235295
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
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\Pdf\Svg;
9+
10+
use LibreSign\XObjectTemplate\Pdf\Svg\SvgArcConverter;
11+
use PHPUnit\Framework\Attributes\DataProvider;
12+
use PHPUnit\Framework\TestCase;
13+
14+
final class SvgArcConverterTest extends TestCase
15+
{
16+
public function testArcToBezierCurvesReturnsEmptyArrayWhenStartAndEndPointsMatch(): void
17+
{
18+
$converter = new SvgArcConverter();
19+
20+
self::assertSame([], $converter->arcToBezierCurves(10.0, 10.0, 5.0, 6.0, 30.0, 0, 1, 10.0, 10.0));
21+
}
22+
23+
public function testArcToBezierCurvesFallsBackToDegenerateLineWhenAnyRadiusIsZero(): void
24+
{
25+
$converter = new SvgArcConverter();
26+
27+
self::assertSame(
28+
[[20.0, 30.0, 20.0, 30.0, 20.0, 30.0]],
29+
$converter->arcToBezierCurves(0.0, 0.0, 0.0, 5.0, 0.0, 0, 1, 20.0, 30.0),
30+
);
31+
}
32+
33+
#[DataProvider('provideArcScenarios')]
34+
public function testArcToBezierCurvesGeneratesExpectedCurveShape(
35+
float $fromX,
36+
float $fromY,
37+
float $radiusX,
38+
float $radiusY,
39+
float $rotation,
40+
int $largeArc,
41+
int $sweep,
42+
float $toX,
43+
float $toY,
44+
int $expectedSegmentCount,
45+
): void {
46+
$converter = new SvgArcConverter();
47+
48+
$curves = $converter->arcToBezierCurves(
49+
$fromX,
50+
$fromY,
51+
$radiusX,
52+
$radiusY,
53+
$rotation,
54+
$largeArc,
55+
$sweep,
56+
$toX,
57+
$toY,
58+
);
59+
60+
self::assertCount($expectedSegmentCount, $curves);
61+
62+
foreach ($curves as $curve) {
63+
self::assertCount(6, $curve);
64+
}
65+
66+
$lastCurve = $curves[array_key_last($curves)];
67+
self::assertEqualsWithDelta($toX, $lastCurve[4], 0.0001);
68+
self::assertEqualsWithDelta($toY, $lastCurve[5], 0.0001);
69+
}
70+
71+
/**
72+
* @return iterable<string, array{fromX: float, fromY: float, radiusX: float, radiusY: float, rotation: float, largeArc: int, sweep: int, toX: float, toY: float, expectedSegmentCount: int}>
73+
*/
74+
public static function provideArcScenarios(): iterable
75+
{
76+
yield 'symmetric half ellipse arc' => [
77+
'fromX' => 0.0,
78+
'fromY' => 5.0,
79+
'radiusX' => 10.0,
80+
'radiusY' => 5.0,
81+
'rotation' => 0.0,
82+
'largeArc' => 0,
83+
'sweep' => 1,
84+
'toX' => 20.0,
85+
'toY' => 5.0,
86+
'expectedSegmentCount' => 2,
87+
];
88+
89+
yield 'rotated arc uses multiple segments' => [
90+
'fromX' => 0.0,
91+
'fromY' => 0.0,
92+
'radiusX' => 40.0,
93+
'radiusY' => 20.0,
94+
'rotation' => 45.0,
95+
'largeArc' => 1,
96+
'sweep' => 1,
97+
'toX' => 60.0,
98+
'toY' => 0.0,
99+
'expectedSegmentCount' => 2,
100+
];
101+
102+
yield 'undersized radii are normalized to still produce a curve' => [
103+
'fromX' => 0.0,
104+
'fromY' => 0.0,
105+
'radiusX' => 5.0,
106+
'radiusY' => 5.0,
107+
'rotation' => 0.0,
108+
'largeArc' => 0,
109+
'sweep' => 1,
110+
'toX' => 30.0,
111+
'toY' => 0.0,
112+
'expectedSegmentCount' => 2,
113+
];
114+
}
115+
}

tests/Unit/Pdf/Svg/SvgColorResolverTest.php

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use LibreSign\XObjectTemplate\Pdf\Svg\SvgColorResolver;
1313
use PHPUnit\Framework\Attributes\DataProvider;
1414
use PHPUnit\Framework\TestCase;
15+
use ReflectionMethod;
1516

1617
final class SvgColorResolverTest extends TestCase
1718
{
@@ -41,7 +42,7 @@ public function testResolveFillColorFallsBackToStyleClassAncestorAndDefault(): v
4142
self::assertSame('rgb(10,20,30)', $resolver->resolveFillColor($styleElement, []));
4243

4344
$classElement = $this->createElement('path', [
44-
'class' => 'primary secondary',
45+
'class' => ' primary secondary ',
4546
]);
4647
self::assertSame('#112233', $resolver->resolveFillColor($classElement, ['secondary' => '#112233']));
4748

@@ -93,27 +94,49 @@ public function testExtractValueFromStyleAttributeReturnsRequestedProperty(
9394
self::assertSame($expected, $result);
9495
}
9596

97+
public function testResolveColorAttributeRemainsPublicForFactoryCollaborators(): void
98+
{
99+
$method = new ReflectionMethod(SvgColorResolver::class, 'resolveColorAttribute');
100+
101+
self::assertTrue($method->isPublic());
102+
}
103+
96104
/**
97105
* @return iterable<string, array{style: string, property: string, expected: ?string, useColorExtractor?: bool}>
98106
*/
99107
public static function provideExtractValueFromStyleAttributeScenarios(): iterable
100108
{
101-
yield 'extract stroke-width from style' => [
109+
yield 'extract generic property' => [
102110
'style' => 'fill:#fff; stroke-width: 2.5 ;',
103111
'property' => 'stroke-width',
104112
'expected' => '2.5',
105113
];
114+
106115
yield 'extract fill color case-insensitive' => [
107116
'style' => ' FiLl : #fff ; ',
108117
'property' => 'fill',
109118
'expected' => '#fff',
110119
'useColorExtractor' => true,
111120
];
121+
122+
yield 'extract dotted property name' => [
123+
'style' => 'fill.opacity: 0.5',
124+
'property' => 'fill.opacity',
125+
'expected' => '0.5',
126+
];
127+
128+
yield 'extract value containing colon characters' => [
129+
'style' => 'fill:url(http://example.com/a:b.svg)',
130+
'property' => 'fill',
131+
'expected' => 'url(http://example.com/a:b.svg)',
132+
];
133+
112134
yield 'empty style returns null' => [
113135
'style' => '',
114136
'property' => 'fill',
115137
'expected' => null,
116138
];
139+
117140
yield 'missing property returns null' => [
118141
'style' => 'stroke:#000',
119142
'property' => 'fill',
@@ -138,10 +161,29 @@ public static function provideNormalizeColorScenarios(): iterable
138161
yield 'none sentinel' => ['input' => 'none', 'expected' => 'none'];
139162
yield 'short hex' => ['input' => '#abc', 'expected' => '#abc'];
140163
yield 'long hex uppercased and trimmed' => ['input' => ' #AABBCC ', 'expected' => '#aabbcc'];
141-
yield 'rgb notation with clamping' => ['input' => 'rgb(300,-1,12)', 'expected' => null];
164+
yield 'hex with invalid prefix' => ['input' => 'x#abc', 'expected' => null];
165+
yield 'hex with invalid suffix' => ['input' => '#abcx', 'expected' => null];
166+
yield 'hex with invalid medium length' => ['input' => '#1234', 'expected' => null];
167+
yield 'rgb notation rejects negative channel' => ['input' => 'rgb(300,-1,12)', 'expected' => null];
142168
yield 'rgb notation valid' => ['input' => 'rgb(255, 0, 12)', 'expected' => '#ff000c'];
169+
yield 'rgb notation clamps overflowing red' => ['input' => 'rgb(256,0,0)', 'expected' => '#ff0000'];
170+
yield 'rgb notation preserves max green' => ['input' => 'rgb(0,255,0)', 'expected' => '#00ff00'];
171+
yield 'rgb notation clamps overflowing green' => ['input' => 'rgb(0,256,0)', 'expected' => '#00ff00'];
172+
yield 'rgb notation preserves zero blue' => ['input' => 'rgb(0,0,0)', 'expected' => '#000000'];
173+
yield 'rgb notation clamps overflowing blue' => ['input' => 'rgb(0,0,256)', 'expected' => '#0000ff'];
174+
yield 'rgb notation rejects prefixed content' => ['input' => 'prefix rgb(255,0,12)', 'expected' => null];
175+
yield 'rgb notation rejects suffixed content' => ['input' => 'rgb(255,0,12) suffix', 'expected' => null];
176+
yield 'rgb notation rejects missing closing parenthesis' => ['input' => 'rgb(255,0,12', 'expected' => null];
177+
yield 'rgb notation rejects missing opening marker' => ['input' => '255,0,12)', 'expected' => null];
178+
yield 'rgb notation rejects contaminated channel' => ['input' => 'rgb(12x,0,0)', 'expected' => null];
179+
yield 'named black' => ['input' => 'black', 'expected' => '#000000'];
180+
yield 'named white' => ['input' => 'white', 'expected' => '#ffffff'];
181+
yield 'named red' => ['input' => 'red', 'expected' => '#ff0000'];
182+
yield 'named green' => ['input' => 'green', 'expected' => '#008000'];
143183
yield 'named gray alias' => ['input' => 'grey', 'expected' => '#808080'];
184+
yield 'named gray canonical' => ['input' => 'gray', 'expected' => '#808080'];
144185
yield 'named blue' => ['input' => 'blue', 'expected' => '#0000ff'];
186+
yield 'named yellow' => ['input' => 'yellow', 'expected' => '#ffff00'];
145187
yield 'invalid color' => ['input' => 'chartreuse-ish', 'expected' => null];
146188
}
147189

0 commit comments

Comments
 (0)