Skip to content

Commit 362d9ae

Browse files
committed
fix(svg): harden transform/path mutation scenarios
Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com>
1 parent ea7719a commit 362d9ae

3 files changed

Lines changed: 149 additions & 12 deletions

File tree

src/Pdf/Svg/SvgPathCommandParser.php

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ private function processPathTokens(array $tokens, PathParsingState $state, PathC
8383

8484
while ($index < $tokenCount) {
8585
$token = $tokens[$index];
86-
if (preg_match('/^[A-Za-z]$/', $token) === 1) {
86+
if ($this->isCommandToken($token)) {
8787
$currentCommand = $token;
8888
++$index;
8989
}
@@ -174,7 +174,7 @@ private function handleMoveCommand(
174174
$state->lastCubicControlX = null;
175175
$state->lastCubicControlY = null;
176176

177-
while ($index < $tokenCount && preg_match('/^[A-Za-z]$/', $tokens[$index]) !== 1) {
177+
while ($index < $tokenCount && !$this->isCommandToken($tokens[$index])) {
178178
$coordinates = $this->pathNumberReader->readPathNumbers($tokens, $index, 2, $context->source);
179179
$nextX = $this->resolveCoord($isRelative, $state->currentX, $coordinates[0]);
180180
$nextY = $this->resolveCoord($isRelative, $state->currentY, $coordinates[1]);
@@ -195,7 +195,7 @@ private function handleLineCommand(
195195
PathParsingState $state,
196196
PathCommandContext $context,
197197
): void {
198-
while ($index < $tokenCount && preg_match('/^[A-Za-z]$/', $tokens[$index]) !== 1) {
198+
while ($index < $tokenCount && !$this->isCommandToken($tokens[$index])) {
199199
$coordinates = $this->pathNumberReader->readPathNumbers($tokens, $index, 2, $context->source);
200200
$nextX = $this->resolveCoord($isRelative, $state->currentX, $coordinates[0]);
201201
$nextY = $this->resolveCoord($isRelative, $state->currentY, $coordinates[1]);
@@ -214,7 +214,7 @@ private function handleHorizontalCommand(
214214
PathParsingState $state,
215215
PathCommandContext $context,
216216
): void {
217-
while ($index < $tokenCount && preg_match('/^[A-Za-z]$/', $tokens[$index]) !== 1) {
217+
while ($index < $tokenCount && !$this->isCommandToken($tokens[$index])) {
218218
$coordinates = $this->pathNumberReader->readPathNumbers($tokens, $index, 1, $context->source);
219219
$state->currentX = $this->resolveCoord($isRelative, $state->currentX, $coordinates[0]);
220220
[$lineX, $lineY] = $this->transformResolver->applyTransformToPoint(
@@ -241,7 +241,7 @@ private function handleVerticalCommand(
241241
PathParsingState $state,
242242
PathCommandContext $context,
243243
): void {
244-
while ($index < $tokenCount && preg_match('/^[A-Za-z]$/', $tokens[$index]) !== 1) {
244+
while ($index < $tokenCount && !$this->isCommandToken($tokens[$index])) {
245245
$coordinates = $this->pathNumberReader->readPathNumbers($tokens, $index, 1, $context->source);
246246
$state->currentY = $this->resolveCoord($isRelative, $state->currentY, $coordinates[0]);
247247
[$lineX, $lineY] = $this->transformResolver->applyTransformToPoint(
@@ -268,7 +268,7 @@ private function handleCubicCommand(
268268
PathParsingState $state,
269269
PathCommandContext $context,
270270
): void {
271-
while ($index < $tokenCount && preg_match('/^[A-Za-z]$/', $tokens[$index]) !== 1) {
271+
while ($index < $tokenCount && !$this->isCommandToken($tokens[$index])) {
272272
$coordinates = $this->pathNumberReader->readPathNumbers($tokens, $index, 6, $context->source);
273273
$startX1 = $this->resolveCoord($isRelative, $state->currentX, $coordinates[0]);
274274
$startY1 = $this->resolveCoord($isRelative, $state->currentY, $coordinates[1]);
@@ -308,7 +308,7 @@ private function handleSmoothCubicCommand(
308308
PathParsingState $state,
309309
PathCommandContext $context,
310310
): void {
311-
while ($index < $tokenCount && preg_match('/^[A-Za-z]$/', $tokens[$index]) !== 1) {
311+
while ($index < $tokenCount && !$this->isCommandToken($tokens[$index])) {
312312
$coordinates = $this->pathNumberReader->readPathNumbers($tokens, $index, 4, $context->source);
313313
$startX1 = $this->reflectControlPoint($state->lastCubicControlX, $state->currentX);
314314
$startY1 = $this->reflectControlPoint($state->lastCubicControlY, $state->currentY);
@@ -348,7 +348,7 @@ private function handleQuadraticCommand(
348348
PathParsingState $state,
349349
PathCommandContext $context,
350350
): void {
351-
while ($index < $tokenCount && preg_match('/^[A-Za-z]$/', $tokens[$index]) !== 1) {
351+
while ($index < $tokenCount && !$this->isCommandToken($tokens[$index])) {
352352
$coordinates = $this->pathNumberReader->readPathNumbers($tokens, $index, 4, $context->source);
353353
$qcpX = $this->resolveCoord($isRelative, $state->currentX, $coordinates[0]);
354354
$qcpY = $this->resolveCoord($isRelative, $state->currentY, $coordinates[1]);
@@ -390,7 +390,7 @@ private function handleSmoothQuadraticCommand(
390390
PathParsingState $state,
391391
PathCommandContext $context,
392392
): void {
393-
while ($index < $tokenCount && preg_match('/^[A-Za-z]$/', $tokens[$index]) !== 1) {
393+
while ($index < $tokenCount && !$this->isCommandToken($tokens[$index])) {
394394
$coordinates = $this->pathNumberReader->readPathNumbers($tokens, $index, 2, $context->source);
395395
$qcpX = $this->reflectControlPoint($state->prevQuadCpX, $state->currentX);
396396
$qcpY = $this->reflectControlPoint($state->prevQuadCpY, $state->currentY);
@@ -431,7 +431,7 @@ private function handleArcCommand(
431431
PathParsingState $state,
432432
PathCommandContext $context,
433433
): void {
434-
while ($index < $tokenCount && preg_match('/^[A-Za-z]$/', $tokens[$index]) !== 1) {
434+
while ($index < $tokenCount && !$this->isCommandToken($tokens[$index])) {
435435
$coordinates = $this->pathNumberReader->readPathNumbers($tokens, $index, 7, $context->source);
436436
$radiusX = abs($coordinates[0]);
437437
$radiusY = abs($coordinates[1]);
@@ -508,6 +508,11 @@ private function resolveCoord(bool $isRelative, float $current, float $coord): f
508508
return $isRelative ? $current + $coord : $coord;
509509
}
510510

511+
private function isCommandToken(string $token): bool
512+
{
513+
return strlen($token) === 1 && ctype_alpha($token);
514+
}
515+
511516
/**
512517
* Reflect a previous control point over the current position.
513518
* Returns current position when no prior control point exists (SVG spec default).

src/Pdf/Svg/SvgTransformResolver.php

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
*/
1919
final class SvgTransformResolver
2020
{
21+
private const MAX_ANCESTOR_DEPTH = 2048;
22+
2123
/**
2224
* Compute the cumulative transform matrix for an element.
2325
*
@@ -33,7 +35,11 @@ public function resolveElementTransformMatrix(DOMElement $element): array
3335
$ancestors = [];
3436
$cursor = $element;
3537

36-
while ($cursor instanceof DOMElement) {
38+
for ($depth = 0; $depth < self::MAX_ANCESTOR_DEPTH; ++$depth) {
39+
if (!$cursor instanceof DOMElement) {
40+
break;
41+
}
42+
3743
$ancestors[] = $cursor;
3844
$cursor = $cursor->parentNode;
3945
}
@@ -79,7 +85,7 @@ private function parseTransformList(string $transform): array
7985

8086
if (
8187
preg_match_all(
82-
'/(matrix|translate|scale|rotate|skewX|skewY)\s*\(([^)]*)\)/i',
88+
'/([a-zA-Z]+)\s*\(([^)]*)\)/',
8389
$transform,
8490
$matches,
8591
PREG_SET_ORDER,

tests/Unit/Pdf/Svg/SvgPdfXObjectFactoryTest.php

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,132 @@ public function testCreateWithNonSvgRootElementThrows(): void
292292
$factory->create('<?xml version="1.0"?><root></root>', '/tmp/wrong-root.svg');
293293
}
294294

295+
public function testCreateAcceptsUppercaseSvgRootElementName(): void
296+
{
297+
$factory = new SvgPdfXObjectFactory();
298+
299+
$xObject = $factory->create(
300+
'<SVG width="10" height="10" xmlns="http://www.w3.org/2000/svg">'
301+
. '<rect x="0" y="0" width="10" height="10" fill="#ff0000"/>'
302+
. '</SVG>',
303+
'/tmp/uppercase-root.svg',
304+
);
305+
306+
self::assertSame([0.0, 0.0, 10.0, 10.0], $xObject->dictionary['BBox']);
307+
self::assertStringContainsString('1 0 0 rg', $xObject->stream);
308+
}
309+
310+
public function testCreateRestoresLibxmlInternalErrorStateAfterParsing(): void
311+
{
312+
$factory = new SvgPdfXObjectFactory();
313+
314+
$previousSetting = libxml_use_internal_errors(false);
315+
316+
try {
317+
$factory->create(
318+
'<svg width="10" height="10" xmlns="http://www.w3.org/2000/svg">'
319+
. '<rect x="0" y="0" width="10" height="10" fill="#000"/>'
320+
. '</svg>',
321+
'/tmp/libxml-state.svg',
322+
);
323+
324+
self::assertFalse(libxml_use_internal_errors());
325+
} finally {
326+
libxml_use_internal_errors($previousSetting);
327+
libxml_clear_errors();
328+
}
329+
}
330+
331+
public function testCreateRespectsViewBoxMinXAndMinYOriginsWhenBuildingPaths(): void
332+
{
333+
$factory = new SvgPdfXObjectFactory();
334+
335+
$xObject = $factory->create(
336+
<<<'SVG'
337+
<svg viewBox="3 4 10 6" xmlns="http://www.w3.org/2000/svg">
338+
<path fill="#000" d="M 3 4 L 13 10"/>
339+
</svg>
340+
SVG,
341+
'/tmp/viewbox-origin.svg',
342+
);
343+
344+
self::assertSame([0.0, 0.0, 10.0, 6.0], $xObject->dictionary['BBox']);
345+
self::assertStringContainsString('0.000000 6.000000 m', $xObject->stream);
346+
self::assertStringContainsString('10.000000 0.000000 l', $xObject->stream);
347+
}
348+
349+
public function testCreateRejectsDimensionWithNonNumericPrefix(): void
350+
{
351+
$factory = new SvgPdfXObjectFactory();
352+
353+
$this->expectException(InvalidArgumentException::class);
354+
$this->expectExceptionMessage(
355+
'SVG source "/tmp/non-numeric-dimension.svg" must define either a valid viewBox or positive width/height.',
356+
);
357+
358+
$factory->create(
359+
'<svg width="abc12" height="10" xmlns="http://www.w3.org/2000/svg">'
360+
. '<path fill="#000" d="M0,0 L1,1"/>'
361+
. '</svg>',
362+
'/tmp/non-numeric-dimension.svg',
363+
);
364+
}
365+
366+
public function testCreateRejectsZeroWidthWhenNoViewBoxIsProvided(): void
367+
{
368+
$factory = new SvgPdfXObjectFactory();
369+
370+
$this->expectException(InvalidArgumentException::class);
371+
$this->expectExceptionMessage(
372+
'SVG source "/tmp/zero-width.svg" must define either a valid viewBox or positive width/height.',
373+
);
374+
375+
$factory->create(
376+
'<svg width="0" height="10" xmlns="http://www.w3.org/2000/svg">'
377+
. '<path fill="#000" d="M0,0 L1,1"/>'
378+
. '</svg>',
379+
'/tmp/zero-width.svg',
380+
);
381+
}
382+
383+
public function testCreateRejectsZeroHeightWhenNoViewBoxIsProvided(): void
384+
{
385+
$factory = new SvgPdfXObjectFactory();
386+
387+
$this->expectException(InvalidArgumentException::class);
388+
$this->expectExceptionMessage(
389+
'SVG source "/tmp/zero-height.svg" must define either a valid viewBox or positive width/height.',
390+
);
391+
392+
$factory->create(
393+
'<svg width="10" height="0" xmlns="http://www.w3.org/2000/svg">'
394+
. '<path fill="#000" d="M0,0 L1,1"/>'
395+
. '</svg>',
396+
'/tmp/zero-height.svg',
397+
);
398+
}
399+
400+
public function testCreateResolvesUppercaseCssFillAndStrokePropertiesFromStyleBlock(): void
401+
{
402+
$factory = new SvgPdfXObjectFactory();
403+
404+
$xObject = $factory->create(
405+
<<<'SVG'
406+
<svg width="12" height="12" xmlns="http://www.w3.org/2000/svg">
407+
<style>
408+
.box { FILL: #112233; STROKE: #ff0000; }
409+
</style>
410+
<rect class="box" x="1" y="1" width="10" height="10" style="stroke-width:2"/>
411+
</svg>
412+
SVG,
413+
'/tmp/uppercase-css-style.svg',
414+
);
415+
416+
self::assertStringContainsString('0.0667 0.1333 0.2 rg', $xObject->stream);
417+
self::assertStringContainsString('1 0 0 RG', $xObject->stream);
418+
self::assertStringContainsString('2.000000 w', $xObject->stream);
419+
}
420+
295421
public function testCreateWithMissingDimensionsOrViewBoxThrows(): void
296422
{
297423
$factory = new SvgPdfXObjectFactory();

0 commit comments

Comments
 (0)