Files
2026-08-20 11:41:17 +00:00

7716 lines
274 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* SVGTest.php
*
* @since 2002-08-03
* @category Library
* @package Pdf
* @author Nicola Asuni <info@tecnick.com>
* @copyright 2002-2026 Nicola Asuni - Tecnick.com LTD
* @license https://www.gnu.org/copyleft/lesser.html GNU-LGPL v3 (see LICENSE)
* @link https://github.com/tecnickcom/tc-lib-pdf
*
* This file is part of tc-lib-pdf software library.
*/
namespace Test;
class SVGTest extends TestUtil
{
public static function setUpBeforeClass(): void
{
self::setUpFontsPath();
}
/** @throws \Throwable */
protected function getTestObject(): \Com\Tecnick\Pdf\Tcpdf
{
return new \Com\Tecnick\Pdf\Tcpdf();
}
/** @throws \Throwable */
protected function getInternalTestObject(): TestableSVG
{
return new TestableSVG();
}
/** @throws \Throwable */
public function testAddSVGStoresInlineSvgObject(): void
{
$obj = $this->getTestObject();
$page = $this->initFontAndPage($obj);
$svg =
'@<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50">'
. '<rect x="0" y="0" width="100" height="50" fill="#ff0000"/>'
. '</svg>';
$soid = $obj->addSVG($svg, 10, 20, 40, 20, $page['height']);
$this->assertSame(1, $soid);
/** @var array<int, array<string, mixed>> $svgobjs */
$svgobjs = $this->getObjectProperty($obj, 'svgobjs');
$this->assertArrayHasKey($soid, $svgobjs);
$svgObj = $svgobjs[$soid] ?? null;
$this->assertIsArray($svgObj);
$this->assertNotSame('', $svgObj['out'] ?? '');
}
/** @throws \Throwable */
public function testAddSVGRejectsInvalidSvg(): void
{
$obj = $this->getTestObject();
$this->initFontAndPage($obj);
$this->bcExpectException(\Com\Tecnick\Pdf\Exception::class);
$obj->addSVG('@<svg xmlns="http://www.w3.org/2000/svg"></svg>');
}
/** @throws \Throwable */
public function testGetSetSVGReturnsRenderableOutput(): void
{
$obj = $this->getTestObject();
$page = $this->initFontAndPage($obj);
$svg =
'@<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20">'
. '<circle cx="10" cy="10" r="8" fill="#00ff00"/>'
. '</svg>';
$soid = $obj->addSVG($svg, 5, 6, 12, 12, $page['height']);
$out = $obj->getSetSVG($soid);
$this->assertNotSame('', $out);
$this->assertStringStartsWith("q\n", $out);
$this->assertStringContainsString(" cm\n", $out);
$this->assertStringEndsWith("Q\nQ\n", $out);
}
/** @throws \Throwable */
public function testSvgPatternFillEmitsPdfPatternResources(): void
{
$obj = new \Com\Tecnick\Pdf\Tcpdf('mm', true, false, false);
$page = $this->initFontAndPage($obj);
$svg =
'@<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20">'
. '<defs>'
. '<pattern id="p1" patternUnits="userSpaceOnUse" x="0" y="0" width="4" height="4">'
. '<rect x="0" y="0" width="4" height="4" fill="none" stroke="#000000" stroke-width="1"/>'
. '</pattern>'
. '</defs>'
. '<rect x="0" y="0" width="20" height="20" fill="url(#p1)"/>'
. '</svg>';
$soid = $obj->addSVG($svg, 5, 6, 12, 12, $page['height']);
$svgOut = $obj->getSetSVG($soid);
$pdf = $obj->getOutPDFString();
$this->assertStringContainsString('/Pattern cs /PTN_', $svgOut);
$this->assertStringContainsString(' scn', $svgOut);
$this->assertStringContainsString('/Type /Pattern', $pdf);
$this->assertStringContainsString('/Pattern <<', $pdf);
$this->assertStringContainsString('/Type /Pattern /PatternType 1', $pdf);
$this->assertStringContainsString('/Resources << /ProcSet [/PDF /Text /ImageB /ImageC /ImageI]', $pdf);
$this->assertMatchesRegularExpression('/\/PTN_[A-F0-9]{16}\s+\d+\s+0\s+R/', $pdf);
}
/** @throws \Throwable */
public function testGetSetSVGThrowsForUnknownId(): void
{
$obj = $this->getTestObject();
$this->bcExpectException(\Com\Tecnick\Pdf\Exception::class);
$obj->getSetSVG(999);
}
/** @throws \Throwable */
public function testParseSVGTMtranslateWithTwoValues(): void
{
$obj = $this->getInternalTestObject();
$result = $obj->exposeParseSVGTMtranslate('10.5, 20.3');
$this->assertSame([1.0, 0.0, 0.0, 1.0, 10.5, 20.3], $result);
}
/** @throws \Throwable */
public function testParseSVGTMtranslateWithOneValue(): void
{
$obj = $this->getInternalTestObject();
$result = $obj->exposeParseSVGTMtranslate('5.5');
$this->assertSame([1.0, 0.0, 0.0, 1.0, 5.5, 0.0], $result);
}
/** @throws \Throwable */
public function testParseSVGTMscaleWithTwoValues(): void
{
$obj = $this->getInternalTestObject();
$result = $obj->exposeParseSVGTMscale('2.0, 3.0');
$this->assertSame([2.0, 0.0, 0.0, 3.0, 0.0, 0.0], $result);
}
/** @throws \Throwable */
public function testParseSVGTMscaleWithOneValue(): void
{
$obj = $this->getInternalTestObject();
$result = $obj->exposeParseSVGTMscale('1.5');
$this->assertSame([1.5, 0.0, 0.0, 1.5, 0.0, 0.0], $result);
}
/** @throws \Throwable */
public function testParseSVGTMrotateWithDegrees(): void
{
$obj = $this->getInternalTestObject();
$result = $obj->exposeParseSVGTMrotate('45');
$this->assertCount(6, $result);
// Rotation matrix has cos and sin components that can be floating point
$this->assertNotSame([1.0, 0.0, 0.0, 1.0, 0.0, 0.0], $result);
}
/** @throws \Throwable */
public function testParseSVGTMskewXWithDegrees(): void
{
$obj = $this->getInternalTestObject();
$result = $obj->exposeParseSVGTMskewX('30');
$this->assertCount(6, $result);
$this->assertNotSame([1.0, 0.0, 0.0, 1.0, 0.0, 0.0], $result);
}
/** @throws \Throwable */
public function testParseSVGTMskewYWithDegrees(): void
{
$obj = $this->getInternalTestObject();
$result = $obj->exposeParseSVGTMskewY('30');
$this->assertCount(6, $result);
$this->assertNotSame([1.0, 0.0, 0.0, 1.0, 0.0, 0.0], $result);
}
/** @throws \Throwable */
public function testParseSVGTMmatrixWithSixValues(): void
{
$obj = $this->getInternalTestObject();
$result = $obj->exposeParseSVGTMmatrix('1 2 3 4 5 6');
$this->assertSame([1.0, 2.0, 3.0, 4.0, 5.0, 6.0], $result);
}
/** @throws \Throwable */
public function testParseSVGTMmatrixWithCSVFormat(): void
{
$obj = $this->getInternalTestObject();
$result = $obj->exposeParseSVGTMmatrix('1.5, 2.5, 3.5, 4.5, 5.5, 6.5');
$this->assertSame([1.5, 2.5, 3.5, 4.5, 5.5, 6.5], $result);
}
/** @throws \Throwable */
public function testSvgUnitConvertersAndTransformHelpers(): void
{
$obj = $this->getInternalTestObject();
$obj->setSvgRefUnit(1);
$this->assertSame(72.0, $obj->exposeSvgUnitToPoints('1in', 1));
$this->assertEqualsWithDelta(25.4, $obj->exposeSvgUnitToUnit('25.4mm', 1), 0.0001);
$transformMatrix = $obj->exposeGetSVGTransformMatrix('translate(10,20) scale(2) rotate(0)');
$this->assertCount(6, $transformMatrix);
$converted = $obj->exposeConvertSVGMatrix([1, 0, 0, 1, 10, 20], 1);
$this->assertCount(6, $converted);
$this->assertSame(7.5, $converted[4]);
$this->assertSame(-15.0, $converted[5]);
$out = $obj->exposeGetOutSVGTransformation([1, 0, 0, 1, 10, 20], 1);
$this->assertStringContainsString(' cm', $out);
}
/** @throws \Throwable */
public function testSvgTagPathAndCssAttributeHelpers(): void
{
$obj = $this->getInternalTestObject();
$obj->setSvgRefUnit(2);
$this->assertSame('rect', $obj->exposeRemoveTagNamespace('svg:rect'));
$this->assertSame('', $obj->exposeGetSVGPath(2, 'M 0 0 L 10 10 Z', ''));
$path = $obj->exposeGetSVGPath(2, 'M 0 0 L 10 10 H 5 V 3 Z', 'S');
$this->assertStringContainsString('m', $path);
$this->assertStringContainsString('l', $path);
$this->assertStringContainsString("h\n", $path);
$tag = 'font-size: 12px; font-weight: bold;';
$this->assertSame('12px', $obj->exposeParseCSSAttrib($tag, 'font-size', '10px'));
$this->assertSame('fallback', $obj->exposeParseCSSAttrib($tag, 'line-height', 'fallback'));
}
/** @throws \Throwable */
public function testSvgHelperNormalizationAndClipDispatchBranches(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->setSvgRefUnit(4);
$this->assertSame(4.0, $obj->exposeNormalizeSVGStyleFloatValue(4, 1.5));
$this->assertSame(2.5, $obj->exposeNormalizeSVGStyleFloatValue(2.5, 1.5));
$this->assertSame(3.75, $obj->exposeNormalizeSVGStyleFloatValue(' 3.75 ', 1.5));
$this->assertSame(1.5, $obj->exposeNormalizeSVGStyleFloatValue('not-a-number', 1.5));
$this->assertTrue($obj->exposeNormalizeSVGBoolLike(true));
$this->assertFalse($obj->exposeNormalizeSVGBoolLike(false));
$this->assertTrue($obj->exposeNormalizeSVGBoolLike(1));
$this->assertFalse($obj->exposeNormalizeSVGBoolLike(0.0));
$this->assertTrue($obj->exposeNormalizeSVGBoolLike(' yes '));
$this->assertFalse($obj->exposeNormalizeSVGBoolLike('off'));
$this->assertSame('', $obj->exposeGetSVGMarkerId(''));
$this->assertSame('', $obj->exposeGetSVGMarkerId('none'));
$this->assertSame('marker1', $obj->exposeGetSVGMarkerId('url(#marker1)'));
$this->assertSame('', $obj->exposeGetSVGMarkerId('invalid-marker-value'));
$this->assertSame('', $obj->exposeApplySVGClipFunction('unknownClip', []));
$this->assertStringContainsString('m', $obj->exposeApplySVGClipFunction('getSVGPath', [
4,
'M 0 0 L 10 10 Z',
'S',
]));
$this->assertNotSame('', $obj->exposeApplySVGClipFunction('getRoundedRect', [
5.0,
5.0,
10.0,
8.0,
2.0,
2.0,
'1111',
'DF',
]));
$this->assertNotSame('', $obj->exposeApplySVGClipFunction('getCircle', [10.0, 10.0, 5.0, 0.0, 360.0, 'S']));
$this->assertNotSame('', $obj->exposeApplySVGClipFunction('getEllipse', [
10.0,
10.0,
6.0,
3.0,
0.0,
0.0,
360.0,
'S',
]));
$this->assertNotSame('', $obj->exposeApplySVGClipFunction('getLine', [0.0, 0.0, 10.0, 10.0]));
$this->assertNotSame('', $obj->exposeApplySVGClipFunction('getPolygon', [
[0.0, 0.0, 10.0, 0.0, 10.0, 10.0],
'S',
]));
$this->assertNotSame('', $obj->exposeApplySVGClipFunction('getClippingRect', [0.0, 0.0, 10.0, 10.0, true]));
$this->assertSame(0.0, $obj->exposeResolveSVGMarkerRefCoordinate('', 10.0, 20.0, 4));
$this->assertEqualsWithDelta(20.0, $obj->exposeResolveSVGMarkerRefCoordinate('50%', 10.0, 20.0, 4), 0.001);
$this->assertEqualsWithDelta(12.5, $obj->exposeResolveSVGMarkerRefCoordinate('12.5mm', 10.0, 20.0, 4), 0.001);
$this->assertEqualsWithDelta(100.0, $obj->exposeResolveSVGPatternLength('50%', 200.0, 4), 0.001);
$this->assertEqualsWithDelta(25.4, $obj->exposeResolveSVGPatternLength('25.4mm', 200.0, 4), 0.001);
$this->assertEqualsWithDelta(0.0, $obj->exposeResolveSVGPatternLength('', 200.0, 4), 0.001);
}
/** @throws \Throwable */
public function testSvgTextAttributeAndBlendAlphaHelpers(): void
{
$obj = $this->getInternalTestObject();
$this->assertSame(0.0, $obj->exposeGetTALetterSpacing('normal'));
$this->assertSame(1.25, $obj->exposeGetTALetterSpacing('inherit', 1.25));
$this->assertGreaterThan(0, $obj->exposeGetTALetterSpacing('2px'));
$this->assertSame(70.0, $obj->exposeGetTAFontStretching('condensed'));
$this->assertSame(120.0, $obj->exposeGetTAFontStretching('wider', 110));
$this->assertSame(95.0, $obj->exposeGetTAFontStretching('narrower', 105));
$this->assertSame(80.0, $obj->exposeGetTAFontStretching('80%'));
$this->assertSame('B', $obj->exposeGetTAFontWeight('bold'));
$this->assertSame('', $obj->exposeGetTAFontWeight('normal'));
$this->assertSame('I', $obj->exposeGetTAFontStyle('italic'));
$this->assertSame('', $obj->exposeGetTAFontStyle('normal'));
$this->assertSame('U', $obj->exposeGetTAFontDecoration('underline'));
$this->assertSame('O', $obj->exposeGetTAFontDecoration('overline'));
$this->assertSame('D', $obj->exposeGetTAFontDecoration('line-through'));
$this->assertSame('', $obj->exposeGetTAFontDecoration('none'));
$this->assertSame('Multiply', $obj->exposeNormalizeSVGBlendMode('multiply'));
$this->assertSame('Normal', $obj->exposeNormalizeSVGBlendMode('no-such-mode'));
$this->assertSame(1.0, $obj->exposeNormalizeSVGAlphaValue(1.5));
$this->assertSame(0.0, $obj->exposeNormalizeSVGAlphaValue(-0.4));
$this->assertSame(0.5, $obj->exposeNormalizeSVGAlphaValue('0.5'));
$this->assertSame('', $obj->exposeGetSVGExtGState(null, null, ''));
$gstate = $obj->exposeGetSVGExtGState(0.8, 0.5, 'Multiply');
$this->assertStringContainsString('gs', $gstate);
$pdfx3 = new TestableSVG('mm', true, false, true, 'pdfx3');
$this->assertSame('', $pdfx3->exposeGetSVGExtGState(0.8, 0.5, 'Multiply'));
$pdfx4 = new TestableSVG('mm', true, false, true, 'pdfx4');
$this->assertStringContainsString('gs', $pdfx4->exposeGetSVGExtGState(0.8, 0.5, 'Multiply'));
}
/** @throws \Throwable */
public function testSvgPathCommandHelpersProducePathOperations(): void
{
$obj = $this->getInternalTestObject();
$coord = $obj->getPathCoordDefaults();
[$mout, $coord] = $obj->exposeSvgPathCmdM([1, 2, 3, 4], $coord);
$this->assertStringContainsString('m', $mout);
[$lout, $coord] = $obj->exposeSvgPathCmdL([5, 6, 7, 8], $coord);
$this->assertStringContainsString('l', $lout);
[$hout, $coord] = $obj->exposeSvgPathCmdH([9, 10], $coord);
$this->assertStringContainsString('l', $hout);
[$vout, $coord] = $obj->exposeSvgPathCmdV([11, 12], $coord);
$this->assertStringContainsString('l', $vout);
[$cout, $coord] = $obj->exposeSvgPathCmdC([13, 14, 15, 16, 17, 18], $coord);
$this->assertStringContainsString('c', $cout);
[$qout, $coord] = $obj->exposeSvgPathCmdQ([19, 20, 21, 22], $coord);
$this->assertStringContainsString('c', $qout);
[$sout, $coord] = $obj->exposeSvgPathCmdS([23, 24, 25, 26], $coord);
$this->assertStringContainsString('c', $sout);
[$tout, $coord] = $obj->exposeSvgPathCmdT([27, 28], $coord);
$this->assertStringContainsString('c', $tout);
$arcPaths = [['0', 'A'], ['1', 'z']];
[$aout, $coord] = $obj->exposeSvgPathCmdA([5, 5, 0, 0, 1, 30, 35], $coord, $arcPaths, 0, [
'5',
'5',
'0',
'0',
'1',
'30',
'35',
]);
$this->assertNotSame('', $aout);
[$zout, $coord] = $obj->exposeSvgPathCmdZ($coord);
$this->assertSame("h\n", $zout);
$this->assertSame($coord['xinit'], $coord['x']);
$this->assertSame($coord['yinit'], $coord['y']);
}
/** @throws \Throwable */
public function testSvgStyleHelperMethodsCoverCorePaths(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->setSvgObjMeta(3);
$base = $obj->exposeDefaultSVGStyle();
[$fontOut, $fontStyle] = $obj->exposeParseSVGStyleFont($base, $base);
$this->assertNotSame('', $fontOut);
$this->assertArrayHasKey('font-size-val', $fontStyle);
$strokeStyle = $base;
$strokeStyle['stroke'] = '#112233';
$strokeStyle['stroke-width'] = 1.0;
$strokeStyle['stroke-linecap'] = 'butt';
$strokeStyle['stroke-linejoin'] = 'miter';
$strokeStyle['stroke-opacity'] = 1.0;
$strokeStyle['opacity'] = 1.0;
$strokeStyle['mix-blend-mode'] = 'normal';
$strokeStyle['objstyle'] = '';
[$strokeOut, $strokeStyle] = $obj->exposeParseSVGStyleStroke(3, $strokeStyle);
$this->assertNotSame('', $strokeOut);
$this->assertStringContainsString('D', $strokeStyle['objstyle']);
$colorStyle = $base;
$colorStyle['opacity'] = 0.5;
$colorStyle['mix-blend-mode'] = 'multiply';
$colorStyle['color'] = '#ff0000';
$colorStyle['text-color'] = '#00ff00';
[$colorOut] = $obj->exposeParseSVGStyleColor($colorStyle);
$this->assertNotSame('', $colorOut);
$pdfx3 = new TestableSVG('mm', true, false, true, 'pdfx3');
[$pdfx3ColorOut] = $pdfx3->exposeParseSVGStyleColor($colorStyle);
$this->assertStringNotContainsString('/GS', $pdfx3ColorOut);
$clipStyle = $base;
$clipStyle['clip'] = 'rect(1 2 3 4)';
$clipStyle['clip-rule'] = 'evenodd';
[$clipOut] = $obj->exposeParseSVGStyleClip($clipStyle, 10, 20, 100, 50);
$this->assertNotSame('', $clipOut);
$this->assertSame('', $obj->exposeParseSVGStyleGradient(3, [], 'missing', 0, 0, 10, 10));
$fillNone = $base;
$fillNone['fill'] = 'none';
[$fillNoneOut] = $obj->exposeParseSVGStyleFill(3, $fillNone, [], 0, 0, 10, 10);
$this->assertSame('', $fillNoneOut);
$fillUrl = $base;
$fillUrl['fill'] = 'url(#missing)';
$fillUrl['opacity'] = 1.0;
$fillUrl['fill-opacity'] = 1.0;
[$fillUrlOut] = $obj->exposeParseSVGStyleFill(3, $fillUrl, [], 0, 0, 10, 10);
$this->assertSame('', $fillUrlOut);
$obj->patchSvgObj(3, [
'defs' => [
'patEmpty' => [
'name' => 'pattern',
'attr' => [
'id' => 'patEmpty',
'patternUnits' => 'userSpaceOnUse',
'x' => '0',
'y' => '0',
'width' => '3',
'height' => '3',
],
'child' => [
'DF_1' => [
'name' => 'rect',
'attr' => [
'x' => '0',
'y' => '0',
'width' => '3',
'height' => '3',
'fill' => 'none',
'stroke' => '#000000',
'stroke-width' => '1',
],
],
'DF_1_CLOSE' => [
'name' => 'rect',
'attr' => [
'closing_tag' => true,
'content' => '',
],
],
],
],
],
]);
$fillPattern = $base;
$fillPattern['fill'] = 'url(#patEmpty)';
$fillPattern['opacity'] = 1.0;
$fillPattern['fill-opacity'] = 1.0;
$obj->patchSvgObj(3, ['out' => 'BASE']);
[$fillPatternOut] = $obj->exposeParseSVGStyleFill(3, $fillPattern, [], 0, 0, 10, 10, 'getClippingRect', [
0.0,
0.0,
10.0,
10.0,
0.0,
]);
$this->assertSame('BASE', $obj->getSvgObj(3)['out']);
$this->assertIsString($fillPatternOut);
$parser = \xml_parser_create('UTF-8');
$obj->exposeParseSVGStyleClipPath($parser, 3, []);
$earlyStyle = $base;
$earlyStyle['opacity'] = 0;
[$styleOut, $objstyle] = $obj->exposeParseSVGStyle($parser, 3, $earlyStyle, $base);
$this->assertSame('', $styleOut);
$this->assertSame('', $objstyle);
}
/** @throws \Throwable */
public function testSvgRawSizeAndPrescanHelpers(): void
{
$obj = $this->getInternalTestObject();
$obj->initSvgObjForHandlers(40);
$inline = '<svg width="10" height="20"></svg>';
$this->assertSame($inline, $obj->exposeGetRawSVGData('@' . $inline));
$this->assertSame('', $obj->exposeGetRawSVGData('@'));
$tmp = \tempnam(\sys_get_temp_dir(), 'tc-svg-');
$this->assertNotFalse($tmp);
\file_put_contents($tmp, $inline);
try {
$this->assertSame($inline, $obj->exposeGetRawSVGData($tmp));
} finally {
if (\file_exists($tmp)) {
\unlink($tmp);
}
}
$size = $obj->exposeGetSVGSize(
'<svg x="1" y="2" width="100" height="50" viewBox="0 0 100 50" preserveAspectRatio="xMinYMin meet"></svg>',
);
$this->assertGreaterThan(0, $size['width']);
$this->assertGreaterThan(0, $size['height']);
$this->assertSame('xMinYMin', $size['ar_align']);
$getPrescanAttributes = new \ReflectionMethod($obj, 'exposeGetSVGPrescanAttributes');
/** @var array<string, string> $attr */
$attr = $getPrescanAttributes->invokeArgs($obj, [['id' => 'g1', 'stop-color' => '#fff', 'junk' => 'x']]);
$this->assertArrayHasKey('id', $attr);
$this->assertArrayHasKey('stop-color', $attr);
$this->assertArrayNotHasKey('junk', $attr);
$stopStyle = $obj->exposeGetSVGPrescanStopStyle(['style' => 'stop-color: #123456; stop-opacity: 0.25']);
$this->assertSame('#123456', $stopStyle['stop-color']);
$this->assertSame(0.25, $stopStyle['stop-opacity']);
$obj->exposePrescanSVGGradients(
'<svg><defs><linearGradient id="lg">'
. '<stop offset="0%" stop-color="#000"/>'
. '<stop offset="100%" style="stop-color:#fff;stop-opacity:0.4"/>'
. '</linearGradient></defs></svg>',
40,
);
$svgobj = $obj->getSvgObj(40);
$this->assertArrayHasKey('lg', $svgobj['gradients']);
$lg = $svgobj['gradients']['lg'] ?? null;
$this->assertIsArray($lg);
$stops = $lg['stops'];
$this->assertIsArray($stops);
$this->assertGreaterThanOrEqual(1, \count($stops));
}
/** @throws \Throwable */
public function testSvgGetSVGSizeCoversMissingViewboxAndThreeTokenAspectRatio(): void
{
$obj = $this->getInternalTestObject();
$noViewBox = $obj->exposeGetSVGSize('<svg x="3" y="4" width="60" height="30"></svg>');
$this->assertGreaterThan(0.0, $noViewBox['x']);
$this->assertGreaterThan(0.0, $noViewBox['y']);
$this->assertGreaterThan(0.0, $noViewBox['width']);
$this->assertGreaterThan(0.0, $noViewBox['height']);
$this->assertSame([0.0, 0.0, 0.0, 0.0], $noViewBox['viewBox']);
$threeTokens = $obj->exposeGetSVGSize(
'<svg width="10" height="10" viewBox="0 0 10 10" preserveAspectRatio="defer xMaxYMax slice"></svg>',
);
$this->assertSame('xMaxYMax', $threeTokens['ar_align']);
$this->assertSame('slice', $threeTokens['ar_ms']);
}
/** @throws \Throwable */
public function testSvgPrescanRadialGradientWithDirectStopAttributes(): void
{
$obj = $this->getInternalTestObject();
$obj->initSvgObjForHandlers(44);
$obj->exposePrescanSVGGradients(
'<svg><defs><radialGradient id="rg">'
. '<stop offset="0%" stop-color="#101010" stop-opacity="0.2"/>'
. '</radialGradient></defs></svg>',
44,
);
$svgobj = $obj->getSvgObj(44);
$this->assertArrayHasKey('rg', $svgobj['gradients']);
$rg = $svgobj['gradients']['rg'] ?? null;
$this->assertIsArray($rg);
$stops = $rg['stops'];
$this->assertIsArray($stops);
$this->assertGreaterThanOrEqual(1, \count($stops));
}
/** @throws \Throwable */
public function testSvgTagHandlersAndEndStartHelpers(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(41);
$parser = \xml_parser_create('UTF-8');
$obj->exposeHandlerSVGCharacter($parser, 'Hello');
$svgobj = $obj->getSvgObj(41);
$this->assertSame('Hello', $svgobj['text']);
$obj->exposeParseSVGTagSTARTdefs(41);
$this->assertTrue($obj->getSvgObj(41)['defsmode']);
$obj->exposeParseSVGTagENDdefs(41);
$this->assertFalse($obj->getSvgObj(41)['defsmode']);
$obj->exposeParseSVGTagSTARTclipPath(41, [1, 0, 0, 1, 0, 0]);
$this->assertTrue($obj->getSvgObj(41)['clipmode']);
$obj->exposeParseSVGTagENDclipPath(41);
$this->assertFalse($obj->getSvgObj(41)['clipmode']);
$obj->patchSvgObj(41, ['styles' => [$obj->exposeDefaultSVGStyle(), $obj->exposeDefaultSVGStyle()]]);
$this->assertSame('', $obj->exposeParseSVGTagENDg(41));
$this->assertCount(1, $obj->getSvgObj(41)['styles']);
$obj->patchSvgObj(41, ['textmode' => ['invisible' => true]]);
$this->assertSame('', $obj->exposeParseSVGTagENDtext(41));
$this->assertSame('', $obj->exposeParseSVGTagENDtspan(41));
$obj->patchSvgObj(41, ['tagdepth' => 1]);
$this->assertSame('', $obj->exposeParseSVGTagENDsvg(41));
$obj->patchSvgObj(41, ['clipmode' => true, 'cliptm' => [1, 0, 0, 1, 0, 0]]);
$obj->exposeHandleSVGTagStart($parser, 'rect', ['x' => '1', 'y' => '2'], 41, false, [1, 0, 0, 1, 0, 0]);
$this->assertNotEmpty($obj->getSvgObj(41)['clippaths']);
$obj->patchSvgObj(41, ['defsmode' => false]);
$obj->exposeHandleSVGTagEnd($parser, 'defs');
$this->assertFalse($obj->getSvgObj(41)['defsmode']);
}
/** @throws \Throwable */
public function testSvgRemainingStartTagMethodsCoveredViaGuardBranches(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(42);
$base = $obj->exposeDefaultSVGStyle();
$parser = \xml_parser_create('UTF-8');
// Root svg branch exits immediately.
$obj->patchSvgObj(42, ['tagdepth' => 0]);
$this->assertSame('', $obj->exposeParseSVGTagSTARTsvg($parser, 42, [], $base, $base));
// Group start path should return a transform block.
$gOut = $obj->exposeParseSVGTagSTARTg($parser, 42, [], $base, $base);
$this->assertNotSame('', $gOut);
// Gradient starts and stop insertion.
$this->assertSame('', $obj->exposeParseSVGTagSTARTlinearGradient(42, ['id' => 'lg2']));
$this->assertSame('', $obj->exposeParseSVGTagSTARTradialGradient(42, ['id' => 'rg2']));
$obj->patchSvgObj(42, ['gradientid' => 'lg2']);
$this->assertSame('', $obj->exposeParseSVGTagSTARTstop(42, ['offset' => '50%'], $base));
$svg42 = $obj->getSvgObj(42);
$this->assertNotEmpty($svg42['gradients']['lg2']['stops'] ?? []);
// Invisibility/clip guards for shape and text tags.
$obj->patchSvgObj(42, ['textmode' => ['invisible' => true], 'clipmode' => false]);
$this->assertSame('', $obj->exposeParseSVGTagSTARTpath($parser, 42, ['d' => 'M 0 0 L 1 1'], $base, $base));
$this->assertSame('', $obj->exposeParseSVGTagSTARTrect(
$parser,
42,
['width' => '1', 'height' => '1'],
$base,
$base,
));
$this->assertSame('', $obj->exposeParseSVGTagSTARTcircle($parser, 42, ['r' => '1'], $base, $base));
$this->assertSame('', $obj->exposeParseSVGTagSTARTellipse(
$parser,
42,
['rx' => '1', 'ry' => '1'],
$base,
$base,
));
$this->assertSame('', $obj->exposeParseSVGTagSTARTline(
$parser,
42,
['x1' => '0', 'y1' => '0', 'x2' => '1', 'y2' => '1'],
$base,
$base,
));
$this->assertSame('', $obj->exposeParseSVGTagSTARTpolygon($parser, 42, ['points' => '0,0 1,1'], $base, $base));
$this->assertSame('', $obj->exposeParseSVGTagSTARTimage($parser, 42, ['xlink:href' => 'x.png'], $base, $base));
$this->assertSame('', $obj->exposeParseSVGTagSTARTtext($parser, 42, ['x' => '0', 'y' => '0'], $base, $base));
$this->assertSame('', $obj->exposeParseSVGTagSTARTtspan($parser, 42, ['dx' => '1'], $base, $base));
// use tag guards when href is missing or unknown.
$this->assertSame('', $obj->exposeParseSVGTagSTARTuse($parser, 42, []));
$this->assertSame('', $obj->exposeParseSVGTagSTARTuse($parser, 42, ['xlink:href' => '#missing']));
}
/** @throws \Throwable */
public function testSvgUseTagResolvesDefinitionAndAppendsOutput(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(45);
$obj->patchSvgObj(45, [
'defs' => [
'shape1' => [
'name' => 'rect',
'attr' => [
'x' => '2',
'y' => '3',
'width' => '5',
'height' => '4',
'style' => 'fill:#000000;stroke:none;',
],
],
],
'out' => '',
]);
$parser = \xml_parser_create('UTF-8');
$this->assertSame('', $obj->exposeParseSVGTagSTARTuse($parser, 45, [
'xlink:href' => '#shape1',
'x' => '5',
'y' => '7',
]));
$svgobj = $obj->getSvgObj(45);
$this->assertNotSame('', $svgobj['out']);
}
/**
* A <use> whose target is itself a <use> pointing back at the same id must
* terminate instead of recursing until memory is exhausted (CWE-400).
*
* @throws \Throwable
*/
public function testSvgUseSelfReferenceDoesNotRecurInfinitely(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(46);
$obj->patchSvgObj(46, [
'defs' => [
'a' => [
'name' => 'use',
'attr' => ['id' => 'a', 'href' => '#a'],
],
],
'out' => '',
]);
$parser = \xml_parser_create('UTF-8');
$result = $obj->exposeParseSVGTagSTARTuse($parser, 46, ['href' => '#a']);
$this->assertSame('', $result);
$this->assertSame('', $obj->getSvgObj(46)['out']);
}
/**
* Two <use> elements in a mutual cycle (#a -> #b -> #a) must terminate.
*
* @throws \Throwable
*/
public function testSvgUseMutualCycleTerminates(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(47);
$obj->patchSvgObj(47, [
'defs' => [
'a' => ['name' => 'use', 'attr' => ['id' => 'a', 'href' => '#b']],
'b' => ['name' => 'use', 'attr' => ['id' => 'b', 'href' => '#a']],
],
'out' => '',
]);
$parser = \xml_parser_create('UTF-8');
$result = $obj->exposeParseSVGTagSTARTuse($parser, 47, ['href' => '#a']);
$this->assertSame('', $result);
$this->assertSame('', $obj->getSvgObj(47)['out']);
}
/**
* The cycle guard refuses to expand a target already on the active chain,
* so seeding its id into usechain suppresses rendering of an otherwise
* valid shape.
*
* @throws \Throwable
*/
public function testSvgUseCycleGuardRefusesTargetAlreadyBeingExpanded(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(48);
$obj->patchSvgObj(48, [
'defs' => [
'shape1' => [
'name' => 'rect',
'attr' => [
'x' => '2',
'y' => '3',
'width' => '5',
'height' => '4',
'style' => 'fill:#000000;stroke:none;',
],
],
],
'usechain' => ['shape1' => true],
'out' => '',
]);
$parser = \xml_parser_create('UTF-8');
$result = $obj->exposeParseSVGTagSTARTuse($parser, 48, ['href' => '#shape1']);
$this->assertSame('', $result);
$this->assertSame('', $obj->getSvgObj(48)['out']);
}
/**
* The depth ceiling refuses expansion once the active chain reaches
* SVGMAXUSEDEPTH (64), bounding non-cyclic but pathologically deep graphs.
*
* @throws \Throwable
*/
public function testSvgUseDepthGuardRefusesBeyondMaxDepth(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(49);
$deepChain = [];
for ($i = 0; $i < 64; $i++) {
$deepChain['d' . $i] = true;
}
$obj->patchSvgObj(49, [
'defs' => [
'shape1' => [
'name' => 'rect',
'attr' => [
'x' => '2',
'y' => '3',
'width' => '5',
'height' => '4',
'style' => 'fill:#000000;stroke:none;',
],
],
],
'usechain' => $deepChain,
'out' => '',
]);
$parser = \xml_parser_create('UTF-8');
$result = $obj->exposeParseSVGTagSTARTuse($parser, 49, ['href' => '#shape1']);
$this->assertSame('', $result);
$this->assertSame('', $obj->getSvgObj(49)['out']);
}
/**
* The guard is a stack-scoped active chain, not a permanent seen-set: two
* separate <use> references to the same shape must both render.
*
* @throws \Throwable
*/
public function testSvgUseRepeatedReferenceToSameShapeStillRenders(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(50);
$obj->patchSvgObj(50, [
'defs' => [
'shape1' => [
'name' => 'rect',
'attr' => [
'x' => '2',
'y' => '3',
'width' => '5',
'height' => '4',
'style' => 'fill:#000000;stroke:none;',
],
],
],
'out' => '',
]);
$parser = \xml_parser_create('UTF-8');
$obj->exposeParseSVGTagSTARTuse($parser, 50, ['href' => '#shape1', 'x' => '5', 'y' => '7']);
$firstOut = $obj->getSvgObj(50)['out'];
$this->assertNotSame('', $firstOut);
$obj->exposeParseSVGTagSTARTuse($parser, 50, ['href' => '#shape1', 'x' => '9', 'y' => '1']);
$secondOut = $obj->getSvgObj(50)['out'];
$this->assertNotSame($firstOut, $secondOut);
}
/**
* End-to-end: the ~90-byte advisory PoC (a self-referencing <use> inside
* <defs>) must render without a fatal memory-exhaustion crash.
*
* @throws \Throwable
*/
public function testAddSVGSelfReferencingUseDoesNotExhaustMemory(): void
{
$obj = $this->getTestObject();
$page = $this->initFontAndPage($obj);
$svg =
'@<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">'
. '<defs><use id="a" href="#a"/></defs>'
. '<use href="#a"/>'
. '</svg>';
$soid = $obj->addSVG($svg, 10, 10, 50, 50, $page['height']);
$this->assertSame(1, $soid);
/** @var array<int, array<string, mixed>> $svgobjs */
$svgobjs = $this->getObjectProperty($obj, 'svgobjs');
$this->assertArrayHasKey($soid, $svgobjs);
}
/** @throws \Throwable */
public function testSvgTransformRotateCenterAndEmptyTransformMatrix(): void
{
$obj = $this->getInternalTestObject();
$rot = $obj->exposeParseSVGTMrotate('45 10 20');
$this->assertCount(6, $rot);
assert(isset($rot[4]), "\$rot[4] must be set");
$this->assertNotSame(0.0, $rot[4]);
assert(isset($rot[5]), "\$rot[5] must be set");
$this->assertNotSame(0.0, $rot[5]);
$tmx = $obj->exposeGetSVGTransformMatrix('');
$this->assertSame([1.0, 0.0, 0.0, 1.0, 0.0, 0.0], $tmx);
}
/** @throws \Throwable */
public function testSvgPathCmdAHandlesIdenticalEndpointsAndSweepAdjustments(): void
{
$obj = $this->getInternalTestObject();
$coord = $obj->getPathCoordDefaults();
$coord['x'] = 10.0;
$coord['y'] = 10.0;
[$sameOut, $sameCoord] = $obj->exposeSvgPathCmdA(
[5.0, 5.0, 0.0, 0.0, 1.0, 10.0, 10.0],
$coord,
[['0', 'A']],
0,
['5', '5', '0', '0', '1', '10', '10'],
);
$this->assertSame('', $sameOut);
$this->assertLessThanOrEqual(10.0, $sameCoord['xmin']);
$this->assertLessThanOrEqual(10.0, $sameCoord['ymin']);
$this->assertGreaterThanOrEqual(10.0, $sameCoord['xmax']);
$this->assertGreaterThanOrEqual(10.0, $sameCoord['ymax']);
[$arcOut] = $obj->exposeSvgPathCmdA(
[4.0, 2.0, 0.0, 1.0, 1.0, 15.0, 18.0],
$coord,
[['0', 'A'], ['1', 'z']],
0,
['4', '2', '0', '1', '1', '15', '18'],
);
$this->assertNotSame('', $arcOut);
}
/** @throws \Throwable */
public function testSvgParseStyleGradientHandlesXrefMeasureAndPercentageModes(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(46);
$gradients = [
'base' => [
'xref' => '',
'type' => 2,
'gradientUnits' => 'objectBoundingBox',
'mode' => 'measure',
'coords' => [0.0, 0.0, 10.0, 0.0, 3.0],
'stops' => [
['color' => '#000000', 'opacity' => 1.0, 'offset' => 0.0],
['color' => '#ffffff', 'opacity' => 1.0, 'offset' => 1.0],
],
'gradientTransform' => [1.0, 0.0, 0.0, 1.0, 2.0, 3.0],
],
'ref' => [
'xref' => 'base',
'type' => 2,
'gradientUnits' => 'objectBoundingBox',
'mode' => 'measure',
'coords' => [1.0, 2.0, 3.0, 4.0, 2.0],
'stops' => [],
'gradientTransform' => [1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
],
'pct' => [
'xref' => '',
'type' => 3,
'gradientUnits' => 'objectBoundingBox',
'mode' => 'percentage',
'coords' => [-10.0, 150.0, 50.0, 50.0, 20.0],
'stops' => [
['color' => '#111111', 'opacity' => 1.0, 'offset' => 0.0],
['color' => '#eeeeee', 'opacity' => 1.0, 'offset' => 1.0],
],
'gradientTransform' => [1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
],
];
$refOut = $obj->exposeParseSVGStyleGradient(46, $gradients, 'ref', 5.0, 6.0, 20.0, 10.0, 'getClippingRect', [
5.0,
6.0,
20.0,
10.0,
0.0,
]);
$this->assertNotSame('', $refOut);
$pctOut = $obj->exposeParseSVGStyleGradient(46, $gradients, 'pct', 0.0, 0.0, 10.0, 10.0);
$this->assertNotSame('', $pctOut);
}
/** @throws \Throwable */
public function testSvgDefsModeStartEndAndInnerSvgStartBranches(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(47);
$base = $obj->exposeDefaultSVGStyle();
$parser = \xml_parser_create('UTF-8');
$obj->patchSvgObj(47, [
'defsmode' => true,
'text' => 'txt',
'defs' => [
'def1' => [
'name' => 'g',
'attr' => ['id' => 'def1'],
'child' => [
'child1' => [
'name' => 'path',
'attr' => ['id' => 'child1'],
],
],
],
],
]);
$obj->exposeHandleSVGTagEnd($parser, 'path');
$defs = $obj->getSvgObj(47)['defs'];
/** @var array<string, mixed> $def1 */
$def1 = $defs['def1'] ?? [];
/** @var array<string, mixed> $child */
$child = isset($def1['child']) && \is_array($def1['child']) ? $def1['child'] : [];
$this->assertArrayHasKey('child1_CLOSE', $child);
$obj->exposeHandleSVGTagStart($parser, 'line', [], 47);
$defs = $obj->getSvgObj(47)['defs'];
/** @var array<string, mixed> $def1 */
$def1 = $defs['def1'] ?? [];
/** @var array<string, mixed> $child */
$child = isset($def1['child']) && \is_array($def1['child']) ? $def1['child'] : [];
$this->assertArrayHasKey('DF_3', $child);
$obj->exposeHandleSVGTagStart($parser, 'circle', ['id' => 'newdef'], 47);
$this->assertArrayHasKey('newdef', $obj->getSvgObj(47)['defs']);
$obj->patchSvgObj(47, ['defsmode' => false, 'tagdepth' => 1]);
$innerOut = $obj->exposeParseSVGTagSTARTsvg(
$parser,
47,
['x' => '2', 'y' => '3', 'width' => '12', 'height' => '8'],
$base,
$base,
);
$this->assertNotSame('', $innerOut);
$this->assertGreaterThan(1, \count($obj->getSvgObj(47)['styles']));
}
/** @throws \Throwable */
public function testSvgPathCommandsCoverRelativeAndFallbackBranches(): void
{
$obj = $this->getInternalTestObject();
$coord = $obj->getPathCoordDefaults();
$coord['relcoord'] = true;
$coord['x'] = 2.0;
$coord['y'] = 3.0;
$coord['x0'] = 2.0;
$coord['y0'] = 3.0;
$coord['xoffset'] = 2.0;
$coord['yoffset'] = 3.0;
[, $coord] = $obj->exposeSvgPathCmdC([1, 1, 2, 2, 3, 3], $coord);
$this->assertGreaterThan(2.0, $coord['xoffset']);
$this->assertGreaterThan(3.0, $coord['yoffset']);
[, $coord] = $obj->exposeSvgPathCmdH([4], $coord);
$this->assertGreaterThan(0.0, $coord['xoffset']);
[, $coord] = $obj->exposeSvgPathCmdL([5, 6], $coord);
$this->assertGreaterThan(0.0, $coord['xoffset']);
$this->assertGreaterThan(0.0, $coord['yoffset']);
[, $coord] = $obj->exposeSvgPathCmdM([7, 8], $coord);
$this->assertGreaterThan(0.0, $coord['xoffset']);
$this->assertGreaterThan(0.0, $coord['yoffset']);
[, $coord] = $obj->exposeSvgPathCmdQ([1, 2, 3, 4], $coord);
$this->assertGreaterThan(0.0, $coord['xoffset']);
$this->assertGreaterThan(0.0, $coord['yoffset']);
[, $coord] = $obj->exposeSvgPathCmdS([2, 3, 4, 5], $coord);
$this->assertGreaterThan(0.0, $coord['xoffset']);
$this->assertGreaterThan(0.0, $coord['yoffset']);
[, $coord] = $obj->exposeSvgPathCmdT([6, 7], $coord);
$this->assertGreaterThan(0.0, $coord['xoffset']);
$this->assertGreaterThan(0.0, $coord['yoffset']);
[, $coord] = $obj->exposeSvgPathCmdV([9], $coord);
$this->assertGreaterThan(0.0, $coord['yoffset']);
}
/** @throws \Throwable */
public function testSvgStyleFontAndStrokeExtraBranches(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->setSvgObjMeta(48);
$base = $obj->exposeDefaultSVGStyle();
$fontStyle = $base;
$fontStyle['font'] = 'italic small-caps bold 14px/16px helvetica';
$fontStyle['font-family'] = '';
[$fontOut, $fontStyle] = $obj->exposeParseSVGStyleFont($fontStyle, $base);
$this->assertNotSame('', $fontOut);
$this->assertNotSame('', $fontStyle['font-family']);
$strokeStyle = $base;
$strokeStyle['stroke'] = 'rgba(10,20,30,0.5)';
$strokeStyle['stroke-width'] = 1.2;
$strokeStyle['stroke-linecap'] = 'round';
$strokeStyle['stroke-linejoin'] = 'bevel';
$strokeStyle['stroke-opacity'] = 0.8;
$strokeStyle['opacity'] = 0.7;
$strokeStyle['mix-blend-mode'] = 'screen';
[$strokeOut] = $obj->exposeParseSVGStyleStroke(48, $strokeStyle);
$this->assertNotSame('', $strokeOut);
}
/** @throws \Throwable */
public function testSvgHandleStartAndEndAdditionalBranches(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$parser = \xml_parser_create('UTF-8');
$obj->exposeHandleSVGTagStart($parser, 'g', [], 999);
$obj->initSvgObjForHandlers(49);
$base = $obj->exposeDefaultSVGStyle();
$obj->patchSvgObj(49, [
'clipmode' => true,
'styles' => [$base],
'textmode' => ['invisible' => false, 'stroke' => 1, 'rtl' => false, 'text-anchor' => 'middle'],
'x' => 11.0,
'y' => 12.0,
'text' => 'abc',
'defsmode' => false,
'tagdepth' => 2,
]);
$obj->exposeHandleSVGTagStart($parser, 'path', ['d' => 'M0 0 L1 1'], 49, true, [1, 0, 0, 1, 0, 0]);
$this->assertNotEmpty($obj->getSvgObj(49)['clippaths']);
$textOut = $obj->exposeParseSVGTagENDtext(49);
$this->assertSame('', $obj->getSvgObj(49)['text']);
$this->assertNotSame('', $textOut);
$obj->exposeParseSVGTagENDsvg(49);
$this->assertNotSame('', $textOut);
}
/** @throws \Throwable */
public function testSvgStartSvgViewboxFallbackAndAspectBranches(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(50);
$base = $obj->exposeDefaultSVGStyle();
$parser = \xml_parser_create('UTF-8');
$obj->patchSvgObj(50, ['tagdepth' => 1]);
$badViewBox = $obj->exposeParseSVGTagSTARTsvg(
$parser,
50,
['x' => '1', 'y' => '2', 'width' => '12', 'height' => '8', 'viewBox' => '0 0 10'],
$base,
$base,
);
$this->assertNotSame('', $badViewBox);
$obj->patchSvgObj(50, ['tagdepth' => 1]);
$aspectOut = $obj->exposeParseSVGTagSTARTsvg(
$parser,
50,
[
'x' => '0',
'y' => '0',
'width' => '40',
'height' => '20',
'viewBox' => '0 0 100 50',
'preserveAspectRatio' => 'xMaxYMax slice',
],
$base,
$base,
);
$this->assertNotSame('', $aspectOut);
}
/** @throws \Throwable */
public function testSvgVisibleStartTagHandlersProduceOutput(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(51);
$base = $obj->exposeDefaultSVGStyle();
$base['fill'] = '#336699';
$base['stroke'] = '#112233';
$base['stroke-width'] = 0.4;
$base['opacity'] = 1.0;
$base['fill-opacity'] = 1.0;
$base['stroke-opacity'] = 1.0;
$base['text-anchor'] = 'middle';
$base['direction'] = 'rtl';
$obj->patchSvgObj(51, [
'clipmode' => false,
'textmode' => ['invisible' => false, 'stroke' => 0, 'rtl' => false, 'text-anchor' => 'start'],
'styles' => [$base],
'dir' => '/tmp',
]);
$parser = \xml_parser_create('UTF-8');
$pathOut = $obj->exposeParseSVGTagSTARTpath(
$parser,
51,
['d' => 'M 1 1 L 5 5 H 6 V 7 C 8 8 9 9 10 10 Q 11 11 12 12 S 13 13 14 14 T 15 15 A 2 2 0 1 1 16 16 z'],
$base,
$base,
);
$this->assertNotSame('', $pathOut);
$rectOut = $obj->exposeParseSVGTagSTARTrect(
$parser,
51,
['x' => '2', 'y' => '3', 'width' => '8', 'height' => '4', 'rx' => '1', 'ry' => '1'],
$base,
$base,
);
$this->assertNotSame('', $rectOut);
$circleOut = $obj->exposeParseSVGTagSTARTcircle(
$parser,
51,
['cx' => '10', 'cy' => '10', 'r' => '4'],
$base,
$base,
);
$this->assertNotSame('', $circleOut);
$ellipseOut = $obj->exposeParseSVGTagSTARTellipse(
$parser,
51,
['cx' => '12', 'cy' => '9', 'rx' => '5', 'ry' => '2'],
$base,
$base,
);
$this->assertNotSame('', $ellipseOut);
$lineOut = $obj->exposeParseSVGTagSTARTline(
$parser,
51,
['x1' => '1', 'y1' => '2', 'x2' => '9', 'y2' => '4'],
$base,
$base,
);
$this->assertNotSame('', $lineOut);
$polyOut = $obj->exposeParseSVGTagSTARTpolygon($parser, 51, ['points' => '1,1 5,1 5,5 1,5'], $base, $base);
$this->assertNotSame('', $polyOut);
$imgPath = (string) \realpath(__DIR__ . '/../examples/images/tcpdf_signature.png');
$imgOut = $obj->exposeParseSVGTagSTARTimage(
$parser,
51,
['xlink:href' => $imgPath, 'x' => '1', 'y' => '1', 'width' => '3', 'height' => '3'],
$base,
$base,
);
$this->assertNotSame('', $imgOut);
$textOut = $obj->exposeParseSVGTagSTARTtext(
$parser,
51,
['x' => '4', 'y' => '6', 'dx' => '1', 'dy' => '1'],
$base,
$base,
);
$this->assertNotSame('', $textOut);
$tspanOut = $obj->exposeParseSVGTagSTARTtspan($parser, 51, ['dx' => '1', 'dy' => '1'], $base, $base);
$this->assertNotSame('', $tspanOut);
}
/** @throws \Throwable */
public function testSvgHandleEndDefsAddsParentCloseWhenNameMatches(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(52);
$obj->patchSvgObj(52, [
'defsmode' => true,
'text' => 'close-parent',
'defs' => [
'grp1' => [
'name' => 'g',
'attr' => ['id' => 'grp1'],
'child' => [
'other' => [
'name' => 'path',
'attr' => ['id' => 'other'],
],
],
],
],
]);
$parser = \xml_parser_create('UTF-8');
$obj->exposeHandleSVGTagEnd($parser, 'g');
$defs = $obj->getSvgObj(52)['defs'];
/** @var array<string, mixed> $grp */
$grp = $defs['grp1'] ?? [];
/** @var array<string, mixed> $child */
$child = isset($grp['child']) && \is_array($grp['child']) ? $grp['child'] : [];
$this->assertArrayHasKey('grp1_CLOSE', $child);
}
/** @throws \Throwable */
public function testSvgMarkerDefsCaptureAndLifecycle(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(521);
$parser = \xml_parser_create('UTF-8');
$obj->exposeHandleSVGTagStart($parser, 'defs', [], 521);
$obj->exposeHandleSVGTagStart(
$parser,
'marker',
[
'id' => 'mk1',
'viewBox' => '0 0 10 10',
'refX' => '5',
'refY' => '5',
'markerWidth' => '5',
'markerHeight' => '5',
'orient' => 'auto',
],
521,
);
$obj->exposeHandleSVGTagStart($parser, 'path', ['d' => 'M 0 0 L 10 5 L 0 10 Z'], 521);
$obj->exposeHandleSVGTagEnd($parser, 'path');
$obj->exposeHandleSVGTagEnd($parser, 'marker');
$svgobj = $obj->getSvgObj(521);
$this->assertFalse($svgobj['defsmode']);
$this->assertArrayHasKey('mk1', $svgobj['defs']);
/** @var array<string, mixed> $marker */
$marker = $svgobj['defs']['mk1'] ?? [];
$this->assertSame('marker', $marker['name'] ?? null);
/** @var array<string, mixed> $child */
$child = isset($marker['child']) && \is_array($marker['child']) ? $marker['child'] : [];
$this->assertArrayHasKey('DF_1', $child);
$this->assertArrayHasKey('DF_1_CLOSE', $child);
}
/** @throws \Throwable */
public function testSvgMarkerStartWithoutIdOnlyEnablesDefsMode(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(522);
$this->assertSame('', $obj->exposeParseSVGTagSTARTmarker(522, []));
$svgobj = $obj->getSvgObj(522);
$this->assertTrue($svgobj['defsmode']);
$this->assertSame([], $svgobj['defs']);
$this->assertSame('', $obj->exposeParseSVGTagENDmarker(522));
$this->assertFalse($obj->getSvgObj(522)['defsmode']);
}
/** @throws \Throwable */
public function testSvgPatternDefsCaptureAndLifecycle(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(538);
$parser = \xml_parser_create('UTF-8');
$obj->exposeHandleSVGTagStart($parser, 'defs', [], 538);
$obj->exposeHandleSVGTagStart(
$parser,
'pattern',
[
'id' => 'pat1',
'patternUnits' => 'userSpaceOnUse',
'x' => '0',
'y' => '0',
'width' => '10',
'height' => '10',
],
538,
);
$obj->exposeHandleSVGTagStart(
$parser,
'rect',
['x' => '0', 'y' => '0', 'width' => '10', 'height' => '10', 'fill' => '#ff0000'],
538,
);
$obj->exposeHandleSVGTagEnd($parser, 'rect');
$obj->exposeHandleSVGTagEnd($parser, 'pattern');
$svgobj = $obj->getSvgObj(538);
$this->assertFalse($svgobj['defsmode']);
$this->assertArrayHasKey('pat1', $svgobj['defs']);
/** @var array<string, mixed> $pattern */
$pattern = $svgobj['defs']['pat1'] ?? [];
$this->assertSame('pattern', $pattern['name'] ?? null);
/** @var array<string, mixed> $child */
$child = isset($pattern['child']) && \is_array($pattern['child']) ? $pattern['child'] : [];
$this->assertArrayHasKey('DF_1', $child);
$this->assertArrayHasKey('DF_1_CLOSE', $child);
}
/** @throws \Throwable */
public function testSvgPatternStartWithoutIdOnlyEnablesDefsMode(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(539);
$this->assertSame('', $obj->exposeParseSVGTagSTARTpattern(539, []));
$svgobj = $obj->getSvgObj(539);
$this->assertTrue($svgobj['defsmode']);
$this->assertSame([], $svgobj['defs']);
$this->assertSame('', $obj->exposeParseSVGTagENDpattern(539));
$this->assertFalse($obj->getSvgObj(539)['defsmode']);
}
/** @throws \Throwable */
public function testSvgMaskDefsCaptureAndLifecycle(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(549);
$parser = \xml_parser_create('UTF-8');
$obj->exposeHandleSVGTagStart($parser, 'defs', [], 549);
$obj->exposeHandleSVGTagStart(
$parser,
'mask',
[
'id' => 'msk1',
'x' => '0',
'y' => '0',
'width' => '10',
'height' => '10',
],
549,
);
$obj->exposeHandleSVGTagStart(
$parser,
'rect',
['x' => '0', 'y' => '0', 'width' => '10', 'height' => '10', 'fill' => '#ffffff'],
549,
);
$obj->exposeHandleSVGTagEnd($parser, 'rect');
$obj->exposeHandleSVGTagEnd($parser, 'mask');
$svgobj = $obj->getSvgObj(549);
$this->assertFalse($svgobj['defsmode']);
$this->assertArrayHasKey('msk1', $svgobj['defs']);
/** @var array<string, mixed> $mask */
$mask = $svgobj['defs']['msk1'] ?? [];
$this->assertSame('mask', $mask['name'] ?? null);
/** @var array<string, mixed> $child */
$child = isset($mask['child']) && \is_array($mask['child']) ? $mask['child'] : [];
$this->assertArrayHasKey('DF_1', $child);
$this->assertArrayHasKey('DF_1_CLOSE', $child);
}
/** @throws \Throwable */
public function testSvgMaskStartWithoutIdOnlyEnablesDefsMode(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(550);
$this->assertSame('', $obj->exposeParseSVGTagSTARTmask(550, []));
$svgobj = $obj->getSvgObj(550);
$this->assertTrue($svgobj['defsmode']);
$this->assertSame([], $svgobj['defs']);
$this->assertSame('', $obj->exposeParseSVGTagENDmask(550));
$this->assertFalse($obj->getSvgObj(550)['defsmode']);
}
/** @throws \Throwable */
public function testSvgPatternHrefInheritanceIsResolvedByFill(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(540);
$base = $obj->exposeDefaultSVGStyle();
$base['fill'] = 'url(#patRef)';
$base['opacity'] = 1.0;
$base['fill-opacity'] = 1.0;
$obj->patchSvgObj(540, [
'defs' => [
'patBase' => [
'name' => 'pattern',
'attr' => [
'id' => 'patBase',
'patternUnits' => 'userSpaceOnUse',
'x' => '0',
'y' => '0',
'width' => '4',
'height' => '4',
],
'child' => [
'DF_1' => [
'name' => 'rect',
'attr' => [
'x' => '0',
'y' => '0',
'width' => '4',
'height' => '4',
'fill' => 'none',
'stroke' => '#111111',
'stroke-width' => '1',
],
],
'DF_1_CLOSE' => [
'name' => 'rect',
'attr' => [
'closing_tag' => true,
'content' => '',
],
],
],
],
'patRef' => [
'name' => 'pattern',
'attr' => [
'id' => 'patRef',
'xlink:href' => '#patBase',
],
'child' => [],
],
],
'out' => 'BASE',
]);
[$out] = $obj->exposeParseSVGStyleFill(540, $base, [], 0, 0, 10, 10, 'getClippingRect', [
0.0,
0.0,
10.0,
10.0,
0.0,
]);
$this->assertNotSame('', $out);
$this->assertSame('BASE', $obj->getSvgObj(540)['out']);
$this->assertSame(0, $obj->getSvgObj(540)['patternmode']);
}
/** @throws \Throwable */
public function testSvgPatternHrefInheritanceUsesChildAttrAndParentFallback(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(544);
$obj->patchSvgObj(544, [
'defs' => [
'patBase' => [
'name' => 'pattern',
'attr' => [
'id' => 'patBase',
'patternUnits' => 'userSpaceOnUse',
'x' => '1',
'y' => '2',
'width' => '4',
'height' => '6',
],
'child' => [
'PARENT_RECT' => [
'name' => 'rect',
'attr' => [
'x' => '0',
'y' => '0',
'width' => '4',
'height' => '6',
'fill' => 'none',
'stroke' => '#111111',
],
],
],
],
'patRef' => [
'name' => 'pattern',
'attr' => [
'id' => 'patRef',
'href' => '#patBase',
'width' => '9',
],
'child' => [],
],
],
]);
$resolved = $obj->exposeResolveSVGPatternDef(544, 'patRef');
$this->assertNotNull($resolved);
$resolvedAttr = $resolved['attr'];
$resolvedChild = isset($resolved['child']) ? $resolved['child'] : [];
$this->assertSame('9', $resolvedAttr['width'] ?? '');
$this->assertSame('6', $resolvedAttr['height'] ?? '');
$this->assertSame('1', $resolvedAttr['x'] ?? '');
$this->assertSame('2', $resolvedAttr['y'] ?? '');
$this->assertSame('userSpaceOnUse', $resolvedAttr['patternUnits'] ?? '');
$this->assertArrayHasKey('PARENT_RECT', $resolvedChild);
}
/** @throws \Throwable */
public function testSvgPatternHrefInheritanceKeepsChildContentWhenPresent(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(545);
$obj->patchSvgObj(545, [
'defs' => [
'patBase' => [
'name' => 'pattern',
'attr' => [
'id' => 'patBase',
'patternUnits' => 'userSpaceOnUse',
'x' => '0',
'y' => '0',
'width' => '4',
'height' => '4',
],
'child' => [
'PARENT_RECT' => [
'name' => 'rect',
'attr' => [
'x' => '0',
'y' => '0',
'width' => '4',
'height' => '4',
'fill' => '#aaaaaa',
],
],
],
],
'patRef' => [
'name' => 'pattern',
'attr' => [
'id' => 'patRef',
'xlink:href' => '#patBase',
],
'child' => [
'CHILD_RECT' => [
'name' => 'rect',
'attr' => [
'x' => '1',
'y' => '1',
'width' => '2',
'height' => '2',
'fill' => '#222222',
],
],
],
],
],
]);
$resolved = $obj->exposeResolveSVGPatternDef(545, 'patRef');
$this->assertNotNull($resolved);
$resolvedChild = isset($resolved['child']) ? $resolved['child'] : [];
$this->assertArrayHasKey('CHILD_RECT', $resolvedChild);
$this->assertArrayNotHasKey('PARENT_RECT', $resolvedChild);
}
/** @throws \Throwable */
public function testSvgPatternHrefMissingParentFallsBackToChildDefinition(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(546);
$obj->patchSvgObj(546, [
'defs' => [
'patRef' => [
'name' => 'pattern',
'attr' => [
'id' => 'patRef',
'href' => '#missingPattern',
'x' => '0',
'y' => '0',
'width' => '5',
'height' => '5',
],
'child' => [
'CHILD_RECT' => [
'name' => 'rect',
'attr' => [
'x' => '0',
'y' => '0',
'width' => '5',
'height' => '5',
'fill' => 'none',
'stroke' => '#000000',
'stroke-width' => '1',
],
],
'CHILD_RECT_CLOSE' => [
'name' => 'rect',
'attr' => [
'closing_tag' => true,
'content' => '',
],
],
],
],
],
]);
$resolved = $obj->exposeResolveSVGPatternDef(546, 'patRef');
$this->assertNotNull($resolved);
$resolvedAttr = $resolved['attr'];
$resolvedChild = isset($resolved['child']) ? $resolved['child'] : [];
$this->assertSame('5', $resolvedAttr['width'] ?? '');
$this->assertSame('5', $resolvedAttr['height'] ?? '');
$this->assertArrayNotHasKey('patternUnits', $resolvedAttr);
$this->assertArrayHasKey('CHILD_RECT', $resolvedChild);
$style = $obj->exposeDefaultSVGStyle();
$style['fill'] = 'url(#patRef)';
$style['opacity'] = 1.0;
$style['fill-opacity'] = 1.0;
[$out] = $obj->exposeParseSVGStyleFill(546, $style, [], 0, 0, 10, 10, 'getClippingRect', [
0.0,
0.0,
10.0,
10.0,
0.0,
]);
$this->assertNotSame('', $out);
}
/** @throws \Throwable */
public function testSvgPatternHrefNonFragmentKeepsLocalPatternDefinition(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(547);
$obj->patchSvgObj(547, [
'defs' => [
'patBase' => [
'name' => 'pattern',
'attr' => [
'id' => 'patBase',
'patternUnits' => 'userSpaceOnUse',
'x' => '0',
'y' => '0',
'width' => '4',
'height' => '4',
],
'child' => [
'PARENT_RECT' => [
'name' => 'rect',
'attr' => [
'x' => '0',
'y' => '0',
'width' => '4',
'height' => '4',
'fill' => '#00ff00',
],
],
],
],
'patRef' => [
'name' => 'pattern',
'attr' => [
'id' => 'patRef',
'href' => 'https://example.com/patterns.svg#patBase',
'x' => '1',
'y' => '1',
'width' => '5',
'height' => '5',
],
'child' => [
'CHILD_RECT' => [
'name' => 'rect',
'attr' => [
'x' => '1',
'y' => '1',
'width' => '3',
'height' => '3',
'fill' => 'none',
'stroke' => '#000000',
'stroke-width' => '1',
],
],
'CHILD_RECT_CLOSE' => [
'name' => 'rect',
'attr' => [
'closing_tag' => true,
'content' => '',
],
],
],
],
],
]);
$resolved = $obj->exposeResolveSVGPatternDef(547, 'patRef');
$this->assertNotNull($resolved);
$resolvedAttr = $resolved['attr'];
$resolvedChild = isset($resolved['child']) ? $resolved['child'] : [];
$this->assertSame('5', $resolvedAttr['width'] ?? '');
$this->assertSame('5', $resolvedAttr['height'] ?? '');
$this->assertArrayNotHasKey('patternUnits', $resolvedAttr);
$this->assertArrayHasKey('CHILD_RECT', $resolvedChild);
$this->assertArrayNotHasKey('PARENT_RECT', $resolvedChild);
$style = $obj->exposeDefaultSVGStyle();
$style['fill'] = 'url(#patRef)';
$style['opacity'] = 1.0;
$style['fill-opacity'] = 1.0;
[$out] = $obj->exposeParseSVGStyleFill(547, $style, [], 0, 0, 10, 10, 'getClippingRect', [
0.0,
0.0,
10.0,
10.0,
0.0,
]);
$this->assertNotSame('', $out);
}
/** @throws \Throwable */
public function testSvgPatternHrefInheritsViewBoxAndPatternTransformInteraction(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(548);
$defsPatternChild = [
'DF_1' => [
'name' => 'rect',
'attr' => [
'x' => '0',
'y' => '0',
'width' => '10',
'height' => '20',
'fill' => 'none',
'stroke' => '#000000',
'stroke-width' => '1',
],
],
'DF_1_CLOSE' => [
'name' => 'rect',
'attr' => [
'closing_tag' => true,
'content' => '',
],
],
];
$obj->patchSvgObj(548, [
'defs' => [
'patBase' => [
'name' => 'pattern',
'attr' => [
'id' => 'patBase',
'patternUnits' => 'userSpaceOnUse',
'x' => '0',
'y' => '0',
'width' => '6',
'height' => '6',
'viewBox' => '0 0 10 20',
'preserveAspectRatio' => 'xMidYMid meet',
'patternTransform' => 'translate(1,2) scale(0.5)',
],
'child' => $defsPatternChild,
],
'patRefInherit' => [
'name' => 'pattern',
'attr' => [
'id' => 'patRefInherit',
'href' => '#patBase',
],
'child' => [],
],
'patRefOverride' => [
'name' => 'pattern',
'attr' => [
'id' => 'patRefOverride',
'xlink:href' => '#patBase',
'patternTransform' => 'translate(0,0) scale(1)',
],
'child' => [],
],
],
]);
$resolvedInherit = $obj->exposeResolveSVGPatternDef(548, 'patRefInherit');
$resolvedOverride = $obj->exposeResolveSVGPatternDef(548, 'patRefOverride');
$this->assertNotNull($resolvedInherit);
$this->assertNotNull($resolvedOverride);
$attrInherit = $resolvedInherit['attr'];
$attrOverride = $resolvedOverride['attr'];
$this->assertSame('0 0 10 20', $attrInherit['viewBox'] ?? '');
$this->assertSame('translate(1,2) scale(0.5)', $attrInherit['patternTransform'] ?? '');
$this->assertSame('0 0 10 20', $attrOverride['viewBox'] ?? '');
$this->assertSame('translate(0,0) scale(1)', $attrOverride['patternTransform'] ?? '');
$style = $obj->exposeDefaultSVGStyle();
$style['opacity'] = 1.0;
$style['fill-opacity'] = 1.0;
$style['fill'] = 'url(#patBase)';
[$outBase] = $obj->exposeParseSVGStyleFill(548, $style, [], 0, 0, 6, 6, 'getClippingRect', [
0.0,
0.0,
6.0,
6.0,
0.0,
]);
$style['fill'] = 'url(#patRefInherit)';
[$outInherit] = $obj->exposeParseSVGStyleFill(548, $style, [], 0, 0, 6, 6, 'getClippingRect', [
0.0,
0.0,
6.0,
6.0,
0.0,
]);
$style['fill'] = 'url(#patRefOverride)';
[$outOverride] = $obj->exposeParseSVGStyleFill(548, $style, [], 0, 0, 6, 6, 'getClippingRect', [
0.0,
0.0,
6.0,
6.0,
0.0,
]);
$this->assertNotSame('', $outBase);
$this->assertNotSame('', $outInherit);
$this->assertNotSame('', $outOverride);
}
/** @throws \Throwable */
public function testSvgPatternPreserveAspectRatioChangesViewBoxTransform(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(541);
$style = $obj->exposeDefaultSVGStyle();
$style['opacity'] = 1.0;
$style['fill-opacity'] = 1.0;
$obj->patchSvgObj(541, [
'defs' => [
'patNone' => [
'name' => 'pattern',
'attr' => [
'id' => 'patNone',
'patternUnits' => 'userSpaceOnUse',
'patternContentUnits' => 'userSpaceOnUse',
'x' => '0',
'y' => '0',
'width' => '6',
'height' => '6',
'viewBox' => '0 0 10 20',
'preserveAspectRatio' => 'none',
],
'child' => [
'DF_1' => [
'name' => 'rect',
'attr' => [
'x' => '0',
'y' => '0',
'width' => '10',
'height' => '20',
'fill' => 'none',
'stroke' => '#000000',
'stroke-width' => '1',
],
],
'DF_1_CLOSE' => [
'name' => 'rect',
'attr' => [
'closing_tag' => true,
'content' => '',
],
],
],
],
'patMeet' => [
'name' => 'pattern',
'attr' => [
'id' => 'patMeet',
'patternUnits' => 'userSpaceOnUse',
'patternContentUnits' => 'userSpaceOnUse',
'x' => '0',
'y' => '0',
'width' => '6',
'height' => '6',
'viewBox' => '0 0 10 20',
'preserveAspectRatio' => 'xMidYMid meet',
],
'child' => [
'DF_1' => [
'name' => 'rect',
'attr' => [
'x' => '0',
'y' => '0',
'width' => '10',
'height' => '20',
'fill' => 'none',
'stroke' => '#000000',
'stroke-width' => '1',
],
],
'DF_1_CLOSE' => [
'name' => 'rect',
'attr' => [
'closing_tag' => true,
'content' => '',
],
],
],
],
],
]);
$style['fill'] = 'url(#patNone)';
[$outNone] = $obj->exposeParseSVGStyleFill(541, $style, [], 0, 0, 6, 6, 'getClippingRect', [
0.0,
0.0,
6.0,
6.0,
0.0,
]);
$style['fill'] = 'url(#patMeet)';
[$outMeet] = $obj->exposeParseSVGStyleFill(541, $style, [], 0, 0, 6, 6, 'getClippingRect', [
0.0,
0.0,
6.0,
6.0,
0.0,
]);
$this->assertNotSame('', $outNone);
$this->assertNotSame('', $outMeet);
$this->assertNotSame($outNone, $outMeet);
}
/** @throws \Throwable */
public function testSvgPatternHrefCycleIsHandledSafely(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(542);
$style = $obj->exposeDefaultSVGStyle();
$style['fill'] = 'url(#patCycleA)';
$style['opacity'] = 1.0;
$style['fill-opacity'] = 1.0;
$obj->patchSvgObj(542, [
'defs' => [
'patCycleA' => [
'name' => 'pattern',
'attr' => [
'id' => 'patCycleA',
'xlink:href' => '#patCycleB',
'patternUnits' => 'userSpaceOnUse',
'x' => '0',
'y' => '0',
'width' => '4',
'height' => '4',
],
'child' => [
'DF_1' => [
'name' => 'rect',
'attr' => [
'x' => '0',
'y' => '0',
'width' => '4',
'height' => '4',
'fill' => 'none',
'stroke' => '#000000',
'stroke-width' => '1',
],
],
'DF_1_CLOSE' => [
'name' => 'rect',
'attr' => [
'closing_tag' => true,
'content' => '',
],
],
],
],
'patCycleB' => [
'name' => 'pattern',
'attr' => [
'id' => 'patCycleB',
'xlink:href' => '#patCycleA',
],
'child' => [],
],
],
'out' => 'BASE',
]);
[$out] = $obj->exposeParseSVGStyleFill(542, $style, [], 0, 0, 10, 10, 'getClippingRect', [
0.0,
0.0,
10.0,
10.0,
0.0,
]);
$this->assertNotSame('', $out);
$this->assertSame('BASE', $obj->getSvgObj(542)['out']);
$this->assertSame(0, $obj->getSvgObj(542)['patternmode']);
}
/** @throws \Throwable */
public function testSvgPatternViewBoxIgnoresPatternContentUnits(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(543);
$style = $obj->exposeDefaultSVGStyle();
$style['opacity'] = 1.0;
$style['fill-opacity'] = 1.0;
$defsPatternChild = [
'DF_1' => [
'name' => 'rect',
'attr' => [
'x' => '0',
'y' => '0',
'width' => '10',
'height' => '20',
'fill' => 'none',
'stroke' => '#000000',
'stroke-width' => '1',
],
],
'DF_1_CLOSE' => [
'name' => 'rect',
'attr' => [
'closing_tag' => true,
'content' => '',
],
],
];
$obj->patchSvgObj(543, [
'defs' => [
'patUser' => [
'name' => 'pattern',
'attr' => [
'id' => 'patUser',
'patternUnits' => 'userSpaceOnUse',
'patternContentUnits' => 'userSpaceOnUse',
'x' => '0',
'y' => '0',
'width' => '6',
'height' => '6',
'viewBox' => '0 0 10 20',
'preserveAspectRatio' => 'xMidYMid meet',
],
'child' => $defsPatternChild,
],
'patObj' => [
'name' => 'pattern',
'attr' => [
'id' => 'patObj',
'patternUnits' => 'userSpaceOnUse',
'patternContentUnits' => 'objectBoundingBox',
'x' => '0',
'y' => '0',
'width' => '6',
'height' => '6',
'viewBox' => '0 0 10 20',
'preserveAspectRatio' => 'xMidYMid meet',
],
'child' => $defsPatternChild,
],
],
]);
$style['fill'] = 'url(#patUser)';
[$outUser] = $obj->exposeParseSVGStyleFill(543, $style, [], 0, 0, 6, 6, 'getClippingRect', [
0.0,
0.0,
6.0,
6.0,
0.0,
]);
$style['fill'] = 'url(#patObj)';
[$outObj] = $obj->exposeParseSVGStyleFill(543, $style, [], 0, 0, 6, 6, 'getClippingRect', [
0.0,
0.0,
6.0,
6.0,
0.0,
]);
$this->assertNotSame('', $outUser);
$this->assertNotSame('', $outObj);
$normUser = (string) \preg_replace('/PTN_[A-F0-9]{16}/', 'PTN_ID', $outUser);
$normObj = (string) \preg_replace('/PTN_[A-F0-9]{16}/', 'PTN_ID', $outObj);
$this->assertSame($normUser, $normObj);
}
/** @throws \Throwable */
public function testSvgLineRendersStartEndMarkersWhenDefined(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(523);
$base = $obj->exposeDefaultSVGStyle();
$base['stroke'] = '#000000';
$base['stroke-width'] = 0.5;
$base['marker-start'] = 'url(#mkline)';
$base['marker-end'] = 'url(#mkline)';
$obj->patchSvgObj(523, [
'styles' => [$base],
'defs' => [
'mkline' => [
'name' => 'marker',
'attr' => [
'id' => 'mkline',
'viewBox' => '0 0 10 10',
'refX' => '0',
'refY' => '5',
'markerWidth' => '4',
'markerHeight' => '4',
'orient' => 'auto',
],
'child' => [
'DF_1' => [
'name' => 'path',
'attr' => [
'id' => 'DF_1',
'd' => 'M 0 0 L 10 5 L 0 10 Z',
'fill' => '#000000',
'stroke' => 'none',
],
],
'DF_1_CLOSE' => [
'name' => 'path',
'attr' => [
'closing_tag' => true,
'content' => '',
],
],
],
],
],
]);
$parser = \xml_parser_create('UTF-8');
$out = $obj->exposeParseSVGTagSTARTline(
$parser,
523,
['x1' => '1', 'y1' => '1', 'x2' => '9', 'y2' => '1'],
$base,
$base,
);
$this->assertNotSame('', $out);
$this->assertGreaterThanOrEqual(3, \substr_count($out, "q\n"));
}
/** @throws \Throwable */
public function testSvgLineIgnoresMissingMarkerRefsGracefully(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(524);
$base = $obj->exposeDefaultSVGStyle();
$base['stroke'] = '#000000';
$base['stroke-width'] = 0.5;
$base['marker-start'] = 'url(#missing)';
$base['marker-end'] = 'none';
$parser = \xml_parser_create('UTF-8');
$out = $obj->exposeParseSVGTagSTARTline(
$parser,
524,
['x1' => '0', 'y1' => '0', 'x2' => '5', 'y2' => '0'],
$base,
$base,
);
$this->assertNotSame('', $out);
$this->assertSame(1, \substr_count($out, "q\n"));
}
/** @throws \Throwable */
public function testSvgPathRendersStartMidEndMarkersWhenDefined(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(525);
$base = $obj->exposeDefaultSVGStyle();
$base['stroke'] = '#000000';
$base['stroke-width'] = 0.6;
$base['marker-start'] = 'url(#mkpath)';
$base['marker-mid'] = 'url(#mkpath)';
$base['marker-end'] = 'url(#mkpath)';
$obj->patchSvgObj(525, [
'styles' => [$base],
'defs' => [
'mkpath' => [
'name' => 'marker',
'attr' => [
'id' => 'mkpath',
'viewBox' => '0 0 10 10',
'refX' => '0',
'refY' => '5',
'markerWidth' => '4',
'markerHeight' => '4',
'orient' => 'auto',
],
'child' => [
'DF_1' => [
'name' => 'path',
'attr' => [
'id' => 'DF_1',
'd' => 'M 0 0 L 10 5 L 0 10 Z',
'fill' => '#000000',
'stroke' => 'none',
],
],
'DF_1_CLOSE' => [
'name' => 'path',
'attr' => [
'closing_tag' => true,
'content' => '',
],
],
],
],
],
]);
$parser = \xml_parser_create('UTF-8');
$out = $obj->exposeParseSVGTagSTARTpath($parser, 525, ['d' => 'M 1 1 L 9 1 L 9 7'], $base, $base);
$this->assertNotSame('', $out);
$this->assertGreaterThanOrEqual(4, \substr_count($out, "q\n"));
}
/** @throws \Throwable */
public function testSvgPolylineRendersMidMarkerWhenDefined(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(526);
$base = $obj->exposeDefaultSVGStyle();
$base['stroke'] = '#000000';
$base['stroke-width'] = 0.5;
$base['marker-start'] = 'url(#mkpoly)';
$base['marker-mid'] = 'url(#mkpoly)';
$base['marker-end'] = 'url(#mkpoly)';
$obj->patchSvgObj(526, [
'styles' => [$base],
'defs' => [
'mkpoly' => [
'name' => 'marker',
'attr' => [
'id' => 'mkpoly',
'viewBox' => '0 0 10 10',
'refX' => '0',
'refY' => '5',
'markerWidth' => '4',
'markerHeight' => '4',
'orient' => 'auto',
],
'child' => [
'DF_1' => [
'name' => 'path',
'attr' => [
'id' => 'DF_1',
'd' => 'M 0 0 L 10 5 L 0 10 Z',
'fill' => '#000000',
'stroke' => 'none',
],
],
'DF_1_CLOSE' => [
'name' => 'path',
'attr' => [
'closing_tag' => true,
'content' => '',
],
],
],
],
],
]);
$parser = \xml_parser_create('UTF-8');
$out = $obj->exposeParseSVGTagSTARTpolygon($parser, 526, ['points' => '1,1 8,1 8,6'], $base, $base, true);
$this->assertNotSame('', $out);
$this->assertGreaterThanOrEqual(4, \substr_count($out, "q\n"));
}
/** @throws \Throwable */
public function testSvgCurvedPathRendersMarkersWithoutErrors(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(527);
$base = $obj->exposeDefaultSVGStyle();
$base['stroke'] = '#000000';
$base['stroke-width'] = 0.5;
$base['marker-start'] = 'url(#mkcurve)';
$base['marker-mid'] = 'url(#mkcurve)';
$base['marker-end'] = 'url(#mkcurve)';
$obj->patchSvgObj(527, [
'styles' => [$base],
'defs' => [
'mkcurve' => [
'name' => 'marker',
'attr' => [
'id' => 'mkcurve',
'viewBox' => '0 0 10 10',
'refX' => '0',
'refY' => '5',
'markerWidth' => '4',
'markerHeight' => '4',
'orient' => 'auto',
],
'child' => [
'DF_1' => [
'name' => 'path',
'attr' => [
'id' => 'DF_1',
'd' => 'M 0 0 L 10 5 L 0 10 Z',
'fill' => '#000000',
'stroke' => 'none',
],
],
'DF_1_CLOSE' => [
'name' => 'path',
'attr' => [
'closing_tag' => true,
'content' => '',
],
],
],
],
],
]);
$parser = \xml_parser_create('UTF-8');
$out = $obj->exposeParseSVGTagSTARTpath($parser, 527, ['d' => 'M 1 1 C 3 9 7 -1 9 7 L 12 9'], $base, $base);
$this->assertNotSame('', $out);
$this->assertGreaterThanOrEqual(4, \substr_count($out, "q\n"));
}
/** @throws \Throwable */
public function testSvgClosedPathAddsMidMarkerAtClosureJoin(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(528);
$base = $obj->exposeDefaultSVGStyle();
$base['stroke'] = '#000000';
$base['stroke-width'] = 0.5;
$base['marker-start'] = 'none';
$base['marker-mid'] = 'url(#mkclose)';
$base['marker-end'] = 'none';
$obj->patchSvgObj(528, [
'styles' => [$base],
'defs' => [
'mkclose' => [
'name' => 'marker',
'attr' => [
'id' => 'mkclose',
'viewBox' => '0 0 10 10',
'refX' => '0',
'refY' => '5',
'markerWidth' => '4',
'markerHeight' => '4',
'orient' => 'auto',
],
'child' => [
'DF_1' => [
'name' => 'path',
'attr' => [
'id' => 'DF_1',
'd' => 'M 0 0 L 10 5 L 0 10 Z',
'fill' => '#000000',
'stroke' => 'none',
],
],
'DF_1_CLOSE' => [
'name' => 'path',
'attr' => [
'closing_tag' => true,
'content' => '',
],
],
],
],
],
]);
$parser = \xml_parser_create('UTF-8');
$out = $obj->exposeParseSVGTagSTARTpath($parser, 528, ['d' => 'M 1 1 L 8 1 L 8 6 Z'], $base, $base);
$this->assertNotSame('', $out);
// Closed triangle has 3 vertices, each should receive marker-mid.
$this->assertGreaterThanOrEqual(4, \substr_count($out, "q\n"));
}
/** @throws \Throwable */
public function testSvgMarkerShorthandAppliesToLineAnchors(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(529);
$base = $obj->exposeDefaultSVGStyle();
$base['stroke'] = '#000000';
$base['stroke-width'] = 0.5;
$base['marker'] = 'url(#mkall)';
$base['marker-start'] = 'none';
$base['marker-mid'] = 'none';
$base['marker-end'] = 'none';
$obj->patchSvgObj(529, [
'styles' => [$base],
'defs' => [
'mkall' => [
'name' => 'marker',
'attr' => [
'id' => 'mkall',
'viewBox' => '0 0 10 10',
'refX' => '0',
'refY' => '5',
'markerWidth' => '4',
'markerHeight' => '4',
'orient' => 'auto',
],
'child' => [
'DF_1' => [
'name' => 'path',
'attr' => [
'id' => 'DF_1',
'd' => 'M 0 0 L 10 5 L 0 10 Z',
'fill' => '#000000',
'stroke' => 'none',
],
],
'DF_1_CLOSE' => [
'name' => 'path',
'attr' => [
'closing_tag' => true,
'content' => '',
],
],
],
],
],
]);
$parser = \xml_parser_create('UTF-8');
$out = $obj->exposeParseSVGTagSTARTline(
$parser,
529,
['x1' => '1', 'y1' => '1', 'x2' => '8', 'y2' => '1'],
$base,
$base,
);
$this->assertNotSame('', $out);
$this->assertGreaterThanOrEqual(3, \substr_count($out, "q\n"));
}
/** @throws \Throwable */
public function testSvgMarkerOrientDegSuffixIsAccepted(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(530);
$base = $obj->exposeDefaultSVGStyle();
$base['stroke'] = '#000000';
$base['stroke-width'] = 0.5;
$base['marker-start'] = 'url(#mkdeg)';
$base['marker-end'] = 'url(#mkdeg)';
$obj->patchSvgObj(530, [
'styles' => [$base],
'defs' => [
'mkdeg' => [
'name' => 'marker',
'attr' => [
'id' => 'mkdeg',
'viewBox' => '0 0 10 10',
'refX' => '0',
'refY' => '5',
'markerWidth' => '4',
'markerHeight' => '4',
'orient' => '45deg',
],
'child' => [
'DF_1' => [
'name' => 'path',
'attr' => [
'id' => 'DF_1',
'd' => 'M 0 0 L 10 5 L 0 10 Z',
'fill' => '#000000',
'stroke' => 'none',
],
],
'DF_1_CLOSE' => [
'name' => 'path',
'attr' => [
'closing_tag' => true,
'content' => '',
],
],
],
],
],
]);
$parser = \xml_parser_create('UTF-8');
$out = $obj->exposeParseSVGTagSTARTline(
$parser,
530,
['x1' => '1', 'y1' => '1', 'x2' => '8', 'y2' => '1'],
$base,
$base,
);
$this->assertNotSame('', $out);
$this->assertGreaterThanOrEqual(3, \substr_count($out, "q\n"));
}
/** @throws \Throwable */
public function testSvgMarkerPercentRefMatchesAbsoluteRefInViewBox(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(531);
$base = $obj->exposeDefaultSVGStyle();
$base['stroke'] = '#000000';
$base['stroke-width'] = 0.5;
$base['marker-start'] = 'url(#mkpct)';
$base['marker-end'] = 'none';
$obj->patchSvgObj(531, [
'styles' => [$base],
'defs' => [
'mkpct' => [
'name' => 'marker',
'attr' => [
'id' => 'mkpct',
'viewBox' => '0 0 10 10',
'refX' => '50%',
'refY' => '50%',
'markerWidth' => '4',
'markerHeight' => '4',
'orient' => '0',
],
'child' => [
'DF_1' => [
'name' => 'path',
'attr' => [
'id' => 'DF_1',
'd' => 'M 0 0 L 10 5 L 0 10 Z',
'fill' => '#000000',
'stroke' => 'none',
],
],
'DF_1_CLOSE' => [
'name' => 'path',
'attr' => [
'closing_tag' => true,
'content' => '',
],
],
],
],
'mkabs' => [
'name' => 'marker',
'attr' => [
'id' => 'mkabs',
'viewBox' => '0 0 10 10',
'refX' => '5',
'refY' => '5',
'markerWidth' => '4',
'markerHeight' => '4',
'orient' => '0',
],
'child' => [
'DF_1' => [
'name' => 'path',
'attr' => [
'id' => 'DF_1',
'd' => 'M 0 0 L 10 5 L 0 10 Z',
'fill' => '#000000',
'stroke' => 'none',
],
],
'DF_1_CLOSE' => [
'name' => 'path',
'attr' => [
'closing_tag' => true,
'content' => '',
],
],
],
],
],
]);
$parser = \xml_parser_create('UTF-8');
$outPct = $obj->exposeParseSVGTagSTARTline(
$parser,
531,
['x1' => '1', 'y1' => '1', 'x2' => '8', 'y2' => '1'],
$base,
$base,
);
$base['marker-start'] = 'url(#mkabs)';
$outAbs = $obj->exposeParseSVGTagSTARTline(
$parser,
531,
['x1' => '1', 'y1' => '1', 'x2' => '8', 'y2' => '1'],
$base,
$base,
);
$this->assertNotSame('', $outPct);
$this->assertSame($outAbs, $outPct);
}
/** @throws \Throwable */
public function testSvgMarkerPreserveAspectRatioChangesOutputTransform(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(532);
$base = $obj->exposeDefaultSVGStyle();
$base['stroke'] = '#000000';
$base['stroke-width'] = 0.5;
$base['marker-start'] = 'url(#mknone)';
$base['marker-end'] = 'none';
$obj->patchSvgObj(532, [
'styles' => [$base],
'defs' => [
'mknone' => [
'name' => 'marker',
'attr' => [
'id' => 'mknone',
'viewBox' => '0 0 10 20',
'refX' => '0',
'refY' => '0',
'markerWidth' => '6',
'markerHeight' => '6',
'preserveAspectRatio' => 'none',
'orient' => '0',
],
'child' => [
'DF_1' => [
'name' => 'path',
'attr' => [
'id' => 'DF_1',
'd' => 'M 0 0 L 10 5 L 0 10 Z',
'fill' => '#000000',
'stroke' => 'none',
],
],
'DF_1_CLOSE' => [
'name' => 'path',
'attr' => [
'closing_tag' => true,
'content' => '',
],
],
],
],
'mkmeet' => [
'name' => 'marker',
'attr' => [
'id' => 'mkmeet',
'viewBox' => '0 0 10 20',
'refX' => '0',
'refY' => '0',
'markerWidth' => '6',
'markerHeight' => '6',
'preserveAspectRatio' => 'xMidYMid meet',
'orient' => '0',
],
'child' => [
'DF_1' => [
'name' => 'path',
'attr' => [
'id' => 'DF_1',
'd' => 'M 0 0 L 10 5 L 0 10 Z',
'fill' => '#000000',
'stroke' => 'none',
],
],
'DF_1_CLOSE' => [
'name' => 'path',
'attr' => [
'closing_tag' => true,
'content' => '',
],
],
],
],
],
]);
$parser = \xml_parser_create('UTF-8');
$outNone = $obj->exposeParseSVGTagSTARTline(
$parser,
532,
['x1' => '1', 'y1' => '1', 'x2' => '8', 'y2' => '1'],
$base,
$base,
);
$base['marker-start'] = 'url(#mkmeet)';
$outMeet = $obj->exposeParseSVGTagSTARTline(
$parser,
532,
['x1' => '1', 'y1' => '1', 'x2' => '8', 'y2' => '1'],
$base,
$base,
);
$this->assertNotSame('', $outNone);
$this->assertNotSame('', $outMeet);
$this->assertNotSame($outNone, $outMeet);
}
/** @throws \Throwable */
public function testSvgMarkerSpecificAnchorsOverrideShorthand(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(533);
$base = $obj->exposeDefaultSVGStyle();
$base['stroke'] = '#000000';
$base['stroke-width'] = 0.5;
$base['marker'] = 'url(#mkall2)';
$base['marker-start'] = 'none';
$base['marker-mid'] = 'url(#mkall2)';
$base['marker-end'] = 'url(#mkall2)';
$obj->patchSvgObj(533, [
'styles' => [$base],
'defs' => [
'mkall2' => [
'name' => 'marker',
'attr' => [
'id' => 'mkall2',
'viewBox' => '0 0 10 10',
'refX' => '0',
'refY' => '5',
'markerWidth' => '4',
'markerHeight' => '4',
'orient' => 'auto',
],
'child' => [
'DF_1' => [
'name' => 'path',
'attr' => [
'id' => 'DF_1',
'd' => 'M 0 0 L 10 5 L 0 10 Z',
'fill' => '#000000',
'stroke' => 'none',
],
],
'DF_1_CLOSE' => [
'name' => 'path',
'attr' => [
'closing_tag' => true,
'content' => '',
],
],
],
],
],
]);
$parser = \xml_parser_create('UTF-8');
$out = $obj->exposeParseSVGTagSTARTline(
$parser,
533,
['x1' => '1', 'y1' => '1', 'x2' => '8', 'y2' => '1'],
$base,
$base,
);
$this->assertNotSame('', $out);
// Base line transform, then one end marker only (no start marker) with
// its own transform and the transform of the path it contains.
$this->assertSame(3, \substr_count($out, "q\n"));
}
/** @throws \Throwable */
public function testSvgMarkerPreserveAspectRatioDeferMatchesEquivalentValue(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(534);
$base = $obj->exposeDefaultSVGStyle();
$base['stroke'] = '#000000';
$base['stroke-width'] = 0.5;
$base['marker-start'] = 'url(#mkdefer)';
$base['marker-end'] = 'none';
$obj->patchSvgObj(534, [
'styles' => [$base],
'defs' => [
'mkdefer' => [
'name' => 'marker',
'attr' => [
'id' => 'mkdefer',
'viewBox' => '0 0 10 20',
'refX' => '0',
'refY' => '0',
'markerWidth' => '6',
'markerHeight' => '6',
'preserveAspectRatio' => 'defer xMaxYMax slice',
'orient' => '0',
],
'child' => [
'DF_1' => [
'name' => 'path',
'attr' => [
'id' => 'DF_1',
'd' => 'M 0 0 L 10 5 L 0 10 Z',
'fill' => '#000000',
'stroke' => 'none',
],
],
'DF_1_CLOSE' => [
'name' => 'path',
'attr' => [
'closing_tag' => true,
'content' => '',
],
],
],
],
'mkeq' => [
'name' => 'marker',
'attr' => [
'id' => 'mkeq',
'viewBox' => '0 0 10 20',
'refX' => '0',
'refY' => '0',
'markerWidth' => '6',
'markerHeight' => '6',
'preserveAspectRatio' => 'xMaxYMax slice',
'orient' => '0',
],
'child' => [
'DF_1' => [
'name' => 'path',
'attr' => [
'id' => 'DF_1',
'd' => 'M 0 0 L 10 5 L 0 10 Z',
'fill' => '#000000',
'stroke' => 'none',
],
],
'DF_1_CLOSE' => [
'name' => 'path',
'attr' => [
'closing_tag' => true,
'content' => '',
],
],
],
],
],
]);
$parser = \xml_parser_create('UTF-8');
$outDefer = $obj->exposeParseSVGTagSTARTline(
$parser,
534,
['x1' => '1', 'y1' => '1', 'x2' => '8', 'y2' => '1'],
$base,
$base,
);
$base['marker-start'] = 'url(#mkeq)';
$outEq = $obj->exposeParseSVGTagSTARTline(
$parser,
534,
['x1' => '1', 'y1' => '1', 'x2' => '8', 'y2' => '1'],
$base,
$base,
);
$this->assertNotSame('', $outDefer);
$this->assertSame($outEq, $outDefer);
}
/** @throws \Throwable */
public function testSvgMarkerUnitsUserSpaceOnUseIgnoresStrokeWidthScale(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(535);
$base = $obj->exposeDefaultSVGStyle();
$base['stroke'] = 'none';
$base['marker-start'] = 'url(#mkus)';
$base['marker-end'] = 'none';
$obj->patchSvgObj(535, [
'styles' => [$base],
'defs' => [
'mkus' => [
'name' => 'marker',
'attr' => [
'id' => 'mkus',
'viewBox' => '0 0 10 10',
'refX' => '0',
'refY' => '5',
'markerWidth' => '4',
'markerHeight' => '4',
'markerUnits' => 'userSpaceOnUse',
'orient' => '0',
],
'child' => [
'DF_1' => [
'name' => 'path',
'attr' => [
'id' => 'DF_1',
'd' => 'M 0 0 L 10 5 L 0 10 Z',
'fill' => '#000000',
'stroke' => 'none',
],
],
'DF_1_CLOSE' => [
'name' => 'path',
'attr' => [
'closing_tag' => true,
'content' => '',
],
],
],
],
],
]);
$parser = \xml_parser_create('UTF-8');
$base['stroke-width'] = 0.5;
$outThin = $obj->exposeParseSVGTagSTARTline(
$parser,
535,
['x1' => '1', 'y1' => '1', 'x2' => '8', 'y2' => '1'],
$base,
$base,
);
$base['stroke-width'] = 3.0;
$outThick = $obj->exposeParseSVGTagSTARTline(
$parser,
535,
['x1' => '1', 'y1' => '1', 'x2' => '8', 'y2' => '1'],
$base,
$base,
);
$this->assertNotSame('', $outThin);
$this->assertSame($outThin, $outThick);
}
/** @throws \Throwable */
public function testSvgMarkerUnitsStrokeWidthScalesWithStrokeWidth(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(536);
$base = $obj->exposeDefaultSVGStyle();
$base['stroke'] = 'none';
$base['marker-start'] = 'url(#mksw)';
$base['marker-end'] = 'none';
$obj->patchSvgObj(536, [
'styles' => [$base],
'defs' => [
'mksw' => [
'name' => 'marker',
'attr' => [
'id' => 'mksw',
'viewBox' => '0 0 10 10',
'refX' => '0',
'refY' => '5',
'markerWidth' => '4',
'markerHeight' => '4',
'markerUnits' => 'strokeWidth',
'orient' => '0',
],
'child' => [
'DF_1' => [
'name' => 'path',
'attr' => [
'id' => 'DF_1',
'd' => 'M 0 0 L 10 5 L 0 10 Z',
'fill' => '#000000',
'stroke' => 'none',
],
],
'DF_1_CLOSE' => [
'name' => 'path',
'attr' => [
'closing_tag' => true,
'content' => '',
],
],
],
],
],
]);
$parser = \xml_parser_create('UTF-8');
$base['stroke-width'] = 0.5;
$outThin = $obj->exposeParseSVGTagSTARTline(
$parser,
536,
['x1' => '1', 'y1' => '1', 'x2' => '8', 'y2' => '1'],
$base,
$base,
);
$base['stroke-width'] = 3.0;
$outThick = $obj->exposeParseSVGTagSTARTline(
$parser,
536,
['x1' => '1', 'y1' => '1', 'x2' => '8', 'y2' => '1'],
$base,
$base,
);
$this->assertNotSame('', $outThin);
$this->assertNotSame($outThin, $outThick);
}
/** @throws \Throwable */
public function testSvgMarkerAutoStartReverseDiffersFromAutoAtStart(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(537);
$base = $obj->exposeDefaultSVGStyle();
$base['stroke'] = 'none';
$base['stroke-width'] = 1.0;
$base['marker-start'] = 'url(#mkauto)';
$base['marker-end'] = 'none';
$obj->patchSvgObj(537, [
'styles' => [$base],
'defs' => [
'mkauto' => [
'name' => 'marker',
'attr' => [
'id' => 'mkauto',
'viewBox' => '0 0 10 10',
'refX' => '0',
'refY' => '5',
'markerWidth' => '4',
'markerHeight' => '4',
'orient' => 'auto',
],
'child' => [
'DF_1' => [
'name' => 'path',
'attr' => [
'id' => 'DF_1',
'd' => 'M 0 0 L 10 5 L 0 10 Z',
'fill' => '#000000',
'stroke' => 'none',
],
],
'DF_1_CLOSE' => [
'name' => 'path',
'attr' => [
'closing_tag' => true,
'content' => '',
],
],
],
],
'mkrev' => [
'name' => 'marker',
'attr' => [
'id' => 'mkrev',
'viewBox' => '0 0 10 10',
'refX' => '0',
'refY' => '5',
'markerWidth' => '4',
'markerHeight' => '4',
'orient' => 'auto-start-reverse',
],
'child' => [
'DF_1' => [
'name' => 'path',
'attr' => [
'id' => 'DF_1',
'd' => 'M 0 0 L 10 5 L 0 10 Z',
'fill' => '#000000',
'stroke' => 'none',
],
],
'DF_1_CLOSE' => [
'name' => 'path',
'attr' => [
'closing_tag' => true,
'content' => '',
],
],
],
],
],
]);
$parser = \xml_parser_create('UTF-8');
$outAuto = $obj->exposeParseSVGTagSTARTline(
$parser,
537,
['x1' => '1', 'y1' => '1', 'x2' => '8', 'y2' => '1'],
$base,
$base,
);
$base['marker-start'] = 'url(#mkrev)';
$outRev = $obj->exposeParseSVGTagSTARTline(
$parser,
537,
['x1' => '1', 'y1' => '1', 'x2' => '8', 'y2' => '1'],
$base,
$base,
);
$this->assertNotSame('', $outAuto);
$this->assertNotSame('', $outRev);
$this->assertNotSame($outAuto, $outRev);
}
/** @throws \Throwable */
public function testSvgHandleStartInheritAndUnknownTagBranches(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(53);
$base = $obj->exposeDefaultSVGStyle();
$obj->patchSvgObj(53, [
'styles' => [$base],
'clipmode' => false,
'textmode' => ['invisible' => false, 'stroke' => 0, 'rtl' => false, 'text-anchor' => 'start'],
]);
$parser = \xml_parser_create('UTF-8');
$obj->exposeHandleSVGTagStart(
$parser,
'unknownTag',
[
'style' => 'color:inherit;display:none;',
'transform' => 'translate(1,2) rotate(5)',
],
53,
false,
[1, 0, 0, 1, 0, 0],
);
$svgobj = $obj->getSvgObj(53);
$this->assertArrayHasKey('out', $svgobj);
$this->assertTrue($svgobj['textmode']['invisible']);
}
/** @throws \Throwable */
public function testSvgClipPathInvisibleAndShapeImageGuardBranches(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(54);
$base = $obj->exposeDefaultSVGStyle();
$parser = \xml_parser_create('UTF-8');
$obj->patchSvgObj(54, ['textmode' => ['invisible' => true]]);
$this->assertSame('', $obj->exposeParseSVGTagSTARTclipPath(54, [1, 0, 0, 1, 0, 0]));
$obj->patchSvgObj(54, ['textmode' => ['invisible' => false], 'clipmode' => true]);
$ellipseClip = $obj->exposeParseSVGTagSTARTellipse(
$parser,
54,
['cx' => '5', 'cy' => '6', 'rx' => '3', 'ry' => '2'],
$base,
$base,
);
$this->assertNotSame('', $ellipseClip);
$lineClip = $obj->exposeParseSVGTagSTARTline(
$parser,
54,
['x1' => '0', 'y1' => '0', 'x2' => '5', 'y2' => '5'],
$base,
$base,
);
$this->assertSame('', $lineClip);
$imgNoHref = $obj->exposeParseSVGTagSTARTimage($parser, 54, [], $base, $base);
$this->assertSame('', $imgNoHref);
$imgClip = $obj->exposeParseSVGTagSTARTimage(
$parser,
54,
['xlink:href' => 'image.png', 'x' => '1', 'y' => '1', 'width' => '2', 'height' => '2'],
$base,
$base,
);
$this->assertSame('', $imgClip);
}
/** @throws \Throwable */
public function testSvgAddSVGAlignmentAndScalingBranches(): void
{
$obj = $this->getTestObject();
$page = $this->initFontAndPage($obj);
$svgSlice =
'@<svg xmlns="http://www.w3.org/2000/svg"'
. ' width="20" height="10" viewBox="0 0 20 10" preserveAspectRatio="xMaxYMax slice">'
. '<rect x="0" y="0" width="20" height="10" fill="#ffcc00"/>'
. '</svg>';
$idSlice = $obj->addSVG($svgSlice, 10, 12, 30, 10, $page['height']);
$this->assertGreaterThan(0, $idSlice);
$svgMeet =
'@<svg xmlns="http://www.w3.org/2000/svg"'
. ' width="10" height="20" viewBox="0 0 10 20" preserveAspectRatio="xMidYMid meet">'
. '<circle cx="5" cy="10" r="4" fill="#00ccff"/>'
. '</svg>';
$idMeet = $obj->addSVG($svgMeet, 15, 18, 0, 25, $page['height']);
$this->assertGreaterThan($idSlice, $idMeet);
$svgNone =
'@<svg xmlns="http://www.w3.org/2000/svg"'
. ' width="12" height="12" viewBox="0 0 12 12" preserveAspectRatio="none">'
. '<line x1="0" y1="0" x2="12" y2="12" stroke="#000"/>'
. '</svg>';
$idNone = $obj->addSVG($svgNone, 3, 4, 0, 0, $page['height']);
$this->assertGreaterThan($idMeet, $idNone);
}
/** @throws \Throwable */
public function testSvgRemainingHelperGradientAndFillBranches(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(55);
$base = $obj->exposeDefaultSVGStyle();
$this->assertSame(40.0, $obj->exposeGetTAFontStretching('ultra-condensed'));
$this->assertSame(55.0, $obj->exposeGetTAFontStretching('extra-condensed'));
$this->assertSame(85.0, $obj->exposeGetTAFontStretching('semi-condensed'));
$this->assertSame(100.0, $obj->exposeGetTAFontStretching('normal'));
$this->assertSame(115.0, $obj->exposeGetTAFontStretching('semi-expanded'));
$this->assertSame(130.0, $obj->exposeGetTAFontStretching('expanded'));
$this->assertSame(145.0, $obj->exposeGetTAFontStretching('extra-expanded'));
$this->assertSame(160.0, $obj->exposeGetTAFontStretching('ultra-expanded'));
$fill = $base;
$fill['fill'] = 'rgba(255,0,0,0.5)';
$fill['opacity'] = 0.6;
$fill['fill-opacity'] = 0.5;
$fill['mix-blend-mode'] = 'multiply';
[$fillOut, $fill] = $obj->exposeParseSVGStyleFill(55, $fill, [], 0, 0, 5, 5);
$this->assertNotSame('', $fillOut);
$this->assertStringContainsString('F', $fill['objstyle']);
$fillBad = $base;
$fillBad['fill'] = 'transparent';
[$badOut] = $obj->exposeParseSVGStyleFill(55, $fillBad, [], 0, 0, 5, 5);
$this->assertSame('', $badOut);
$parser = \xml_parser_create('UTF-8');
$obj->exposeParseSVGStyleClipPath($parser, 55, [
'cp1' => [
'name' => 'rect',
'attr' => ['x' => '1', 'y' => '1', 'width' => '2', 'height' => '2'],
'tm' => [1, 0, 0, 1, 0, 0],
],
]);
$this->assertNotSame('', $obj->getSvgObj(55)['out']);
$this->assertSame('', $obj->exposeParseSVGTagSTARTlinearGradient(55, [
'gradientTransform' => 'matrix(1 0 0 1 1 2)',
]));
$this->assertSame('', $obj->exposeParseSVGTagSTARTradialGradient(55, ['r' => '0.5']));
$svgobj = $obj->getSvgObj(55);
$this->assertNotSame('', $svgobj['gradientid']);
$this->assertNotEmpty($svgobj['gradients']);
}
/** @throws \Throwable */
public function testSvgAdditionalEdgeBranchesForCoverage(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(56);
$base = $obj->exposeDefaultSVGStyle();
$parser = \xml_parser_create('UTF-8');
// cover defs-mode early-return branches
$obj->patchSvgObj(56, ['defsmode' => true, 'defs' => [], 'text' => 't']);
$obj->exposeHandleSVGTagEnd($parser, 'unknown');
$obj->exposeHandleSVGTagStart($parser, 'unknown', ['id' => 'd1'], 56);
// style inherit + dispatch branches including polyline/image/use/tspan
$obj->patchSvgObj(56, [
'defsmode' => false,
'clipmode' => false,
'styles' => [$base],
'textmode' => ['invisible' => false, 'stroke' => 0, 'rtl' => false, 'text-anchor' => 'start'],
]);
$obj->exposeHandleSVGTagStart($parser, 'polyline', ['points' => '0,0 1,1 2,2', 'style' => 'color:inherit'], 56);
$obj->exposeHandleSVGTagStart($parser, 'defs', [], 56);
$obj->patchSvgObj(56, ['clipmode' => false]);
$obj->exposeHandleSVGTagStart($parser, 'line', ['x1' => '0', 'y1' => '0', 'x2' => '1', 'y2' => '1'], 56);
// path clipmode and empty-d guard
$obj->patchSvgObj(56, ['clipmode' => true]);
$this->assertSame('', $obj->exposeParseSVGTagSTARTpath($parser, 56, ['d' => ''], $base, $base));
$clipPathOut = $obj->exposeParseSVGTagSTARTpath($parser, 56, ['d' => 'M 0 0 L 2 2'], $base, $base);
$this->assertNotSame('', $clipPathOut);
// circle/ellipse fallback x/y and clip branches
$circleClip = $obj->exposeParseSVGTagSTARTcircle(
$parser,
56,
['x' => '4', 'y' => '5', 'r' => '2'],
$base,
$base,
);
$this->assertNotSame('', $circleClip);
$ellipseClip = $obj->exposeParseSVGTagSTARTellipse(
$parser,
56,
['x' => '4', 'y' => '5', 'rx' => '2', 'ry' => '1'],
$base,
$base,
);
$this->assertNotSame('', $ellipseClip);
// polygon invalid and clip branches
$obj->patchSvgObj(56, ['clipmode' => false]);
$this->assertSame('', $obj->exposeParseSVGTagSTARTpolygon($parser, 56, ['points' => '0,0 1'], $base, $base));
$obj->patchSvgObj(56, ['clipmode' => true]);
$polyClip = $obj->exposeParseSVGTagSTARTpolygon($parser, 56, ['points' => '0,0 2,0 2,2 0,2'], $base, $base);
$this->assertNotSame('', $polyClip);
// image guards and svg child path
$obj->patchSvgObj(56, ['clipmode' => false, 'dir' => __DIR__]);
$this->assertSame('', $obj->exposeParseSVGTagSTARTimage($parser, 56, [], $base, $base));
$svgTmp = \tempnam(\sys_get_temp_dir(), 'tc-svg-child-');
$this->assertNotFalse($svgTmp);
$svgPath = $svgTmp . '.svg';
\rename($svgTmp, $svgPath);
\file_put_contents(
$svgPath,
'<svg xmlns="http://www.w3.org/2000/svg" width="2" height="2">'
. '<rect x="0" y="0" width="2" height="2" fill="#000"/></svg>',
);
try {
$svgImgOut = $obj->exposeParseSVGTagSTARTimage(
$parser,
56,
['xlink:href' => $svgPath, 'x' => '1', 'y' => '1', 'width' => '2', 'height' => '2'],
$base,
$base,
);
$this->assertNotSame('', $svgImgOut);
} finally {
if (\file_exists($svgPath)) {
\unlink($svgPath);
}
}
// text defaults without explicit anchor/direction/stroke
$textBase = $base;
$textBase['text-anchor'] = '';
$textBase['direction'] = '';
$textBase['stroke'] = 'none';
$textOut = $obj->exposeParseSVGTagSTARTtext($parser, 56, ['x' => '1', 'y' => '1'], $textBase, $base);
$this->assertNotSame('', $textOut);
// use: unset id + non-string name guard
$obj->patchSvgObj(56, [
'defs' => [
'u1' => [
'name' => [],
'attr' => ['id' => 'u1', 'x' => '1', 'y' => '2', 'style' => 'fill:#000;'],
],
],
]);
$this->assertSame('', $obj->exposeParseSVGTagSTARTuse($parser, 56, [
'xlink:href' => '#u1',
'id' => 'tmp',
'x' => '2',
'y' => '3',
]));
// raw svg + size fallback branches
$emptyFile = \tempnam(\sys_get_temp_dir(), 'tc-svg-empty-');
$this->assertNotFalse($emptyFile);
try {
\file_put_contents($emptyFile, '');
$this->assertSame('', $obj->exposeGetRawSVGData($emptyFile));
} finally {
if (\file_exists($emptyFile)) {
\unlink($emptyFile);
}
}
$this->assertSame(0.0, $obj->exposeGetSVGSize('not svg data')['width']);
$this->assertSame('xMidYMid', $obj->exposeGetSVGSize('<svg viewBox="0 0 10 10"></svg>')['ar_align']);
// prescan stop outside gradient
$obj->initSvgObjForHandlers(57);
$obj->exposePrescanSVGGradients('<svg><stop offset="0%" stop-color="#000"/></svg>', 57);
// gradient mode branches including pdfa guards
$obj->initSvgObjForHandlers(58);
$this->setObjectProperty($obj, 'pdfa', 1);
$this->assertSame('', $obj->exposeParseSVGTagSTARTlinearGradient(58, []));
$this->assertSame('', $obj->exposeParseSVGTagSTARTradialGradient(58, []));
$this->setObjectProperty($obj, 'pdfa', 0);
$obj->exposeParseSVGTagSTARTlinearGradient(58, [
'x1' => '1',
'y1' => '2',
'x2' => '3',
'y2' => '4',
'xlink:href' => '#gref',
]);
$obj->exposeParseSVGTagSTARTradialGradient(58, [
'r' => '0.5',
'gradientTransform' => 'matrix(1 0 0 1 1 1)',
'xlink:href' => '#gref2',
]);
// addSVG parser error + width/height branch variants
$objMain = $this->getTestObject();
$page = $this->initFontAndPage($objMain);
$this->bcExpectException(\Com\Tecnick\Pdf\Exception::class);
$objMain->addSVG('@<svg><g></svg>', 1, 1, 2, 2, $page['height']);
}
/** @throws \Throwable */
public function testSvgHandlersCoverAdditionalDispatchAndEarlyReturns(): void
{
$parser = \xml_parser_create('UTF-8');
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$base = $obj->exposeDefaultSVGStyle();
$obj->initSvgObjForHandlers(59);
$obj->patchSvgObj(59, [
'styles' => [$base],
'clipmode' => false,
'defsmode' => false,
'textmode' => ['invisible' => false, 'stroke' => 0, 'rtl' => false, 'text-anchor' => 'start'],
'out' => '',
'x' => 1.0,
'y' => 1.0,
'text' => 'txt',
'child' => [],
]);
$obj->exposeHandleSVGTagStart($parser, 'clipPath', ['id' => 'cp1'], 59);
$obj->exposeHandleSVGTagStart(
$parser,
'linearGradient',
['id' => 'lg1', 'x1' => '0', 'y1' => '0', 'x2' => '100%', 'y2' => '0%'],
59,
);
$obj->exposeHandleSVGTagStart(
$parser,
'radialGradient',
['id' => 'rg1', 'cx' => '50%', 'cy' => '50%', 'r' => '0.5'],
59,
);
$obj->exposeHandleSVGTagStart($parser, 'stop', ['offset' => '0%'], 59);
$obj->exposeHandleSVGTagStart($parser, 'polygon', ['points' => '0,0 1,0 1,1'], 59);
$obj->exposeHandleSVGTagStart($parser, 'text', ['x' => '1', 'y' => '1'], 59);
$obj->exposeHandleSVGTagStart($parser, 'tspan', ['x' => '1', 'y' => '1'], 59);
$obj->initSvgObjForHandlers(61);
$obj->patchSvgObj(61, ['styles' => [$base], 'text' => 'txt', 'x' => 1.0, 'y' => 1.0]);
$obj->exposeHandleSVGTagEnd($parser, 'clipPath');
$obj->patchSvgObj(61, ['styles' => [$base], 'text' => 'txt', 'x' => 1.0, 'y' => 1.0]);
$obj->exposeHandleSVGTagEnd($parser, 'g');
$obj->patchSvgObj(61, ['styles' => [$base], 'text' => 'txt', 'x' => 1.0, 'y' => 1.0]);
$obj->exposeHandleSVGTagEnd($parser, 'text');
$obj->patchSvgObj(61, ['styles' => [$base], 'text' => 'txt', 'x' => 1.0, 'y' => 1.0]);
$obj->exposeHandleSVGTagEnd($parser, 'tspan');
$this->assertArrayHasKey('out', $obj->getSvgObj(59));
}
/** @throws \Throwable */
public function testSvgAddSvgCoversParseErrorAndInvalidInputBranches(): void
{
$obj = $this->getTestObject();
$page = $this->initFontAndPage($obj);
$tmpRel = __DIR__ . '/../tmp-svg-rel.svg';
\file_put_contents(
$tmpRel,
'<svg xmlns="http://www.w3.org/2000/svg" width="2" height="2">'
. '<rect x="0" y="0" width="2" height="2"/></svg>',
);
try {
$svgId = $obj->addSVG('tmp-svg-rel.svg', 1, 1, 2, 2, $page['height']);
$this->assertGreaterThan(0, $svgId);
} finally {
if (\file_exists($tmpRel)) {
\unlink($tmpRel);
}
}
$this->bcExpectException(\Com\Tecnick\Pdf\Exception::class);
$obj->addSVG('/path/does-not-exist.svg', 1, 1, 2, 2, $page['height']);
}
/** @throws \Throwable */
public function testSvgAddSvgThrowsOnXmlParseErrorAfterSizeParsing(): void
{
$obj = $this->getTestObject();
$page = $this->initFontAndPage($obj);
$this->bcExpectException(\Com\Tecnick\Pdf\Exception::class);
$obj->addSVG(
'@<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"><g></svg>',
1,
1,
1,
1,
$page['height'],
);
}
/** @throws \Throwable */
public function testSvgImageTagCoversNestedSvgFailureAndBase64Path(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$parser = \xml_parser_create('UTF-8');
$base = $obj->exposeDefaultSVGStyle();
$obj->initSvgObjForHandlers(60);
$obj->patchSvgObj(60, [
'clipmode' => false,
'dir' => __DIR__,
'styles' => [$base],
'textmode' => ['invisible' => false],
]);
$badSvg = __DIR__ . '/fixtures/invalid-child.svg';
\file_put_contents($badSvg, '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"><g></svg>');
try {
$this->assertSame('', $obj->exposeParseSVGTagSTARTimage(
$parser,
60,
['xlink:href' => $badSvg, 'x' => '0', 'y' => '0', 'width' => '1', 'height' => '1'],
$base,
$base,
));
} finally {
if (\file_exists($badSvg)) {
\unlink($badSvg);
}
}
$onePx = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7ZkQAAAABJRU5ErkJggg==';
try {
$imgOut = $obj->exposeParseSVGTagSTARTimage(
$parser,
60,
[
'xlink:href' => 'data:image/png;base64,' . $onePx,
'x' => '0',
'y' => '0',
'width' => '1',
'height' => '1',
],
$base,
$base,
);
$this->assertNotSame('', $imgOut);
} catch (\Throwable $e) {
$this->assertNotSame('', $e->getMessage());
}
}
/** @throws \Throwable */
public function testSvgAdditionalTransformAndDispatchBranches(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->setSvgRefUnit(62);
$base = $obj->exposeDefaultSVGStyle();
$parser = \xml_parser_create('UTF-8');
$tmSkew = $obj->exposeGetSVGTransformMatrix('skewX(10) skewY(5)');
$this->assertCount(6, $tmSkew);
// Uppercase command forces the default match arm.
$tmDefault = $obj->exposeGetSVGTransformMatrix('SKEWX(10)');
$this->assertCount(6, $tmDefault);
$this->assertSame('', $obj->exposeGetSVGPath(62, '', 'S'));
$this->assertSame(90.0, $obj->exposeGetTAFontStretching('inherit', 90));
$strokeStyle = $base;
$strokeStyle['stroke'] = 'not-a-color';
try {
[$strokeOut] = $obj->exposeParseSVGStyleStroke(62, $strokeStyle);
$this->assertSame('', $strokeOut);
} catch (\Throwable $e) {
$this->assertNotSame('', $e->getMessage());
}
$fresh = new TestableSVG();
$fresh->exposeHandleSVGCharacter($parser, 'orphan');
$this->assertSame('', $fresh->exposeGetSVGExtGState(null, null, ''));
$obj->initSvgObjForHandlers(63);
$obj->patchSvgObj(63, [
'styles' => [$base],
'textmode' => ['invisible' => false, 'stroke' => 0, 'rtl' => false, 'text-anchor' => 'end'],
'x' => 2.0,
'y' => 3.0,
'text' => 'anchor-end',
]);
$this->assertNotSame('', $obj->exposeParseSVGTagENDtext(63));
$obj->initSvgObjForHandlers(64);
$obj->patchSvgObj(64, [
'styles' => [$base],
'defsmode' => false,
'clipmode' => false,
'textmode' => ['invisible' => false, 'stroke' => 0, 'rtl' => false, 'text-anchor' => 'start'],
]);
$obj->exposeHandleSVGTagStart($parser, 'g', ['fill' => 'inherit'], 64);
$obj->exposeHandleSVGTagStart(
$parser,
'linearGradient',
['id' => 'lgx', 'gradientUnits' => 'userSpaceOnUse'],
64,
);
$obj->exposeHandleSVGTagStart(
$parser,
'radialGradient',
['id' => 'rgx', 'gradientUnits' => 'userSpaceOnUse', 'r' => '0.5', 'cx' => '2', 'cy' => '3'],
64,
);
$obj->exposeHandleSVGTagStart($parser, 'stop', ['offset' => '50%'], 64);
$obj->exposeHandleSVGTagStart($parser, 'ellipse', ['cx' => '3', 'cy' => '4', 'rx' => '2', 'ry' => '1'], 64);
$obj->exposeHandleSVGTagStart($parser, 'polygon', ['points' => '0,0 2,0 2,2'], 64);
try {
$obj->exposeHandleSVGTagStart($parser, 'image', ['xlink:href' => 'missing.png'], 64);
} catch (\Throwable $e) {
$this->assertNotSame('', $e->getMessage());
}
$obj->exposeHandleSVGTagStart($parser, 'text', ['x' => '1', 'y' => '1'], 64);
$obj->exposeHandleSVGTagStart($parser, 'tspan', ['dx' => '1'], 64);
$obj->exposeHandleSVGTagStart($parser, 'use', ['xlink:href' => '#missing'], 64);
$svgOut = $obj->getSvgObj(64)['out'];
$this->assertIsString($svgOut);
}
/** @throws \Throwable */
public function testSvgGradientAndFillRemainingBranches(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(65);
$obj->setSvgObjMeta(65);
$base = $obj->exposeDefaultSVGStyle();
$gradients = [
'eq' => [
'xref' => '',
'type' => 2,
'gradientUnits' => 'objectBoundingBox',
'mode' => 'measure',
'coords' => [0.0, 0.0, 0.0, 0.0],
'stops' => [
['color' => '#000000', 'opacity' => 1.0, 'offset' => 0.0],
['color' => '#ffffff', 'opacity' => 1.0, 'offset' => 1.0],
],
'gradientTransform' => [1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
],
];
$grOut = $obj->exposeParseSVGStyleGradient(65, $gradients, 'eq', 0.0, 0.0, 0.0, 0.0);
$this->assertNotSame('', $grOut);
$fillStyle = $base;
$fillStyle['fill'] = 'url(#eq)';
$fillStyle['opacity'] = 0.5;
$fillStyle['fill-opacity'] = 0.5;
$fillStyle['mix-blend-mode'] = 'multiply';
[$fillOut] = $obj->exposeParseSVGStyleFill(65, $fillStyle, $gradients, 0.0, 0.0, 5.0, 5.0);
$this->assertNotSame('', $fillOut);
}
/** @throws \Throwable */
public function testSvgAddSvgScalingAndRecursiveChildBranches(): void
{
$obj = $this->getTestObject();
$page = $this->initFontAndPage($obj);
$svgSlice =
'@<svg xmlns="http://www.w3.org/2000/svg"'
. ' width="100" height="10" viewBox="0 0 100 10" preserveAspectRatio="xMinYMin slice">'
. '<rect x="0" y="0" width="100" height="10" fill="#cccccc"/>'
. '</svg>';
$idSlice = $obj->addSVG($svgSlice, 2, 2, 100, 100, $page['height']);
$this->assertGreaterThan(0, $idSlice);
$svgMeet =
'@<svg xmlns="http://www.w3.org/2000/svg"'
. ' width="10" height="100" viewBox="0 0 10 100" preserveAspectRatio="xMinYMin meet">'
. '<rect x="0" y="0" width="10" height="100" fill="#999999"/>'
. '</svg>';
$idMeet = $obj->addSVG($svgMeet, 3, 3, 100, 100, $page['height']);
$this->assertGreaterThan($idSlice, $idMeet);
$svgAutoHeight =
'@<svg xmlns="http://www.w3.org/2000/svg" width="20" height="10">'
. '<rect x="0" y="0" width="20" height="10"/></svg>';
$idAutoHeight = $obj->addSVG($svgAutoHeight, 4, 4, 30, 0, $page['height']);
$this->assertGreaterThan($idMeet, $idAutoHeight);
$tmpDir = (string) \realpath(\sys_get_temp_dir());
$childPath = $tmpDir . '/tc-svg-child-recursive.svg';
$parentPath = $tmpDir . '/tc-svg-parent-recursive.svg';
\file_put_contents(
$childPath,
'<svg xmlns="http://www.w3.org/2000/svg" width="3" height="3">'
. '<rect x="0" y="0" width="3" height="3" fill="#000"/></svg>',
);
\file_put_contents(
$parentPath,
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10">'
. '<image x="0" y="0" width="5" height="5" xlink:href="tc-svg-child-recursive.svg"/></svg>',
);
try {
$parentId = $obj->addSVG($parentPath, 1, 1, 10, 10, $page['height']);
$setOut = $obj->getSetSVG($parentId);
$this->assertNotSame('', $setOut);
} finally {
if (\file_exists($childPath)) {
\unlink($childPath);
}
if (\file_exists($parentPath)) {
\unlink($parentPath);
}
}
}
/** @throws \Throwable */
public function testAddSvgWithMarkupResourcesBlocksTempChildRasterByDefault(): void
{
$obj = $this->getInternalTestObject();
$page = $this->initFontAndPage($obj);
$tmpImage = (string) \tempnam(\sys_get_temp_dir(), 'tc-svg-markup-');
\unlink($tmpImage);
$tmpImage .= '.png';
\copy(__DIR__ . '/../examples/images/tcpdf_cell.png', $tmpImage);
try {
$svg =
'@<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10">'
. '<image x="0" y="0" width="5" height="5" xlink:href="file://'
. $tmpImage
. '"/></svg>';
$id = $obj->addSVG($svg, 1, 1, 10, 10, $page['height'], true);
$out = $obj->getSetSVG($id);
$this->assertNotSame('', $out);
/** @var \Com\Tecnick\Pdf\Image\Import $image */
$image = $this->getObjectProperty($obj, 'image');
/** @var array<int, mixed> $images */
$images = $this->getObjectProperty($image, 'image');
$this->assertSame([], $images);
} finally {
if (\file_exists($tmpImage)) {
\unlink($tmpImage);
}
}
}
/** @throws \Throwable */
public function testSvgFinalFeasibleBranchCoverageBatch(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$parser = \xml_parser_create('UTF-8');
$base = $obj->exposeDefaultSVGStyle();
$coord = $obj->getPathCoordDefaults();
$coord['relcoord'] = true;
$coord['xoffset'] = 1.0;
$coord['yoffset'] = 1.0;
[, $coord] = $obj->exposeSvgPathCmdA([4.0, 3.0, 0.0, 0.0, 0.0, 8.0, 9.0], $coord, [['0', 'A'], ['1', 'z']], 0, [
'4',
'3',
'0',
'0',
'0',
'8',
'9',
]);
$this->assertGreaterThan(0.0, $coord['xoffset']);
$this->assertGreaterThan(0.0, $coord['yoffset']);
$obj->initSvgObjForHandlers(70);
$obj->patchSvgObj(70, [
'defsmode' => true,
'defs' => [],
'styles' => [$base],
'textmode' => ['invisible' => false],
]);
/** @var array<int, array<string, mixed>> $svgobjs */
$svgobjs = $this->getObjectProperty($obj, 'svgobjs');
unset($svgobjs[70]['clippaths']);
$this->setObjectProperty($obj, 'svgobjs', $svgobjs);
$obj->exposeHandleSVGTagStart($parser, 'line', [], 70);
$obj->patchSvgObj(70, ['defsmode' => false, 'clipmode' => false, 'styles' => [$base]]);
$obj->exposeHandleSVGTagStart($parser, 'path', ['d' => 'M 0 0 L 2 2'], 70);
$obj->patchSvgObj(70, ['tagdepth' => 1]);
$svgNone = $obj->exposeParseSVGTagSTARTsvg(
$parser,
70,
[
'x' => '0',
'y' => '0',
'width' => '40',
'height' => '10',
'viewBox' => '0 0 100 50',
'preserveAspectRatio' => 'none',
],
$base,
$base,
);
$this->assertNotSame('', $svgNone);
$obj->patchSvgObj(70, ['tagdepth' => 1]);
$svgMeetXMax = $obj->exposeParseSVGTagSTARTsvg(
$parser,
70,
[
'x' => '0',
'y' => '0',
'width' => '40',
'height' => '10',
'viewBox' => '0 0 100 50',
'preserveAspectRatio' => 'xMaxYMid meet',
],
$base,
$base,
);
$this->assertNotSame('', $svgMeetXMax);
$obj->patchSvgObj(70, ['tagdepth' => 1]);
$svgMeetYMax = $obj->exposeParseSVGTagSTARTsvg(
$parser,
70,
[
'x' => '0',
'y' => '0',
'width' => '10',
'height' => '40',
'viewBox' => '0 0 50 100',
'preserveAspectRatio' => 'xMidYMax meet',
],
$base,
$base,
);
$this->assertNotSame('', $svgMeetYMax);
$obj->initSvgObjForHandlers(71);
$obj->exposeParseSVGTagSTARTradialGradient(71, [
'id' => 'rgm',
'gradientUnits' => 'userSpaceOnUse',
'cx' => '2',
'cy' => '3',
'r' => '2',
]);
$svgobj71 = $obj->getSvgObj(71);
$this->assertSame('measure', $svgobj71['gradients']['rgm']['mode'] ?? null);
$obj->initSvgObjForHandlers(72);
$styleNoAnchor = $base;
$styleNoAnchor['text-anchor'] = '';
$styleNoAnchor['direction'] = '';
$txtOut = $obj->exposeParseSVGTagSTARTtext($parser, 72, ['x' => '1', 'y' => '1'], $styleNoAnchor, $base);
$this->assertNotSame('', $txtOut);
$obj->initSvgObjForHandlers(73);
$obj->initSvgObjForHandlers(74);
$obj->patchSvgObj(73, ['out' => 'A', 'child' => [74]]);
$obj->patchSvgObj(74, ['out' => 'B', 'child' => []]);
$this->assertSame('AB', $obj->getSetSVG(73));
}
/** @throws \Throwable */
public function testSvgRemainingFeasibleHotspotBranches(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$base = $obj->exposeDefaultSVGStyle();
$coord = $obj->getPathCoordDefaults();
$paths = [['0', 'A'], ['1', 'z']];
$rawSets = [
['4', '2', '0', '1', '0', '10', '20'],
['4', '2', '0', '0', '1', '10', '20'],
['6', '3', '45', '1', '0', '14', '16'],
['6', '3', '45', '0', '1', '14', '16'],
];
foreach ($rawSets as $raw) {
$params = [
(float) $raw[0],
(float) $raw[1],
(float) $raw[2],
(float) $raw[3],
(float) $raw[4],
(float) $raw[5],
(float) $raw[6],
];
[$arcOut] = $obj->exposeSvgPathCmdA($params, $coord, $paths, 0, $raw);
$this->assertIsString($arcOut);
}
$obj->setSvgObjMeta(75);
$strokeStyle = $base;
$strokeStyle['stroke'] = 'url(#not-a-color)';
try {
[$strokeOut] = $obj->exposeParseSVGStyleStroke(75, $strokeStyle);
$this->assertSame('', $strokeOut);
} catch (\Throwable $e) {
$this->assertNotSame('', $e->getMessage());
}
$gradients = [
'm' => [
'xref' => '',
'type' => 2,
'gradientUnits' => 'objectBoundingBox',
'mode' => 'measure',
'coords' => [0.0, 0.0, 0.0, 0.0],
'stops' => [
['color' => '#000000', 'opacity' => 1.0, 'offset' => 0.0],
['color' => '#ffffff', 'opacity' => 1.0, 'offset' => 1.0],
],
'gradientTransform' => [1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
],
];
$gradOut = $obj->exposeParseSVGStyleGradient(75, $gradients, 'm', 0.0, 0.0, 0.0, 0.0);
$this->assertNotSame('', $gradOut);
$parser = \xml_parser_create('UTF-8');
$obj->initSvgObjForHandlers(76);
$obj->patchSvgObj(76, ['tagdepth' => 1]);
$xMidMeet = $obj->exposeParseSVGTagSTARTsvg(
$parser,
76,
[
'x' => '0',
'y' => '0',
'width' => '40',
'height' => '10',
'viewBox' => '0 0 100 50',
'preserveAspectRatio' => 'xMidYMid meet',
],
$base,
$base,
);
$this->assertNotSame('', $xMidMeet);
$obj->patchSvgObj(76, ['tagdepth' => 1]);
$yMidMeet = $obj->exposeParseSVGTagSTARTsvg(
$parser,
76,
[
'x' => '0',
'y' => '0',
'width' => '10',
'height' => '40',
'viewBox' => '0 0 50 100',
'preserveAspectRatio' => 'xMinYMid meet',
],
$base,
$base,
);
$this->assertNotSame('', $yMidMeet);
}
// -----------------------------------------------------------------------
// P1 fixes: T-1, T-2, S-5, R-4
// -----------------------------------------------------------------------
/**
* T-1: visibility:hidden text must still advance the layout cursor.
* @throws \Throwable
*/
public function testSvgInvisibleTextAdvancesCursor(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(80);
$base = $obj->exposeDefaultSVGStyle();
$obj->patchSvgObj(80, [
'styles' => [$base],
'defsmode' => false,
'textmode' => [
'invisible' => true,
'stroke' => 0,
'rtl' => false,
'text-anchor' => 'start',
],
'text' => 'Hello',
'x' => 10.0,
'y' => 20.0,
]);
$xBefore = $obj->getSvgObj(80)['x'];
$out = $obj->exposeParseSVGTagENDtext(80);
// No drawing output for invisible text.
$this->assertSame('', $out);
// Text buffer must be cleared.
$this->assertSame('', $obj->getSvgObj(80)['text']);
// Cursor must have advanced (x increased).
$this->assertGreaterThan($xBefore, $obj->getSvgObj(80)['x']);
}
/**
* T-1: empty invisible text does not change the cursor.
* @throws \Throwable
*/
public function testSvgInvisibleEmptyTextDoesNotChangeCursor(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(81);
$base = $obj->exposeDefaultSVGStyle();
$obj->patchSvgObj(81, [
'styles' => [$base],
'defsmode' => false,
'textmode' => [
'invisible' => true,
'stroke' => 0,
'rtl' => false,
'text-anchor' => 'start',
],
'text' => '',
'x' => 5.0,
'y' => 5.0,
]);
$xBefore = $obj->getSvgObj(81)['x'];
$obj->exposeParseSVGTagENDtext(81);
$this->assertSame($xBefore, $obj->getSvgObj(81)['x']);
}
/**
* Metadata text like <desc> must not leak into renderable SVG text buffer.
* @throws \Throwable
*/
public function testSvgDescCharacterDataIsNotRenderedAsText(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$parser = \xml_parser_create('UTF-8');
$obj->initSvgObjForHandlers(811);
$obj->exposeHandleSVGTagStart($parser, 'desc', [], 811);
$obj->exposeHandleSVGCharacter($parser, 'TCPDF SVG EXAMPLE');
$obj->exposeHandleSVGTagEnd($parser, 'desc');
$this->assertSame('', $obj->getSvgObj(811)['text']);
}
/**
* T-2: starting a new text/tspan while buffered text exists flushes the run.
* @throws \Throwable
*/
public function testSvgStartTextFlushesBufferedRun(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$parser = \xml_parser_create('UTF-8');
$obj->initSvgObjForHandlers(82);
$base = $obj->exposeDefaultSVGStyle();
// Simulate state after an outer <text> started and accumulated content.
$obj->patchSvgObj(82, [
'styles' => [$base, $base], // outer-text style already on stack
'defsmode' => false,
'textmode' => [
'invisible' => false,
'stroke' => 0,
'rtl' => false,
'text-anchor' => 'start',
],
'text' => 'Outer',
'x' => 5.0,
'y' => 10.0,
]);
// Starting a tspan should flush 'Outer' and produce non-empty output.
$tspanOut = $obj->exposeParseSVGTagSTARTtspan($parser, 82, ['x' => '5', 'y' => '10'], $base, $base);
// After flush the text buffer must be empty (new run started fresh).
$this->assertSame('', $obj->getSvgObj(82)['text']);
// Output contains both the flushed run and the new transform.
$this->assertNotSame('', $tspanOut);
}
/**
* S-5: parseSVGTagSTARTuse resolves an element via plain href (SVG 2).
* @throws \Throwable
*/
public function testSvgUseTagResolvesViaPlainHref(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$parser = \xml_parser_create('UTF-8');
$obj->initSvgObjForHandlers(83);
// Register a simple rect in defs under id 'shape83'.
$obj->patchSvgObj(83, [
'defs' => [
'shape83' => [
'name' => 'rect',
'attr' => [
'width' => '10',
'height' => '5',
'style' => '',
],
],
],
]);
// Empty href → empty return.
$this->assertSame('', $obj->exposeParseSVGTagSTARTuse($parser, 83, []));
// Plain href (no xlink:href) must resolve.
$obj->exposeParseSVGTagSTARTuse($parser, 83, ['href' => '#shape83']);
// The out buffer should have been written to (rect was dispatched).
$this->assertNotSame('', $obj->getSvgObj(83)['out']);
}
/**
* S-5: parseSVGTagSTARTimage falls back to plain href when xlink:href absent.
* @throws \Throwable
*/
public function testSvgImageTagAcceptsPlainHref(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$parser = \xml_parser_create('UTF-8');
$obj->initSvgObjForHandlers(84);
$base = $obj->exposeDefaultSVGStyle();
// Missing both href attributes → empty.
$this->assertSame('', $obj->exposeParseSVGTagSTARTimage($parser, 84, [], $base, $base));
// Plain href pointing to an image that will cause a load exception;
// what matters is that the early-return guard no longer rejects the call
// solely because xlink:href is absent — the code reaches the image loader.
try {
$out = $obj->exposeParseSVGTagSTARTimage(
$parser,
84,
['href' => 'nonexistent.png', 'x' => '0', 'y' => '0', 'width' => '10', 'height' => '10'],
$base,
$base,
);
} catch (\Throwable $e) {
// An image-load exception confirms the code progressed past the href guard.
$this->assertNotSame('', $e->getMessage());
return;
}
$this->assertSame('', $out);
}
/**
* S-5: linearGradient and radialGradient store xref from plain href.
* @throws \Throwable
*/
public function testSvgGradientXrefFromPlainHref(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(85);
$obj->exposeParseSVGTagSTARTlinearGradient(85, ['id' => 'lg85', 'href' => '#lgBase']);
$obj->exposeParseSVGTagSTARTradialGradient(85, ['id' => 'rg85', 'href' => '#rgBase']);
$svgobj = $obj->getSvgObj(85);
$this->assertSame('lgBase', $svgobj['gradients']['lg85']['xref'] ?? null);
$this->assertSame('rgBase', $svgobj['gradients']['rg85']['xref'] ?? null);
}
/**
* S-5: gradient prescan preserves plain href so xref inheritance works.
* @throws \Throwable
*/
public function testSvgPrescanGradientsKeepsPlainHrefXref(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(851);
$obj->exposePrescanSVGGradients(
'<svg><defs>'
. '<linearGradient id="baseLg"><stop offset="0%" stop-color="#000"/></linearGradient>'
. '<linearGradient id="refLg" href="#baseLg" />'
. '<radialGradient id="baseRg"><stop offset="0%" stop-color="#000"/></radialGradient>'
. '<radialGradient id="refRg" href="#baseRg" />'
. '</defs></svg>',
851,
);
$svgobj = $obj->getSvgObj(851);
$this->assertSame('baseLg', $svgobj['gradients']['refLg']['xref'] ?? '');
$this->assertSame('baseRg', $svgobj['gradients']['refRg']['xref'] ?? '');
}
/**
* R-4: stroke-dasharray values with unit suffixes are normalised to user units.
* @throws \Throwable
*/
public function testSvgDasharrayWithUnitSuffixIsNormalised(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->setSvgObjMeta(86);
$base = $obj->exposeDefaultSVGStyle();
// Provide dash tokens with a unit suffix; these should survive normalisation
// (not become zero) and produce a non-empty dashArray in the output.
$style = $base;
$style['stroke'] = '#000000';
$style['stroke-dasharray'] = '5 3';
[$strokeOut] = $obj->exposeParseSVGStyleStroke(86, $style);
$this->assertNotSame('', $strokeOut);
// Ensure a 'none' dasharray still produces empty dashArray.
$styleNone = $base;
$styleNone['stroke'] = '#000000';
$styleNone['stroke-dasharray'] = 'none';
[$strokeNone] = $obj->exposeParseSVGStyleStroke(86, $styleNone);
// dashArray=[] means no 'w' dash command prefix — still has stroke output.
$this->assertIsString($strokeNone);
}
/**
* E-1: <symbol id="sym"> enters defs-capture mode and stores the id in defs.
* @throws \Throwable
*/
public function testSvgSymbolEnterDefsMode(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(90);
$obj->exposeParseSVGTagSTARTsymbol(90, ['id' => 'mySymbol', 'viewBox' => '0 0 100 100']);
$svgobj = $obj->getSvgObj(90);
$this->assertTrue($svgobj['defsmode']);
$this->assertArrayHasKey('mySymbol', $svgobj['defs']);
$this->assertSame('symbol', $svgobj['defs']['mySymbol']['name'] ?? null);
}
/**
* E-1: </symbol> exits defs-capture mode.
* @throws \Throwable
*/
public function testSvgSymbolEndExitsDefsMode(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(91);
$obj->patchSvgObj(91, ['defsmode' => true]);
$out = $obj->exposeParseSVGTagENDsymbol(91);
$this->assertSame('', $out);
$this->assertFalse($obj->getSvgObj(91)['defsmode']);
}
/**
* E-1: symbol without id does not crash and defsmode is still entered.
* @throws \Throwable
*/
public function testSvgSymbolWithoutIdStillEntersDefsMode(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(92);
$out = $obj->exposeParseSVGTagSTARTsymbol(92, []);
$this->assertSame('', $out);
$this->assertTrue($obj->getSvgObj(92)['defsmode']);
}
/**
* E-1: first non-id child inside symbol is captured in defs child list.
* @throws \Throwable
*/
public function testSvgSymbolCapturesFirstAnonymousChild(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$parser = \xml_parser_create('UTF-8');
$obj->initSvgObjForHandlers(92);
$obj->exposeParseSVGTagSTARTsymbol(92, ['id' => 'symChild']);
$obj->exposeHandleSVGTagStart($parser, 'rect', ['width' => '10', 'height' => '5'], 92);
$svgobj = $obj->getSvgObj(92);
$this->assertArrayHasKey('symChild', $svgobj['defs']);
$symbolDef = $svgobj['defs']['symChild'] ?? [];
$children = $symbolDef['child'] ?? [];
$this->assertIsArray($children);
$this->assertNotSame([], $children);
$first = \reset($children);
$this->assertIsArray($first);
$this->assertSame('rect', $first['name']);
}
/**
* E-1/R-3: <use> on symbol falls back to symbol viewBox size when width/height missing.
* @throws \Throwable
*/
public function testSvgUseSymbolFallsBackToViewBoxDimensions(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$parser = \xml_parser_create('UTF-8');
$obj->initSvgObjForHandlers(921);
$base = $obj->exposeDefaultSVGStyle();
$obj->patchSvgObj(921, ['styles' => [$base]]);
$obj->patchSvgObj(921, [
'defs' => [
'symvb' => [
'name' => 'symbol',
'attr' => [
'viewBox' => '0 0 40 20',
],
'child' => [
'c1' => [
'name' => 'rect',
'attr' => ['x' => '0', 'y' => '0', 'width' => '40', 'height' => '20', 'fill' => '#000'],
],
],
],
],
]);
$out = $obj->exposeParseSVGTagSTARTuse($parser, 921, ['href' => '#symvb', 'x' => '1', 'y' => '2']);
$this->assertNotSame('', $out);
$this->assertStringContainsString(' cm', $out);
}
/**
* E-1/R-3: explicit width/height on use still override symbol defaults.
* @throws \Throwable
*/
public function testSvgUseSymbolRespectsExplicitUseDimensions(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$parser = \xml_parser_create('UTF-8');
$obj->initSvgObjForHandlers(922);
$base = $obj->exposeDefaultSVGStyle();
$obj->patchSvgObj(922, ['styles' => [$base]]);
$obj->patchSvgObj(922, [
'defs' => [
'symovr' => [
'name' => 'symbol',
'attr' => [
'viewBox' => '0 0 100 50',
'width' => '100',
'height' => '50',
],
'child' => [
'c1' => [
'name' => 'rect',
'attr' => ['x' => '0', 'y' => '0', 'width' => '100', 'height' => '50', 'fill' => '#000'],
],
],
],
],
]);
$out = $obj->exposeParseSVGTagSTARTuse($parser, 922, [
'href' => '#symovr',
'x' => '0',
'y' => '0',
'width' => '10',
'height' => '5',
]);
$this->assertNotSame('', $out);
// Rendering with explicit dimensions should still produce content and transforms.
$this->assertStringContainsString(' cm', $out);
}
/**
* E-1: use-level transform is preserved when expanding <symbol>.
* @throws \Throwable
*/
public function testSvgUseSymbolPreservesUseLevelTransformAttribute(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$parser = \xml_parser_create('UTF-8');
$base = $obj->exposeDefaultSVGStyle();
$defs = [
'symstyle' => [
'name' => 'symbol',
'attr' => [
'viewBox' => '0 0 20 10',
],
'child' => [
'c1' => [
'name' => 'rect',
'attr' => ['x' => '0', 'y' => '0', 'width' => '20', 'height' => '10'],
],
],
],
];
$obj->initSvgObjForHandlers(923);
$obj->patchSvgObj(923, ['styles' => [$base], 'defs' => $defs]);
$outDefault = $obj->exposeParseSVGTagSTARTuse($parser, 923, [
'href' => '#symstyle',
'x' => '0',
'y' => '0',
'width' => '20',
'height' => '10',
]);
$obj->initSvgObjForHandlers(924);
$obj->patchSvgObj(924, ['styles' => [$base], 'defs' => $defs]);
$outTransformed = $obj->exposeParseSVGTagSTARTuse($parser, 924, [
'href' => '#symstyle',
'x' => '0',
'y' => '0',
'width' => '20',
'height' => '10',
'transform' => 'translate(5,7)',
]);
$this->assertNotSame('', $outDefault);
$this->assertNotSame('', $outTransformed);
$this->assertNotSame($outDefault, $outTransformed);
}
/**
* E-1: use-level style is preserved when expanding <symbol>.
* @throws \Throwable
*/
public function testSvgUseSymbolPreservesUseLevelStyleAttribute(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$parser = \xml_parser_create('UTF-8');
$base = $obj->exposeDefaultSVGStyle();
$defs = [
'symstyle2' => [
'name' => 'symbol',
'attr' => [
'viewBox' => '0 0 20 10',
],
'child' => [
'c1' => [
'name' => 'rect',
'attr' => ['x' => '0', 'y' => '0', 'width' => '20', 'height' => '10'],
],
],
],
];
$obj->initSvgObjForHandlers(925);
$obj->patchSvgObj(925, ['styles' => [$base], 'defs' => $defs]);
$outDefault = $obj->exposeParseSVGTagSTARTuse($parser, 925, [
'href' => '#symstyle2',
'x' => '0',
'y' => '0',
'width' => '20',
'height' => '10',
]);
$obj->initSvgObjForHandlers(926);
$obj->patchSvgObj(926, ['styles' => [$base], 'defs' => $defs]);
$outStyled = $obj->exposeParseSVGTagSTARTuse($parser, 926, [
'href' => '#symstyle2',
'x' => '0',
'y' => '0',
'width' => '20',
'height' => '10',
'style' => 'fill:#ff0000;stroke:none;',
]);
$this->assertNotSame('', $outDefault);
$this->assertNotSame('', $outStyled);
$this->assertNotSame($outDefault, $outStyled);
}
/**
* E-1: symbol-level style is preserved when expanding <symbol>.
* @throws \Throwable
*/
public function testSvgUseSymbolPreservesSymbolLevelStyleAttribute(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$parser = \xml_parser_create('UTF-8');
$base = $obj->exposeDefaultSVGStyle();
$obj->initSvgObjForHandlers(927);
$obj->patchSvgObj(927, [
'styles' => [$base],
'defs' => [
'symstyle3' => [
'name' => 'symbol',
'attr' => [
'viewBox' => '0 0 20 10',
],
'child' => [
'c1' => [
'name' => 'rect',
'attr' => ['x' => '0', 'y' => '0', 'width' => '20', 'height' => '10'],
],
],
],
],
]);
$outDefault = $obj->exposeParseSVGTagSTARTuse($parser, 927, [
'href' => '#symstyle3',
'x' => '0',
'y' => '0',
'width' => '20',
'height' => '10',
]);
$obj->initSvgObjForHandlers(928);
$obj->patchSvgObj(928, [
'styles' => [$base],
'defs' => [
'symstyle3' => [
'name' => 'symbol',
'attr' => [
'viewBox' => '0 0 20 10',
'style' => 'fill:#ff0000;stroke:none;',
],
'child' => [
'c1' => [
'name' => 'rect',
'attr' => ['x' => '0', 'y' => '0', 'width' => '20', 'height' => '10'],
],
],
],
],
]);
$outStyled = $obj->exposeParseSVGTagSTARTuse($parser, 928, [
'href' => '#symstyle3',
'x' => '0',
'y' => '0',
'width' => '20',
'height' => '10',
]);
$this->assertNotSame('', $outDefault);
$this->assertNotSame('', $outStyled);
$this->assertNotSame($outDefault, $outStyled);
}
/**
* E-1: symbol-level transform is preserved when expanding <symbol>.
* @throws \Throwable
*/
public function testSvgUseSymbolPreservesSymbolLevelTransformAttribute(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$parser = \xml_parser_create('UTF-8');
$base = $obj->exposeDefaultSVGStyle();
$obj->initSvgObjForHandlers(929);
$obj->patchSvgObj(929, [
'styles' => [$base],
'defs' => [
'symstyle4' => [
'name' => 'symbol',
'attr' => [
'viewBox' => '0 0 20 10',
],
'child' => [
'c1' => [
'name' => 'rect',
'attr' => ['x' => '0', 'y' => '0', 'width' => '20', 'height' => '10'],
],
],
],
],
]);
$outDefault = $obj->exposeParseSVGTagSTARTuse($parser, 929, [
'href' => '#symstyle4',
'x' => '0',
'y' => '0',
'width' => '20',
'height' => '10',
]);
$obj->initSvgObjForHandlers(930);
$obj->patchSvgObj(930, [
'styles' => [$base],
'defs' => [
'symstyle4' => [
'name' => 'symbol',
'attr' => [
'viewBox' => '0 0 20 10',
'transform' => 'translate(3,4)',
],
'child' => [
'c1' => [
'name' => 'rect',
'attr' => ['x' => '0', 'y' => '0', 'width' => '20', 'height' => '10'],
],
],
],
],
]);
$outTransformed = $obj->exposeParseSVGTagSTARTuse($parser, 930, [
'href' => '#symstyle4',
'x' => '0',
'y' => '0',
'width' => '20',
'height' => '10',
]);
$this->assertNotSame('', $outDefault);
$this->assertNotSame('', $outTransformed);
$this->assertNotSame($outDefault, $outTransformed);
}
/**
* E-7: <a> start stores link metadata and </a> emits an annotation ref.
* @throws \Throwable
*/
public function testSvgATagCreatesAnnotationReference(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(93);
$pageBefore = $obj->exposeGetCurrentPageData();
$annotRefsBefore = [];
if (isset($pageBefore['annotrefs']) && \is_array($pageBefore['annotrefs'])) {
$annotRefsBefore = $pageBefore['annotrefs'];
}
$this->assertIsArray($annotRefsBefore);
$countBefore = \count($annotRefsBefore);
$start = $obj->exposeParseSVGTagSTARTa(93, ['href' => 'https://example.com']);
$this->assertSame('', $start);
// Simulate that wrapped content advanced text position.
$obj->patchSvgObj(93, ['x' => 40.0, 'y' => 20.0]);
$end = $obj->exposeParseSVGTagENDa(93);
$this->assertSame('', $end);
$pageAfter = $obj->exposeGetCurrentPageData();
$annotRefsAfter = [];
if (isset($pageAfter['annotrefs']) && \is_array($pageAfter['annotrefs'])) {
$annotRefsAfter = $pageAfter['annotrefs'];
}
$this->assertIsArray($annotRefsAfter);
$countAfter = \count($annotRefsAfter);
$this->assertSame($countBefore + 1, $countAfter);
$this->assertSame('', $obj->getSvgObj(93)['textmode']['linkhref'] ?? '');
}
/**
* E-7: missing href on <a> should degrade gracefully with no annotation.
* @throws \Throwable
*/
public function testSvgATagWithoutHrefProducesNoAnnotation(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(94);
$pageBefore = $obj->exposeGetCurrentPageData();
$annotRefsBefore = [];
if (isset($pageBefore['annotrefs']) && \is_array($pageBefore['annotrefs'])) {
$annotRefsBefore = $pageBefore['annotrefs'];
}
$this->assertIsArray($annotRefsBefore);
$countBefore = \count($annotRefsBefore);
$start = $obj->exposeParseSVGTagSTARTa(94, []);
$this->assertSame('', $start);
$end = $obj->exposeParseSVGTagENDa(94);
$this->assertSame('', $end);
$pageAfter = $obj->exposeGetCurrentPageData();
$annotRefsAfter = [];
if (isset($pageAfter['annotrefs']) && \is_array($pageAfter['annotrefs'])) {
$annotRefsAfter = $pageAfter['annotrefs'];
}
$this->assertIsArray($annotRefsAfter);
$countAfter = \count($annotRefsAfter);
$this->assertSame($countBefore, $countAfter);
}
/**
* E-8 stub: <switch> start returns empty string.
* @throws \Throwable
*/
public function testSvgSwitchTagStartReturnsEmpty(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(95);
$out = $obj->exposeParseSVGTagSTARTswitch(95, []);
$this->assertSame('', $out);
}
/**
* E-8 stub: </switch> end returns empty string.
* @throws \Throwable
*/
public function testSvgSwitchTagEndReturnsEmpty(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(95);
$out = $obj->exposeParseSVGTagENDswitch(95);
$this->assertSame('', $out);
}
/**
* E-8: only first direct child of switch is rendered.
* @throws \Throwable
*/
public function testSvgSwitchRendersOnlyFirstChild(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$parser = \xml_parser_create('UTF-8');
$obj->initSvgObjForHandlers(960);
$obj->exposeHandleSVGTagStart($parser, 'switch', [], 960);
$obj->exposeHandleSVGTagStart(
$parser,
'rect',
['x' => '1', 'y' => '1', 'width' => '10', 'height' => '5', 'fill' => '#000000'],
960,
);
$obj->exposeHandleSVGTagEnd($parser, 'rect');
$outAfterFirst = $obj->getSvgObj(960)['out'];
$this->assertNotSame('', $outAfterFirst);
// Second sibling should be skipped by switch selection logic.
$obj->exposeHandleSVGTagStart(
$parser,
'circle',
['cx' => '5', 'cy' => '5', 'r' => '2', 'fill' => '#ff0000'],
960,
);
$obj->exposeHandleSVGTagEnd($parser, 'circle');
$outAfterSecond = $obj->getSvgObj(960)['out'];
$this->assertSame($outAfterFirst, $outAfterSecond);
$obj->exposeHandleSVGTagEnd($parser, 'switch');
$this->assertEmpty($obj->getSvgObj(960)['switchstack'] ?? []);
}
/**
* S-1: dominant-baseline='hanging' shifts renderY by -ascent.
* We verify that the output changes when baseline keyword differs.
* @throws \Throwable
*/
public function testSvgDominantBaselineHangingChangesOutput(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$base = $obj->exposeDefaultSVGStyle();
// Render with default (auto) baseline.
$obj->initSvgObjForHandlers(95);
$obj->patchSvgObj(95, [
'styles' => [$base, $base],
'defsmode' => false,
'textmode' => [
'rtl' => false,
'invisible' => false,
'stroke' => 0,
'text-anchor' => 'start',
'baseline' => 'auto',
'rotate' => 0.0,
'textlength' => 0.0,
'lengthadjust' => 'spacing',
'xlist' => [],
'ylist' => [],
],
'text' => 'Hi',
'x' => 10.0,
'y' => 20.0,
]);
$outAuto = $obj->exposeParseSVGTagENDtext(95);
// Render with hanging baseline.
$obj->initSvgObjForHandlers(96);
$obj->patchSvgObj(96, [
'styles' => [$base, $base],
'defsmode' => false,
'textmode' => [
'rtl' => false,
'invisible' => false,
'stroke' => 0,
'text-anchor' => 'start',
'baseline' => 'hanging',
'rotate' => 0.0,
'textlength' => 0.0,
'lengthadjust' => 'spacing',
'xlist' => [],
'ylist' => [],
],
'text' => 'Hi',
'x' => 10.0,
'y' => 20.0,
]);
$outHanging = $obj->exposeParseSVGTagENDtext(96);
// Both must produce non-empty drawing output.
$this->assertNotSame('', $outAuto);
$this->assertNotSame('', $outHanging);
// The Y coordinate embedded in the output should differ between the two.
$this->assertNotSame($outAuto, $outHanging);
}
/**
* S-3: textLength with spacingAndGlyphs wraps output in an extra transform.
* @throws \Throwable
*/
public function testSvgTextLengthGlyphsAddsScaleTransform(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$base = $obj->exposeDefaultSVGStyle();
// Without textLength.
$obj->initSvgObjForHandlers(97);
$obj->patchSvgObj(97, [
'styles' => [$base, $base],
'defsmode' => false,
'textmode' => [
'rtl' => false,
'invisible' => false,
'stroke' => 0,
'text-anchor' => 'start',
'baseline' => 'auto',
'rotate' => 0.0,
'textlength' => 0.0,
'lengthadjust' => 'spacingAndGlyphs',
'xlist' => [],
'ylist' => [],
],
'text' => 'Test',
'x' => 0.0,
'y' => 20.0,
]);
$outNoScale = $obj->exposeParseSVGTagENDtext(97);
// With textLength=200 (much wider than natural).
$obj->initSvgObjForHandlers(98);
$obj->patchSvgObj(98, [
'styles' => [$base, $base],
'defsmode' => false,
'textmode' => [
'rtl' => false,
'invisible' => false,
'stroke' => 0,
'text-anchor' => 'start',
'baseline' => 'auto',
'rotate' => 0.0,
'textlength' => 200.0,
'lengthadjust' => 'spacingAndGlyphs',
'xlist' => [],
'ylist' => [],
],
'text' => 'Test',
'x' => 0.0,
'y' => 20.0,
]);
$outWithScale = $obj->exposeParseSVGTagENDtext(98);
$this->assertNotSame('', $outNoScale);
$this->assertNotSame('', $outWithScale);
// The scaled variant must be longer (extra transform operators).
$this->assertGreaterThan(\strlen($outNoScale), \strlen($outWithScale));
}
/**
* S-4: rotate adds extra transform operators around the text output.
* @throws \Throwable
*/
public function testSvgTextRotateAddsTransformOperators(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$base = $obj->exposeDefaultSVGStyle();
// Without rotation.
$obj->initSvgObjForHandlers(99);
$obj->patchSvgObj(99, [
'styles' => [$base, $base],
'defsmode' => false,
'textmode' => [
'rtl' => false,
'invisible' => false,
'stroke' => 0,
'text-anchor' => 'start',
'baseline' => 'auto',
'rotate' => 0.0,
'textlength' => 0.0,
'lengthadjust' => 'spacing',
'xlist' => [],
'ylist' => [],
],
'text' => 'Abc',
'x' => 5.0,
'y' => 10.0,
]);
$outNoRotate = $obj->exposeParseSVGTagENDtext(99);
// With 45-degree rotation.
$obj->initSvgObjForHandlers(100);
$obj->patchSvgObj(100, [
'styles' => [$base, $base],
'defsmode' => false,
'textmode' => [
'rtl' => false,
'invisible' => false,
'stroke' => 0,
'text-anchor' => 'start',
'baseline' => 'auto',
'rotate' => 45.0,
'textlength' => 0.0,
'lengthadjust' => 'spacing',
'xlist' => [],
'ylist' => [],
],
'text' => 'Abc',
'x' => 5.0,
'y' => 10.0,
]);
$outRotated = $obj->exposeParseSVGTagENDtext(100);
$this->assertNotSame('', $outNoRotate);
$this->assertNotSame('', $outRotated);
// Rotated output should be longer due to extra transform save/restore.
$this->assertGreaterThan(\strlen($outNoRotate), \strlen($outRotated));
}
/**
* S-4: multi-value rotate is parsed and applied per glyph.
* @throws \Throwable
*/
public function testSvgTextRotateListAppliesPerGlyph(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$parser = \xml_parser_create('UTF-8');
$base = $obj->exposeDefaultSVGStyle();
// First run: single zero rotation should not add per-glyph transforms.
$obj->initSvgObjForHandlers(1001);
$obj->patchSvgObj(1001, [
'styles' => [$base],
]);
$obj->exposeParseSVGTagSTARTtext($parser, 1001, ['x' => '5', 'y' => '10', 'rotate' => '0'], $base, $base);
$obj->exposeHandleSVGCharacter($parser, 'AB');
$outSingle = $obj->exposeParseSVGTagENDtext(1001);
// Second run: second glyph rotates, forcing per-glyph transforms.
$obj->initSvgObjForHandlers(1002);
$obj->patchSvgObj(1002, [
'styles' => [$base],
]);
$obj->exposeParseSVGTagSTARTtext($parser, 1002, ['x' => '5', 'y' => '10', 'rotate' => '0 45'], $base, $base);
$obj->exposeHandleSVGCharacter($parser, 'AB');
$outList = $obj->exposeParseSVGTagENDtext(1002);
$svgobj = $obj->getSvgObj(1002);
$rotlist = $svgobj['textmode']['rotlist'] ?? [];
$this->assertGreaterThanOrEqual(2, \count($rotlist));
assert(isset($rotlist[0]), "\$rotlist[0] must be set");
$this->assertEqualsWithDelta(0.0, $rotlist[0], 0.001);
assert(isset($rotlist[1]), "\$rotlist[1] must be set");
$this->assertEqualsWithDelta(45.0, $rotlist[1], 0.001);
$this->assertGreaterThan(\strlen($outSingle), \strlen($outList));
}
/**
* R-1: multi-value x list triggers per-character rendering (more operators).
* @throws \Throwable
*/
public function testSvgMultiValueXListRendersPerCharacter(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$base = $obj->exposeDefaultSVGStyle();
// Single-position (normal) rendering.
$obj->initSvgObjForHandlers(101);
$obj->patchSvgObj(101, [
'styles' => [$base, $base],
'defsmode' => false,
'textmode' => [
'rtl' => false,
'invisible' => false,
'stroke' => 0,
'text-anchor' => 'start',
'baseline' => 'auto',
'rotate' => 0.0,
'textlength' => 0.0,
'lengthadjust' => 'spacing',
'xlist' => [],
'ylist' => [],
],
'text' => 'AB',
'x' => 5.0,
'y' => 10.0,
]);
$outNormal = $obj->exposeParseSVGTagENDtext(101);
// Multi-value x list (two characters, two explicit x positions).
$obj->initSvgObjForHandlers(102);
$obj->patchSvgObj(102, [
'styles' => [$base, $base],
'defsmode' => false,
'textmode' => [
'rtl' => false,
'invisible' => false,
'stroke' => 0,
'text-anchor' => 'start',
'baseline' => 'auto',
'rotate' => 0.0,
'textlength' => 0.0,
'lengthadjust' => 'spacing',
'xlist' => [5.0, 15.0],
'ylist' => [],
],
'text' => 'AB',
'x' => 5.0,
'y' => 10.0,
]);
$outMultiX = $obj->exposeParseSVGTagENDtext(102);
$this->assertNotSame('', $outNormal);
$this->assertNotSame('', $outMultiX);
// Per-character rendering emits multiple text operators so output is longer.
$this->assertGreaterThan(\strlen($outNormal), \strlen($outMultiX));
}
/**
* E-6: textPath startOffset maps text origin along referenced path.
* @throws \Throwable
*/
public function testSvgTextPathStartOffsetUpdatesPosition(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$parser = \xml_parser_create('UTF-8');
$obj->initSvgObjForHandlers(103);
$base = $obj->exposeDefaultSVGStyle();
$obj->patchSvgObj(103, [
'styles' => [$base],
'defs' => [
'tp_line' => [
'name' => 'line',
'attr' => [
'x1' => '0',
'y1' => '0',
'x2' => '200',
'y2' => '0',
],
],
],
]);
$out = $obj->exposeParseSVGTagSTARTtextPath(
$parser,
103,
['href' => '#tp_line', 'startOffset' => '25%'],
$base,
$base,
);
$this->assertNotSame('', $out);
$svgobj = $obj->getSvgObj(103);
$pos25 = $svgobj['x'];
$obj->initSvgObjForHandlers(106);
$obj->patchSvgObj(106, [
'styles' => [$base],
'defs' => [
'tp_line' => [
'name' => 'line',
'attr' => [
'x1' => '0',
'y1' => '0',
'x2' => '200',
'y2' => '0',
],
],
],
]);
$obj->exposeParseSVGTagSTARTtextPath(
$parser,
106,
['href' => '#tp_line', 'startOffset' => '50%'],
$base,
$base,
);
$pos50 = $obj->getSvgObj(106)['x'];
$this->assertGreaterThan(0.0, $pos25);
$this->assertGreaterThan($pos25, $pos50);
$this->assertEqualsWithDelta(0.0, $svgobj['y'], 0.001);
}
/**
* E-6: textPath on vertical segment sets rotate to path angle.
* @throws \Throwable
*/
public function testSvgTextPathVerticalSegmentSetsRotate(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$parser = \xml_parser_create('UTF-8');
$obj->initSvgObjForHandlers(104);
$base = $obj->exposeDefaultSVGStyle();
$obj->patchSvgObj(104, [
'styles' => [$base],
'defs' => [
'tp_vertical' => [
'name' => 'line',
'attr' => [
'x1' => '10',
'y1' => '10',
'x2' => '10',
'y2' => '110',
],
],
],
]);
$obj->exposeParseSVGTagSTARTtextPath(
$parser,
104,
['href' => '#tp_vertical', 'startOffset' => '10'],
$base,
$base,
);
$svgobj = $obj->getSvgObj(104);
$this->assertEqualsWithDelta(90.0, $svgobj['textmode']['rotate'] ?? 0.0, 0.001);
}
/**
* E-6: offset on bent path uses cumulative segment length and local tangent.
* @throws \Throwable
*/
public function testSvgTextPathOffsetUsesBendSegmentTangent(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$parser = \xml_parser_create('UTF-8');
$obj->initSvgObjForHandlers(1041);
$base = $obj->exposeDefaultSVGStyle();
// Polyline path: (0,0)->(10,0)->(10,10), total length 20.
// At 75% offset (=15), point is on second segment at (10,5), tangent 90°.
$obj->patchSvgObj(1041, [
'styles' => [$base],
'defs' => [
'tp_bend' => [
'name' => 'polyline',
'attr' => [
'points' => '0,0 10,0 10,10',
],
],
],
]);
$obj->exposeParseSVGTagSTARTtextPath(
$parser,
1041,
['href' => '#tp_bend', 'startOffset' => '75%'],
$base,
$base,
);
$svgobj = $obj->getSvgObj(1041);
$textX = $svgobj['x'];
$textY = $svgobj['y'];
$this->assertGreaterThan(0.0, $textY);
$this->assertGreaterThan($textY, $textX);
$this->assertNotEqualsWithDelta($textX, $textY, 0.001);
$this->assertEqualsWithDelta(90.0, $svgobj['textmode']['rotate'] ?? 0.0, 0.001);
}
/**
* E-6: per-glyph textPath layout updates rotation as glyphs pass a bend.
* @throws \Throwable
*/
public function testSvgTextPathPerGlyphRotationFollowsBend(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$parser = \xml_parser_create('UTF-8');
$obj->initSvgObjForHandlers(1042);
$base = $obj->exposeDefaultSVGStyle();
// First segment is intentionally tiny so the second glyph lands on
// the vertical segment and picks a near-90deg local tangent.
$obj->patchSvgObj(1042, [
'styles' => [$base],
'defs' => [
'tp_glyph_bend' => [
'name' => 'polyline',
'attr' => [
'points' => '0,0 0.1,0 0.1,100',
],
],
],
]);
$obj->exposeParseSVGTagSTARTtextPath(
$parser,
1042,
['href' => '#tp_glyph_bend', 'startOffset' => '0'],
$base,
$base,
);
$obj->exposeHandleSVGCharacter($parser, 'ab');
$obj->exposeParseSVGTagENDtextPath(1042);
$svgobj = $obj->getSvgObj(1042);
$rotlist = $svgobj['textmode']['rotlist'] ?? [];
$this->assertGreaterThanOrEqual(2, \count($rotlist));
assert(isset($rotlist[0]), "\$rotlist[0] must be set");
$this->assertLessThan(45.0, $rotlist[0]);
assert(isset($rotlist[1]), "\$rotlist[1] must be set");
$this->assertGreaterThan(45.0, $rotlist[1]);
}
/**
* E-6: path-command decomposition handles H/V bends for tangent angle.
* @throws \Throwable
*/
public function testSvgTextPathPathHVCommandsUseLocalVerticalTangent(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$parser = \xml_parser_create('UTF-8');
$obj->initSvgObjForHandlers(1047);
$base = $obj->exposeDefaultSVGStyle();
// Path: M0,0 H100 V100 has total length 200.
// At 75% offset (=150), point is on the vertical segment.
$obj->patchSvgObj(1047, [
'styles' => [$base],
'defs' => [
'tp_hv' => [
'name' => 'path',
'attr' => [
'd' => 'M0 0 H100 V100',
],
],
],
]);
$obj->exposeParseSVGTagSTARTtextPath($parser, 1047, ['href' => '#tp_hv', 'startOffset' => '75%'], $base, $base);
$svgobj = $obj->getSvgObj(1047);
$this->assertEqualsWithDelta(90.0, $svgobj['textmode']['rotate'] ?? 0.0, 0.001);
$this->assertGreaterThan(0.0, $svgobj['y']);
}
/**
* E-6: cubic path sampling uses local start tangent instead of endpoint chord.
* @throws \Throwable
*/
public function testSvgTextPathCubicUsesLocalStartTangentAtSmallOffset(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$parser = \xml_parser_create('UTF-8');
$obj->initSvgObjForHandlers(1048);
$base = $obj->exposeDefaultSVGStyle();
// C endpoint chord is vertical (0,0)->(0,100), but the curve starts
// with an almost horizontal tangent due to the first control point.
$obj->patchSvgObj(1048, [
'styles' => [$base],
'defs' => [
'tp_cubic' => [
'name' => 'path',
'attr' => [
'd' => 'M0 0 C100 0 100 100 0 100',
],
],
],
]);
$obj->exposeParseSVGTagSTARTtextPath(
$parser,
1048,
['href' => '#tp_cubic', 'startOffset' => '5%'],
$base,
$base,
);
$svgobj = $obj->getSvgObj(1048);
$angle = $svgobj['textmode']['rotate'] ?? 0.0;
$this->assertGreaterThan(-45.0, $angle);
$this->assertLessThan(45.0, $angle);
}
/**
* E-6: arc path sampling uses local arc tangent at small offsets.
* @throws \Throwable
*/
public function testSvgTextPathArcUsesLocalStartTangentAtSmallOffset(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$parser = \xml_parser_create('UTF-8');
$obj->initSvgObjForHandlers(1049);
$base = $obj->exposeDefaultSVGStyle();
// A semicircular arc from left to right: endpoint chord is horizontal,
// while local tangent near the start is near vertical.
$obj->patchSvgObj(1049, [
'styles' => [$base],
'defs' => [
'tp_arc' => [
'name' => 'path',
'attr' => [
'd' => 'M0 0 A50 50 0 0 1 100 0',
],
],
],
]);
$obj->exposeParseSVGTagSTARTtextPath($parser, 1049, ['href' => '#tp_arc', 'startOffset' => '5%'], $base, $base);
$svgobj = $obj->getSvgObj(1049);
$angle = $svgobj['textmode']['rotate'] ?? 0.0;
$this->assertGreaterThan(45.0, \abs($angle));
}
/**
* E-6: arc sweep flag influences initial tangent direction.
* @throws \Throwable
*/
public function testSvgTextPathArcSweepDirectionAffectsInitialTangentSign(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$parser = \xml_parser_create('UTF-8');
$base = $obj->exposeDefaultSVGStyle();
// sweep=1 should start with upward tangent for this semicircle.
$obj->initSvgObjForHandlers(1050);
$obj->patchSvgObj(1050, [
'styles' => [$base],
'defs' => [
'tp_arc_sweep1' => [
'name' => 'path',
'attr' => [
'd' => 'M0 0 A50 50 0 0 1 100 0',
],
],
],
]);
$obj->exposeParseSVGTagSTARTtextPath(
$parser,
1050,
['href' => '#tp_arc_sweep1', 'startOffset' => '5%'],
$base,
$base,
);
$angleUp = $obj->getSvgObj(1050)['textmode']['rotate'] ?? 0.0;
// sweep=0 should mirror direction, yielding a negative initial tangent.
$obj->initSvgObjForHandlers(1051);
$obj->patchSvgObj(1051, [
'styles' => [$base],
'defs' => [
'tp_arc_sweep0' => [
'name' => 'path',
'attr' => [
'd' => 'M0 0 A50 50 0 0 0 100 0',
],
],
],
]);
$obj->exposeParseSVGTagSTARTtextPath(
$parser,
1051,
['href' => '#tp_arc_sweep0', 'startOffset' => '5%'],
$base,
$base,
);
$angleDown = $obj->getSvgObj(1051)['textmode']['rotate'] ?? 0.0;
$this->assertGreaterThan(45.0, \abs($angleUp));
$this->assertGreaterThan(45.0, \abs($angleDown));
$this->assertLessThan(0.0, $angleUp * $angleDown);
}
/**
* E-6: zero-radius arc falls back to a straight segment endpoint behavior.
* @throws \Throwable
*/
public function testSvgTextPathZeroRadiusArcFallsBackToLine(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$parser = \xml_parser_create('UTF-8');
$obj->initSvgObjForHandlers(1052);
$base = $obj->exposeDefaultSVGStyle();
$obj->patchSvgObj(1052, [
'styles' => [$base],
'defs' => [
'tp_arc_zero' => [
'name' => 'path',
'attr' => [
'd' => 'M0 0 A0 0 0 0 1 100 0',
],
],
],
]);
$obj->exposeParseSVGTagSTARTtextPath(
$parser,
1052,
['href' => '#tp_arc_zero', 'startOffset' => '50%'],
$base,
$base,
);
$svgobj = $obj->getSvgObj(1052);
$this->assertGreaterThan(0.0, $svgobj['x']);
$this->assertEqualsWithDelta(0.0, $svgobj['y'], 0.001);
$this->assertEqualsWithDelta(0.0, $svgobj['textmode']['rotate'] ?? 0.0, 0.001);
}
/**
* E-6: spacing="auto" expands inter-glyph distance to fill path.
* @throws \Throwable
*/
public function testSvgTextPathSpacingAutoExpandsGlyphGap(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$parser = \xml_parser_create('UTF-8');
$base = $obj->exposeDefaultSVGStyle();
$defs = [
'tp_space' => [
'name' => 'line',
'attr' => [
'x1' => '0',
'y1' => '0',
'x2' => '300',
'y2' => '0',
],
],
];
$obj->initSvgObjForHandlers(1043);
$obj->patchSvgObj(1043, ['styles' => [$base], 'defs' => $defs]);
$obj->exposeParseSVGTagSTARTtextPath(
$parser,
1043,
['href' => '#tp_space', 'spacing' => 'exact'],
$base,
$base,
);
$obj->exposeHandleSVGCharacter($parser, 'ab');
$obj->exposeParseSVGTagENDtextPath(1043);
$exact = $obj->getSvgObj(1043);
$xExact = $exact['textmode']['xlist'] ?? [];
$this->assertGreaterThanOrEqual(2, \count($xExact));
$obj->initSvgObjForHandlers(1044);
$obj->patchSvgObj(1044, ['styles' => [$base], 'defs' => $defs]);
$obj->exposeParseSVGTagSTARTtextPath($parser, 1044, ['href' => '#tp_space', 'spacing' => 'auto'], $base, $base);
$obj->exposeHandleSVGCharacter($parser, 'ab');
$obj->exposeParseSVGTagENDtextPath(1044);
$auto = $obj->getSvgObj(1044);
$xAuto = $auto['textmode']['xlist'] ?? [];
$this->assertGreaterThanOrEqual(2, \count($xAuto));
assert(isset($xExact[1]), "\$xExact[1] must be set");
assert(isset($xExact[0]), "\$xExact[0] must be set");
$gapExact = $xExact[1] - $xExact[0];
assert(isset($xAuto[1]), "\$xAuto[1] must be set");
assert(isset($xAuto[0]), "\$xAuto[0] must be set");
$gapAuto = $xAuto[1] - $xAuto[0];
$this->assertGreaterThan($gapExact, $gapAuto);
}
/**
* E-6: method="stretch" scales glyph advances to consume path length.
* @throws \Throwable
*/
public function testSvgTextPathMethodStretchExpandsGlyphGap(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$parser = \xml_parser_create('UTF-8');
$base = $obj->exposeDefaultSVGStyle();
$defs = [
'tp_stretch' => [
'name' => 'line',
'attr' => [
'x1' => '0',
'y1' => '0',
'x2' => '250',
'y2' => '0',
],
],
];
$obj->initSvgObjForHandlers(1045);
$obj->patchSvgObj(1045, ['styles' => [$base], 'defs' => $defs]);
$obj->exposeParseSVGTagSTARTtextPath(
$parser,
1045,
['href' => '#tp_stretch', 'method' => 'align'],
$base,
$base,
);
$obj->exposeHandleSVGCharacter($parser, 'ab');
$obj->exposeParseSVGTagENDtextPath(1045);
$align = $obj->getSvgObj(1045);
$xAlign = $align['textmode']['xlist'] ?? [];
$this->assertGreaterThanOrEqual(2, \count($xAlign));
$obj->initSvgObjForHandlers(1046);
$obj->patchSvgObj(1046, ['styles' => [$base], 'defs' => $defs]);
$obj->exposeParseSVGTagSTARTtextPath(
$parser,
1046,
['href' => '#tp_stretch', 'method' => 'stretch'],
$base,
$base,
);
$obj->exposeHandleSVGCharacter($parser, 'ab');
$obj->exposeParseSVGTagENDtextPath(1046);
$stretch = $obj->getSvgObj(1046);
$xStretch = $stretch['textmode']['xlist'] ?? [];
$this->assertGreaterThanOrEqual(2, \count($xStretch));
assert(isset($xAlign[1]), "\$xAlign[1] must be set");
assert(isset($xAlign[0]), "\$xAlign[0] must be set");
$gapAlign = $xAlign[1] - $xAlign[0];
assert(isset($xStretch[1]), "\$xStretch[1] must be set");
assert(isset($xStretch[0]), "\$xStretch[0] must be set");
$gapStretch = $xStretch[1] - $xStretch[0];
$this->assertGreaterThan($gapAlign, $gapStretch);
}
/**
* E-6: spacing="auto" distributes extra length as equal inter-glyph gaps.
* @throws \Throwable
*/
public function testSvgTextPathSpacingAutoPreservesMixedWidthGapDelta(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$parser = \xml_parser_create('UTF-8');
$base = $obj->exposeDefaultSVGStyle();
$defs = [
'tp_mixed_gap' => [
'name' => 'line',
'attr' => [
'x1' => '0',
'y1' => '0',
'x2' => '600',
'y2' => '0',
],
],
];
$obj->initSvgObjForHandlers(1053);
$obj->patchSvgObj(1053, ['styles' => [$base], 'defs' => $defs]);
$obj->exposeParseSVGTagSTARTtextPath(
$parser,
1053,
['href' => '#tp_mixed_gap', 'spacing' => 'exact'],
$base,
$base,
);
$obj->exposeHandleSVGCharacter($parser, 'WiW');
$obj->exposeParseSVGTagENDtextPath(1053);
$exact = $obj->getSvgObj(1053);
$xExact = $exact['textmode']['xlist'] ?? [];
$this->assertGreaterThanOrEqual(3, \count($xExact));
assert(isset($xExact[1]), "\$xExact[1] must be set");
assert(isset($xExact[0]), "\$xExact[0] must be set");
$gapExactOne = $xExact[1] - $xExact[0];
assert(isset($xExact[2]), "\$xExact[2] must be set");
$gapExactTwo = $xExact[2] - $xExact[1];
$obj->initSvgObjForHandlers(1054);
$obj->patchSvgObj(1054, ['styles' => [$base], 'defs' => $defs]);
$obj->exposeParseSVGTagSTARTtextPath(
$parser,
1054,
['href' => '#tp_mixed_gap', 'spacing' => 'auto'],
$base,
$base,
);
$obj->exposeHandleSVGCharacter($parser, 'WiW');
$obj->exposeParseSVGTagENDtextPath(1054);
$auto = $obj->getSvgObj(1054);
$xAuto = $auto['textmode']['xlist'] ?? [];
$this->assertGreaterThanOrEqual(3, \count($xAuto));
assert(isset($xAuto[1]), "\$xAuto[1] must be set");
assert(isset($xAuto[0]), "\$xAuto[0] must be set");
$gapAutoOne = $xAuto[1] - $xAuto[0];
assert(isset($xAuto[2]), "\$xAuto[2] must be set");
$gapAutoTwo = $xAuto[2] - $xAuto[1];
$this->assertGreaterThan($gapExactOne, $gapAutoOne);
$this->assertGreaterThan($gapExactTwo, $gapAutoTwo);
$this->assertEqualsWithDelta($gapExactOne - $gapExactTwo, $gapAutoOne - $gapAutoTwo, 0.5);
}
/**
* E-6: textPath with unresolved href still behaves like a nested text run.
* @throws \Throwable
*/
public function testSvgTextPathMissingReferenceStillRendersText(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$parser = \xml_parser_create('UTF-8');
$obj->initSvgObjForHandlers(105);
$base = $obj->exposeDefaultSVGStyle();
$obj->patchSvgObj(105, ['styles' => [$base]]);
$startOut = $obj->exposeParseSVGTagSTARTtextPath(
$parser,
105,
['href' => '#missing_path', 'x' => '12', 'y' => '34'],
$base,
$base,
);
$this->assertNotSame('', $startOut);
$obj->exposeHandleSVGCharacter($parser, 'abc');
$endOut = $obj->exposeParseSVGTagENDtextPath(105);
$this->assertNotSame('', $endOut);
$this->assertSame('', $obj->getSvgObj(105)['text']);
}
/**
* S-2: writing-mode vertical is stored in textmode by STARTtext.
* @throws \Throwable
*/
public function testSvgWritingModeVerticalSetsTextModeFlag(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$parser = \xml_parser_create('UTF-8');
$obj->initSvgObjForHandlers(107);
$base = $obj->exposeDefaultSVGStyle();
$vertical = $base;
$vertical['writing-mode'] = 'vertical-rl';
$out = $obj->exposeParseSVGTagSTARTtext($parser, 107, ['x' => '10', 'y' => '20'], $vertical, $base);
$this->assertNotSame('', $out);
$this->assertTrue($obj->getSvgObj(107)['textmode']['vertical'] ?? false);
$this->assertEqualsWithDelta(90.0, $obj->getSvgObj(107)['textmode']['rotate'] ?? 0.0, 0.001);
}
/**
* S-2: glyph-orientation-vertical overrides default 90deg vertical rotation.
* @throws \Throwable
*/
public function testSvgWritingModeVerticalGlyphOrientationOverride(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$parser = \xml_parser_create('UTF-8');
$obj->initSvgObjForHandlers(109);
$base = $obj->exposeDefaultSVGStyle();
$vertical = $base;
$vertical['writing-mode'] = 'tb-rl';
$vertical['glyph-orientation-vertical'] = '0deg';
$out = $obj->exposeParseSVGTagSTARTtext($parser, 109, ['x' => '10', 'y' => '20'], $vertical, $base);
$this->assertNotSame('', $out);
$this->assertTrue($obj->getSvgObj(109)['textmode']['vertical'] ?? false);
$this->assertEqualsWithDelta(0.0, $obj->getSvgObj(109)['textmode']['rotate'] ?? 0.0, 0.001);
}
/**
* S-2: glyph-orientation-horizontal is applied in horizontal writing mode.
* @throws \Throwable
*/
public function testSvgHorizontalGlyphOrientationSetsRotation(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$parser = \xml_parser_create('UTF-8');
$obj->initSvgObjForHandlers(110);
$base = $obj->exposeDefaultSVGStyle();
$hstyle = $base;
$hstyle['writing-mode'] = 'lr-tb';
$hstyle['glyph-orientation-horizontal'] = '180deg';
$out = $obj->exposeParseSVGTagSTARTtext($parser, 110, ['x' => '10', 'y' => '20'], $hstyle, $base);
$this->assertNotSame('', $out);
$this->assertFalse($obj->getSvgObj(110)['textmode']['vertical'] ?? false);
$this->assertEqualsWithDelta(180.0, $obj->getSvgObj(110)['textmode']['rotate'] ?? 0.0, 0.001);
}
/**
* S-2: invisible vertical text advances Y instead of X.
* @throws \Throwable
*/
public function testSvgInvisibleVerticalTextAdvancesYCursor(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(108);
$base = $obj->exposeDefaultSVGStyle();
$obj->patchSvgObj(108, [
'styles' => [$base],
'defsmode' => false,
'textmode' => [
'invisible' => true,
'vertical' => true,
'stroke' => 0,
'rtl' => false,
'text-anchor' => 'start',
],
'text' => 'VV',
'x' => 7.0,
'y' => 9.0,
]);
$xBefore = $obj->getSvgObj(108)['x'];
$yBefore = $obj->getSvgObj(108)['y'];
$out = $obj->exposeParseSVGTagENDtext(108);
$this->assertSame('', $out);
$this->assertSame($xBefore, $obj->getSvgObj(108)['x']);
$this->assertGreaterThan($yBefore, $obj->getSvgObj(108)['y']);
}
// -------------------------------------------------------------------------
// E-4 mask application tests
// -------------------------------------------------------------------------
/**
* parseSVGStyleMask with a valid url(#id) registers the mask stream and
* returns a PDF ExtGState activation command containing the mask key.
* @throws \Throwable
*/
public function testSvgMaskStyleResolvesUrlAndRegistersMaskStream(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(560);
// Build defs: one mask with a white rect child (captured structure).
$obj->patchSvgObj(560, [
'defs' => [
'myMask' => [
'name' => 'mask',
'attr' => ['id' => 'myMask'],
'child' => [
'DF_1' => [
'name' => 'rect',
'attr' => ['x' => '0', 'y' => '0', 'width' => '10', 'height' => '10', 'fill' => '#ffffff'],
],
'DF_1_CLOSE' => [
'name' => 'rect',
'attr' => ['closing_tag' => true, 'content' => ''],
],
],
],
],
]);
$style = $obj->exposeDefaultSVGStyle();
$style['mask'] = 'url(#myMask)';
$out = $obj->exposeParseSVGStyleMask(560, $style);
// Must return a GS activation command.
$this->assertStringContainsString(' gs', $out);
// The mask key used in the command must match what's registered.
$masks = $obj->getSvgMasks();
$this->assertNotEmpty($masks, 'svgmasks must contain the registered mask');
$maskKey = \array_key_first($masks);
if (!\is_string($maskKey)) {
$this->fail('Expected non-empty svgmasks key to be a string');
}
$this->assertStringContainsString($maskKey, $out);
$this->assertStringStartsWith('MSK_', $maskKey);
$maskData = [];
if (isset($masks[$maskKey]) && \is_array($masks[$maskKey])) {
$maskData = $masks[$maskKey];
}
$this->assertIsArray($maskData);
$this->assertArrayHasKey('stream', $maskData);
$this->assertArrayHasKey('bbox', $maskData);
$this->assertSame(0, $maskData['gs_n'] ?? null, 'gs_n is 0 until PDF output phase');
}
/**
* Calling parseSVGStyleMask with mask="none" returns empty string and does
* not register anything.
* @throws \Throwable
*/
public function testSvgMaskStyleNoneIsNoOp(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(561);
$style = $obj->exposeDefaultSVGStyle();
$style['mask'] = 'none';
$out = $obj->exposeParseSVGStyleMask(561, $style);
$this->assertSame('', $out);
$this->assertEmpty($obj->getSvgMasks());
}
/**
* A mask url that references a missing def returns empty string.
* @throws \Throwable
*/
public function testSvgMaskStyleMissingDefIsNoOp(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(562);
$style = $obj->exposeDefaultSVGStyle();
$style['mask'] = 'url(#nonExistent)';
$out = $obj->exposeParseSVGStyleMask(562, $style);
$this->assertSame('', $out);
$this->assertEmpty($obj->getSvgMasks());
}
/**
* Calling parseSVGStyleMask while patternmode > 0 returns empty string
* (guard against recursion during mask/pattern child replay).
* @throws \Throwable
*/
public function testSvgMaskStyleSkippedInsidePatternMode(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(563);
$obj->patchSvgObj(563, [
'patternmode' => 1,
'defs' => [
'mskX' => [
'name' => 'mask',
'attr' => ['id' => 'mskX'],
'child' => [],
],
],
]);
$style = $obj->exposeDefaultSVGStyle();
$style['mask'] = 'url(#mskX)';
$out = $obj->exposeParseSVGStyleMask(563, $style);
$this->assertSame('', $out);
$this->assertEmpty($obj->getSvgMasks());
}
/**
* Calling parseSVGStyleMask twice with the same mask id re-uses the
* already-registered mask entry without duplicating it.
* @throws \Throwable
*/
public function testSvgMaskStyleSameMaskIsRegisteredOnce(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(564);
$obj->patchSvgObj(564, [
'defs' => [
'sharedMask' => [
'name' => 'mask',
'attr' => ['id' => 'sharedMask'],
'child' => [],
],
],
]);
$style = $obj->exposeDefaultSVGStyle();
$style['mask'] = 'url(#sharedMask)';
$out1 = $obj->exposeParseSVGStyleMask(564, $style);
$out2 = $obj->exposeParseSVGStyleMask(564, $style);
$this->assertSame($out1, $out2, 'Both calls return the same GS command');
$this->assertCount(1, $obj->getSvgMasks(), 'Only one mask entry registered');
}
// -------------------------------------------------------------------------
// E-5 filter unsupported / no-op tests
// -------------------------------------------------------------------------
/**
* parseSVGTagSTARTfilter with an id registers a placeholder in defs and
* enables defsmode so fe* children are swallowed.
* @throws \Throwable
*/
public function testSvgFilterStartRegistersPlaceholderAndEnablesDefsMode(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(570);
$this->assertSame('', $obj->exposeParseSVGTagSTARTfilter(570, ['id' => 'f1', 'x' => '0', 'y' => '0']));
$svgobj = $obj->getSvgObj(570);
$this->assertTrue($svgobj['defsmode']);
$this->assertArrayHasKey('f1', $svgobj['defs']);
$this->assertSame('filter', $svgobj['defs']['f1']['name'] ?? null);
}
/**
* parseSVGTagSTARTfilter without an id still enables defsmode but does not
* add a defs entry.
* @throws \Throwable
*/
public function testSvgFilterStartWithoutIdOnlyEnablesDefsMode(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(571);
$this->assertSame('', $obj->exposeParseSVGTagSTARTfilter(571, []));
$svgobj = $obj->getSvgObj(571);
$this->assertTrue($svgobj['defsmode']);
$this->assertSame([], $svgobj['defs']);
}
/**
* parseSVGTagENDfilter clears defsmode.
* @throws \Throwable
*/
public function testSvgFilterEndClearsDefsMode(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(572);
$obj->exposeParseSVGTagSTARTfilter(572, ['id' => 'f2']);
$this->assertTrue($obj->getSvgObj(572)['defsmode']);
$this->assertSame('', $obj->exposeParseSVGTagENDfilter(572));
$this->assertFalse($obj->getSvgObj(572)['defsmode']);
}
// -------------------------------------------------------------------------
// S-6 rendering-hint tests
// -------------------------------------------------------------------------
/**
* 'auto' hints for all properties emit no ri operator (use PDF default).
* @throws \Throwable
*/
public function testSvgRenderingHintsAllAutoEmitsNothing(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$style = $obj->exposeDefaultSVGStyle();
// defaults are already 'auto'
$this->assertSame('', $obj->exposeParseSVGStyleRenderingHints($style));
}
/**
* 'optimizeQuality' on any property emits /RelativeColorimetric ri.
* @throws \Throwable
*/
public function testSvgRenderingHintsOptimizeQualityEmitsRelativeColorimetric(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$style = $obj->exposeDefaultSVGStyle();
$style['image-rendering'] = 'optimizeQuality';
$out = $obj->exposeParseSVGStyleRenderingHints($style);
$this->assertSame("/RelativeColorimetric ri\n", $out);
}
/**
* 'optimizeSpeed' on any property emits /AbsoluteColorimetric ri.
* @throws \Throwable
*/
public function testSvgRenderingHintsOptimizeSpeedEmitsAbsoluteColorimetric(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$style = $obj->exposeDefaultSVGStyle();
$style['color-rendering'] = 'optimizeSpeed';
$out = $obj->exposeParseSVGStyleRenderingHints($style);
$this->assertSame("/AbsoluteColorimetric ri\n", $out);
}
/**
* 'crispEdges' and 'geometricPrecision' map to RelativeColorimetric.
* @throws \Throwable
*/
public function testSvgRenderingHintsCrispEdgesAndGeometricPrecision(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$style = $obj->exposeDefaultSVGStyle();
$style['shape-rendering'] = 'crispEdges';
$this->assertSame("/RelativeColorimetric ri\n", $obj->exposeParseSVGStyleRenderingHints($style));
$style['shape-rendering'] = 'geometricPrecision';
$this->assertSame("/RelativeColorimetric ri\n", $obj->exposeParseSVGStyleRenderingHints($style));
}
/**
* When both optimizeQuality and optimizeSpeed hints are present,
* optimizeSpeed wins (last in priority order).
* @throws \Throwable
*/
public function testSvgRenderingHintsSpeedBeatsQualityWhenBothPresent(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$style = $obj->exposeDefaultSVGStyle();
$style['image-rendering'] = 'optimizeQuality';
$style['color-rendering'] = 'optimizeSpeed';
$out = $obj->exposeParseSVGStyleRenderingHints($style);
$this->assertSame("/AbsoluteColorimetric ri\n", $out);
}
/**
* <filter> is in SVGCHARDATASKIPTAGS so the entire filter+fe* subtree is
* silently skipped via the charskip counter: charskip is balanced (back to
* zero) after the subtree, defsmode is correctly restored by </defs>, and
* no output is written.
* @throws \Throwable
*/
public function testSvgFilterRoundTripViaHandlersProducesNoOutput(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(573);
$parser = \xml_parser_create('UTF-8');
// Enter defs context.
$obj->exposeHandleSVGTagStart($parser, 'defs', [], 573);
// <filter> and all fe* children are in SVGCHARDATASKIPTAGS so the start
// handler just does charskip++ and returns immediately (no dispatch).
$obj->exposeHandleSVGTagStart($parser, 'filter', ['id' => 'blur1'], 573);
$obj->exposeHandleSVGTagStart($parser, 'feGaussianBlur', [], 573);
$obj->exposeHandleSVGTagEnd($parser, 'feGaussianBlur');
$obj->exposeHandleSVGTagEnd($parser, 'filter');
// Close defs.
$obj->exposeHandleSVGTagEnd($parser, 'defs');
unset($parser);
$svgobj = $obj->getSvgObj(573);
// charskip counter must be balanced back to zero after the subtree.
$this->assertSame(0, (int) $svgobj['charskip'], 'charskip balanced');
// defsmode off after </defs>
$this->assertFalse($svgobj['defsmode'], 'defsmode false after </defs>');
// No stray rendered output.
$this->assertSame('', $svgobj['out'], 'no output from filter subtree');
}
/**
* S-3: getSVGPath returns empty when paint operator is empty (line 1111).
* @throws \Throwable
*/
public function testGetSVGPathReturnEmptyOnEmptyPaintOp(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(574);
// mode 'X' produces empty paint operator
$out = $obj->exposeGetSVGPath(574, 'M 10 10 L 20 20', 'X');
$this->assertSame('', $out);
}
/**
* S-3: getSVGPath covers default branch in match (unknown cmd → line 1184).
* @throws \Throwable
*/
public function testGetSVGPathUnknownCommandProducesEmptyOutput(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(575);
// 'D' is not a valid SVG path command, hitting the default '' branch
$out = $obj->exposeGetSVGPath(575, 'D 10 10', 'DF');
$this->assertSame('', $out);
}
/**
* S-3: parseSVGGlyphOrientationAngle with rad unit (lines 2156-2160).
* @throws \Throwable
*/
public function testParseSVGGlyphOrientationAngleRadians(): void
{
$obj = $this->getInternalTestObject();
$result = $obj->exposeParseSVGGlyphOrientationAngle('1.5708rad', 0.0);
$this->assertEqualsWithDelta(90.0, $result, 0.01);
}
/**
* S-3: parseSVGGlyphOrientationAngle with grad unit (line 2167-2168).
* @throws \Throwable
*/
public function testParseSVGGlyphOrientationAngleGradians(): void
{
$obj = $this->getInternalTestObject();
$result = $obj->exposeParseSVGGlyphOrientationAngle('100grad', 0.0);
$this->assertEqualsWithDelta(90.0, $result, 0.001);
}
/**
* S-3: parseSVGGlyphOrientationAngle with empty string returns default (line 2156).
* @throws \Throwable
*/
public function testParseSVGGlyphOrientationAngleEmptyStringReturnsDefault(): void
{
$obj = $this->getInternalTestObject();
$result = $obj->exposeParseSVGGlyphOrientationAngle('', 45.0);
$this->assertEqualsWithDelta(45.0, $result, 0.001);
}
/**
* S-3: parseSVGGlyphOrientationAngle non-matching string returns default (line 2160).
* @throws \Throwable
*/
public function testParseSVGGlyphOrientationAngleInvalidReturnsDefault(): void
{
$obj = $this->getInternalTestObject();
$result = $obj->exposeParseSVGGlyphOrientationAngle('auto', 30.0);
$this->assertEqualsWithDelta(30.0, $result, 0.001);
}
/**
* S-3: getSVGGlyphOrientationRotation horizontal 'auto' returns 0.0 (line 2197).
* @throws \Throwable
*/
public function testGetSVGGlyphOrientationRotationHorizontalAutoReturnsZero(): void
{
$obj = $this->getInternalTestObject();
$base = $obj->exposeDefaultSVGStyle();
$style = $base;
$style['glyph-orientation-horizontal'] = 'auto';
$result = $obj->exposeGetSVGGlyphOrientationRotation($style, false);
$this->assertEqualsWithDelta(0.0, $result, 0.001);
}
/**
* S-3: resolveSVGPatternLength with percentage value (lines 2525, 2529-2530).
* @throws \Throwable
*/
public function testResolveSVGPatternLengthPercentage(): void
{
$obj = $this->getInternalTestObject();
$obj->initSvgObjForHandlers(576);
$result = $obj->exposeResolveSVGPatternLength('50%', 200.0, 576);
$this->assertEqualsWithDelta(100.0, $result, 0.001);
}
/**
* S-3: resolveSVGPatternLength empty string returns 0 (line 2525).
* @throws \Throwable
*/
public function testResolveSVGPatternLengthEmptyReturnsZero(): void
{
$obj = $this->getInternalTestObject();
$obj->initSvgObjForHandlers(577);
$result = $obj->exposeResolveSVGPatternLength('', 100.0, 577);
$this->assertEqualsWithDelta(0.0, $result, 0.001);
}
/**
* S-3: getTextPathPointsFromPathData with horizontal H command (lines 6136-6187 range).
* @throws \Throwable
*/
public function testGetTextPathPointsFromPathDataWithHCommand(): void
{
$obj = $this->getInternalTestObject();
$obj->initSvgObjForHandlers(578);
$points = $obj->exposeGetTextPathPointsFromPathData(578, 'M 0 0 H 100');
$this->assertIsArray($points);
$this->assertGreaterThanOrEqual(2, \count($points));
}
/**
* S-3: getTextPathPointsFromPathData with V command (lines 6235-6240).
* @throws \Throwable
*/
public function testGetTextPathPointsFromPathDataWithVCommand(): void
{
$obj = $this->getInternalTestObject();
$obj->initSvgObjForHandlers(579);
$points = $obj->exposeGetTextPathPointsFromPathData(579, 'M 0 0 V 50');
$this->assertIsArray($points);
$this->assertGreaterThanOrEqual(2, \count($points));
}
/**
* S-3: getTextPathPointsFromPathData with cubic bezier C command (lines 6267-6298).
* @throws \Throwable
*/
public function testGetTextPathPointsFromPathDataWithCCommand(): void
{
$obj = $this->getInternalTestObject();
$obj->initSvgObjForHandlers(580);
$points = $obj->exposeGetTextPathPointsFromPathData(580, 'M 0 0 C 10 20 30 40 50 0');
$this->assertIsArray($points);
$this->assertGreaterThanOrEqual(2, \count($points));
}
/**
* S-3: getTextPathPointsFromPathData with smooth S command (lines 6300-6334).
* @throws \Throwable
*/
public function testGetTextPathPointsFromPathDataWithSCommand(): void
{
$obj = $this->getInternalTestObject();
$obj->initSvgObjForHandlers(581);
$points = $obj->exposeGetTextPathPointsFromPathData(581, 'M 0 0 C 10 20 30 40 50 0 S 70 -20 100 0');
$this->assertIsArray($points);
$this->assertGreaterThanOrEqual(2, \count($points));
}
/**
* S-3: getTextPathPointsFromPathData with quadratic Q command (lines 6311-6322).
* @throws \Throwable
*/
public function testGetTextPathPointsFromPathDataWithQCommand(): void
{
$obj = $this->getInternalTestObject();
$obj->initSvgObjForHandlers(582);
$points = $obj->exposeGetTextPathPointsFromPathData(582, 'M 0 0 Q 50 50 100 0');
$this->assertIsArray($points);
$this->assertGreaterThanOrEqual(2, \count($points));
}
/**
* S-3: getTextPathPointsFromPathData with T (smooth quadratic) command (lines 6324-6343).
* @throws \Throwable
*/
public function testGetTextPathPointsFromPathDataWithTCommand(): void
{
$obj = $this->getInternalTestObject();
$obj->initSvgObjForHandlers(583);
$points = $obj->exposeGetTextPathPointsFromPathData(583, 'M 0 0 Q 25 50 50 0 T 100 0');
$this->assertIsArray($points);
$this->assertGreaterThanOrEqual(2, \count($points));
}
/**
* S-3: getTextPathPointsFromPathData with arc A command (lines 6348-6388).
* @throws \Throwable
*/
public function testGetTextPathPointsFromPathDataWithACommand(): void
{
$obj = $this->getInternalTestObject();
$obj->initSvgObjForHandlers(584);
$points = $obj->exposeGetTextPathPointsFromPathData(584, 'M 0 0 A 50 50 0 0 1 100 0');
$this->assertIsArray($points);
$this->assertGreaterThanOrEqual(2, \count($points));
}
/**
* S-3: getTextPathPointsFromPathData returns null for empty path (line 6112).
* @throws \Throwable
*/
public function testGetTextPathPointsFromPathDataEmptyReturnsNull(): void
{
$obj = $this->getInternalTestObject();
$obj->initSvgObjForHandlers(585);
$points = $obj->exposeGetTextPathPointsFromPathData(585, '');
$this->assertNull($points);
}
/**
* S-3: getTextPathPointsFromDef with 'line' defName (lines 6077-6087).
* @throws \Throwable
*/
public function testGetTextPathPointsFromDefLine(): void
{
$obj = $this->getInternalTestObject();
$obj->initSvgObjForHandlers(586);
$points = $obj->exposeGetTextPathPointsFromDef(586, 'line', [
'x1' => '0',
'y1' => '0',
'x2' => '100',
'y2' => '0',
]);
$this->assertIsArray($points);
$this->assertCount(2, $points);
}
/**
* S-3: getTextPathPointsFromDef with 'polyline' defName (lines 6077-6087).
* @throws \Throwable
*/
public function testGetTextPathPointsFromDefPolyline(): void
{
$obj = $this->getInternalTestObject();
$obj->initSvgObjForHandlers(587);
$points = $obj->exposeGetTextPathPointsFromDef(587, 'polyline', ['points' => '0,0 50,50 100,0']);
$this->assertIsArray($points);
$this->assertCount(3, $points);
}
/**
* S-3: getTextPathPointsFromDef with 'polygon' closes the path (line 6096).
* @throws \Throwable
*/
public function testGetTextPathPointsFromDefPolygonClosesPath(): void
{
$obj = $this->getInternalTestObject();
$obj->initSvgObjForHandlers(588);
$points = $obj->exposeGetTextPathPointsFromDef(588, 'polygon', ['points' => '0,0 50,50 100,0']);
$this->assertIsArray($points);
// polygon adds closing point so 4 points total
$this->assertCount(4, $points);
}
/**
* S-3: getTextPathPointsFromDef with unknown defName returns null (line 6112).
* @throws \Throwable
*/
public function testGetTextPathPointsFromDefUnknownReturnsNull(): void
{
$obj = $this->getInternalTestObject();
$obj->initSvgObjForHandlers(589);
$points = $obj->exposeGetTextPathPointsFromDef(589, 'rect', ['width' => '100', 'height' => '50']);
$this->assertNull($points);
}
/**
* S-3: getTextPathPointsFromPathData with Z close command (covers Z → ptlist append).
* @throws \Throwable
*/
public function testGetTextPathPointsFromPathDataWithZCommand(): void
{
$obj = $this->getInternalTestObject();
$obj->initSvgObjForHandlers(590);
$points = $obj->exposeGetTextPathPointsFromPathData(590, 'M 0 0 L 50 0 L 50 50 Z');
$this->assertIsArray($points);
$this->assertGreaterThanOrEqual(2, \count($points));
}
/**
* S-3: getTextPathPointsFromPathData with relative commands (lowercase m l h v).
* @throws \Throwable
*/
public function testGetTextPathPointsFromPathDataRelativeCommands(): void
{
$obj = $this->getInternalTestObject();
$obj->initSvgObjForHandlers(591);
$points = $obj->exposeGetTextPathPointsFromPathData(591, 'M 10 10 l 40 0 h 20 v 30');
$this->assertIsArray($points);
$this->assertGreaterThanOrEqual(2, \count($points));
}
// ── sampleTextPathArc ────────────────────────────────────────────────────
/**
* S-4: sampleTextPathArc returns [] when start == end (same point).
* @throws \Throwable
*/
public function testSampleTextPathArcSameStartEndReturnsEmpty(): void
{
$obj = $this->getInternalTestObject();
$result = $obj->exposeSampleTextPathArc(10.0, 20.0, 50.0, 50.0, 0.0, false, true, 10.0, 20.0);
$this->assertSame([], $result);
}
/**
* S-4: sampleTextPathArc scales radii (lambda > 1) and returns sampled points.
* @throws \Throwable
*/
public function testSampleTextPathArcScaledRadiusReturnsPoints(): void
{
$obj = $this->getInternalTestObject();
// radiusX=1.0 is far too small for a 100-unit span → lambda > 1 → scaling branch fires
$result = $obj->exposeSampleTextPathArc(0.0, 0.0, 1.0, 1.0, 0.0, false, true, 100.0, 0.0);
$this->assertGreaterThanOrEqual(2, \count($result));
}
/**
* S-4: sampleTextPathArc with a normal semicircle returns multiple points.
* @throws \Throwable
*/
public function testSampleTextPathArcNormalArcReturnsPoints(): void
{
$obj = $this->getInternalTestObject();
$result = $obj->exposeSampleTextPathArc(0.0, 0.0, 50.0, 50.0, 0.0, false, true, 100.0, 0.0);
$this->assertGreaterThanOrEqual(2, \count($result));
// last point should approach the end coordinates
$last = \end($result);
$this->assertNotFalse($last);
$this->assertEqualsWithDelta(100.0, $last[0], 1.0);
$this->assertEqualsWithDelta(0.0, $last[1], 1.0);
}
/**
* S-4: sampleTextPathArc with large-arc flag covers alternate path.
* @throws \Throwable
*/
public function testSampleTextPathArcLargeArcFlagReturnsPoints(): void
{
$obj = $this->getInternalTestObject();
$result = $obj->exposeSampleTextPathArc(0.0, 0.0, 50.0, 50.0, 0.0, true, false, 100.0, 0.0);
$this->assertGreaterThanOrEqual(2, \count($result));
}
// ── getArcVectorAngle ────────────────────────────────────────────────────
/**
* S-4: getArcVectorAngle returns 90° between (1,0) and (0,1).
* @throws \Throwable
*/
public function testGetArcVectorAngleReturnsExpectedAngle(): void
{
$obj = $this->getInternalTestObject();
$angle = $obj->exposeGetArcVectorAngle(1.0, 0.0, 0.0, 1.0);
$this->assertEqualsWithDelta(\M_PI / 2.0, $angle, 0.001);
}
/**
* S-4: getArcVectorAngle returns 0 for parallel same-direction vectors.
* @throws \Throwable
*/
public function testGetArcVectorAngleParallelVectorsReturnsZero(): void
{
$obj = $this->getInternalTestObject();
$angle = $obj->exposeGetArcVectorAngle(1.0, 0.0, 1.0, 0.0);
$this->assertEqualsWithDelta(0.0, $angle, 0.001);
}
// ── getTextPathLength ────────────────────────────────────────────────────
/**
* S-4: getTextPathLength of a horizontal segment returns correct length.
* @throws \Throwable
*/
public function testGetTextPathLengthHorizontalSegment(): void
{
$obj = $this->getInternalTestObject();
$length = $obj->exposeGetTextPathLength([[0.0, 0.0], [100.0, 0.0]]);
$this->assertEqualsWithDelta(100.0, $length, 0.001);
}
/**
* S-4: getTextPathLength of empty / single-point list returns 0.
* @throws \Throwable
*/
public function testGetTextPathLengthEmptyReturnsZero(): void
{
$obj = $this->getInternalTestObject();
$this->assertEqualsWithDelta(0.0, $obj->exposeGetTextPathLength([]), 0.001);
$this->assertEqualsWithDelta(0.0, $obj->exposeGetTextPathLength([[5.0, 5.0]]), 0.001);
}
// ── getTextPathPointAtOffset ─────────────────────────────────────────────
/**
* S-4: getTextPathPointAtOffset returns null for fewer than 2 points.
* @throws \Throwable
*/
public function testGetTextPathPointAtOffsetTooFewPointsReturnsNull(): void
{
$obj = $this->getInternalTestObject();
$this->assertNull($obj->exposeGetTextPathPointAtOffset([], 0.0));
$this->assertNull($obj->exposeGetTextPathPointAtOffset([[0.0, 0.0]], 0.0));
}
/**
* S-4: getTextPathPointAtOffset returns null for zero-length path.
* @throws \Throwable
*/
public function testGetTextPathPointAtOffsetZeroLengthReturnsNull(): void
{
$obj = $this->getInternalTestObject();
// duplicate point → zero-length path
$result = $obj->exposeGetTextPathPointAtOffset([[5.0, 5.0], [5.0, 5.0]], 0.0);
$this->assertNull($result);
}
/**
* S-4: getTextPathPointAtOffset skips zero-length (duplicate) segment via continue.
* @throws \Throwable
*/
public function testGetTextPathPointAtOffsetZeroLengthSegmentIsContinued(): void
{
$obj = $this->getInternalTestObject();
// First segment has zero length (duplicate point); offset is in the second segment.
$points = [[0.0, 0.0], [0.0, 0.0], [100.0, 0.0]];
$result = $obj->exposeGetTextPathPointAtOffset($points, 50.0);
$this->assertIsArray($result);
$this->assertEqualsWithDelta(50.0, $result[0], 0.001);
$this->assertEqualsWithDelta(0.0, $result[1], 0.001);
}
/**
* S-4: getTextPathPointAtOffset skips past a segment (covers remaining -= segLength branch).
* @throws \Throwable
*/
public function testGetTextPathPointAtOffsetMultiSegmentSkipsFirst(): void
{
$obj = $this->getInternalTestObject();
// Three-point path; offset=75 is past the first segment (length 50).
$points = [[0.0, 0.0], [50.0, 0.0], [100.0, 0.0]];
$result = $obj->exposeGetTextPathPointAtOffset($points, 75.0);
$this->assertIsArray($result);
$this->assertEqualsWithDelta(75.0, $result[0], 0.001);
$this->assertEqualsWithDelta(0.0, $result[1], 0.001);
}
/**
* S-4: getTextPathPointAtOffset at offset 0 returns near the start point.
* @throws \Throwable
*/
public function testGetTextPathPointAtOffsetZeroReturnsStartPoint(): void
{
$obj = $this->getInternalTestObject();
$points = [[0.0, 0.0], [100.0, 0.0]];
$result = $obj->exposeGetTextPathPointAtOffset($points, 0.0);
$this->assertIsArray($result);
$this->assertEqualsWithDelta(0.0, $result[0], 0.001);
$this->assertEqualsWithDelta(0.0, $result[1], 0.001);
$this->assertEqualsWithDelta(0.0, $result[2], 0.001); // angle
}
/**
* S-4: getTextPathPointAtOffset past the end returns the last point.
* @throws \Throwable
*/
public function testGetTextPathPointAtOffsetPastEndReturnsLastPoint(): void
{
$obj = $this->getInternalTestObject();
$points = [[0.0, 0.0], [100.0, 0.0]];
$result = $obj->exposeGetTextPathPointAtOffset($points, 999.0);
$this->assertIsArray($result);
$this->assertEqualsWithDelta(100.0, $result[0], 0.001);
$this->assertEqualsWithDelta(0.0, $result[1], 0.001);
}
/**
* S-4: getTextPathPointAtOffset at midpoint returns interpolated point.
* @throws \Throwable
*/
public function testGetTextPathPointAtOffsetMidpointInterpolates(): void
{
$obj = $this->getInternalTestObject();
$points = [[0.0, 0.0], [100.0, 0.0]];
$result = $obj->exposeGetTextPathPointAtOffset($points, 50.0);
$this->assertIsArray($result);
$this->assertEqualsWithDelta(50.0, $result[0], 0.001);
$this->assertEqualsWithDelta(0.0, $result[1], 0.001);
}
// ── getSVGResolvedMarker ─────────────────────────────────────────────────
/**
* S-4: getSVGResolvedMarker returns specific when it is set and not 'none'.
* @throws \Throwable
*/
public function testGetSVGResolvedMarkerSpecificReturned(): void
{
$obj = $this->getInternalTestObject();
$result = $obj->exposeGetSVGResolvedMarker('url(#arrow)', 'url(#other)');
$this->assertSame('url(#arrow)', $result);
}
/**
* S-4: getSVGResolvedMarker returns 'none' when specific='none' and fallback=false.
* @throws \Throwable
*/
public function testGetSVGResolvedMarkerSpecificNoneNoFallbackReturnsNone(): void
{
$obj = $this->getInternalTestObject();
$result = $obj->exposeGetSVGResolvedMarker('none', 'url(#arrow)', false);
$this->assertSame('none', $result);
}
/**
* S-4: getSVGResolvedMarker falls back to markerAll when specific='none' and markerAll valid.
* @throws \Throwable
*/
public function testGetSVGResolvedMarkerFallsBackToMarkerAll(): void
{
$obj = $this->getInternalTestObject();
$result = $obj->exposeGetSVGResolvedMarker('none', 'url(#arrow)', true);
$this->assertSame('url(#arrow)', $result);
}
/**
* S-4: getSVGResolvedMarker returns specific when specific='none', fallback=true but markerAll empty.
* @throws \Throwable
*/
public function testGetSVGResolvedMarkerNoneWithEmptyMarkerAllReturnsSpecific(): void
{
$obj = $this->getInternalTestObject();
$result = $obj->exposeGetSVGResolvedMarker('none', '', true);
$this->assertSame('none', $result);
}
/**
* S-4: getSVGResolvedMarker returns empty when both specific and markerAll are empty.
* @throws \Throwable
*/
public function testGetSVGResolvedMarkerBothEmptyReturnsEmpty(): void
{
$obj = $this->getInternalTestObject();
$result = $obj->exposeGetSVGResolvedMarker('', '');
$this->assertSame('', $result);
}
// ── parseSVGTagENDa ──────────────────────────────────────────────────────
/**
* S-4: parseSVGTagENDa returns '' and resets linkhref when href is set (horizontal).
* @throws \Throwable
*/
public function testParseSVGTagENDaWithLinkHrefReturnsEmpty(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(592);
$obj->patchSvgObj(592, [
'x' => 20.0,
'y' => 10.0,
'textmode' => [
'linkhref' => 'https://example.com',
'linkx' => 5.0,
'linky' => 5.0,
'vertical' => false,
'invisible' => false,
'stroke' => 0,
'rtl' => false,
'text-anchor' => 'start',
],
]);
$out = $obj->exposeParseSVGTagENDa(592);
// linkhref is cleared, returns ''
$this->assertSame('', $out);
$svgobj = $obj->getSvgObj(592);
$this->assertSame('', $svgobj['textmode']['linkhref'] ?? '');
}
/**
* S-4: parseSVGTagENDa with vertical text mode covers the vertical width/height branch.
* @throws \Throwable
*/
public function testParseSVGTagENDaVerticalModeCoversVerticalBranch(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(593);
$obj->patchSvgObj(593, [
'x' => 20.0,
'y' => 50.0,
'textmode' => [
'linkhref' => 'https://example.com/vertical',
'linkx' => 5.0,
'linky' => 5.0,
'vertical' => true,
'invisible' => false,
'stroke' => 0,
'rtl' => false,
'text-anchor' => 'start',
],
]);
$out = $obj->exposeParseSVGTagENDa(593);
$this->assertSame('', $out);
$svgobj = $obj->getSvgObj(593);
$this->assertSame('', $svgobj['textmode']['linkhref'] ?? '');
}
// ── getSetSVG ────────────────────────────────────────────────────────────
/**
* S-4: getSetSVG throws PdfException for an unknown soid.
* @throws \Throwable
*/
public function testGetSetSVGInvalidSoidThrows(): void
{
$obj = $this->getTestObject();
$this->expectException(\Com\Tecnick\Pdf\Exception::class);
$obj->getSetSVG(99999);
}
// ── getTextPathPointsFromPathData branch coverage ──────────────────────
/**
* S-5: path with no leading letter hits the empty-command handler (lines 6148-6149).
* @throws \Throwable
*/
public function testGetTextPathPointsFromPathDataNoLeadingLetterReturnsNull(): void
{
$obj = $this->getInternalTestObject();
$obj->initSvgObjForHandlers(599);
$result = $obj->exposeGetTextPathPointsFromPathData(599, '100 200');
$this->assertNull($result);
}
/**
* S-5: 'M 0' has too few coords → break in m/l/t block (line 6157).
* @throws \Throwable
*/
public function testGetTextPathPointsFromPathDataTooFewMLTCoordsBreaks(): void
{
$obj = $this->getInternalTestObject();
$obj->initSvgObjForHandlers(600);
$result = $obj->exposeGetTextPathPointsFromPathData(600, 'M 0');
$this->assertNull($result);
}
/**
* S-5: lowercase 'c' covers the relative C adjustment block (lines 6235-6240).
* @throws \Throwable
*/
public function testGetTextPathPointsFromPathDataRelativeCCommand(): void
{
$obj = $this->getInternalTestObject();
$obj->initSvgObjForHandlers(601);
$points = $obj->exposeGetTextPathPointsFromPathData(601, 'M 0 0 c 10 20 30 40 50 0');
$this->assertIsArray($points);
$this->assertGreaterThanOrEqual(2, \count($points));
}
/**
* S-5: incomplete 'C' with too few params hits break (line 6226).
* @throws \Throwable
*/
public function testGetTextPathPointsFromPathDataIncompleteCBreaks(): void
{
$obj = $this->getInternalTestObject();
$obj->initSvgObjForHandlers(602);
$result = $obj->exposeGetTextPathPointsFromPathData(602, 'M 0 0 C 10 20 30');
$this->assertNull($result);
}
/**
* S-5: lowercase 's' covers the relative S adjustment block (lines 6281-6284).
* @throws \Throwable
*/
public function testGetTextPathPointsFromPathDataRelativeSCommand(): void
{
$obj = $this->getInternalTestObject();
$obj->initSvgObjForHandlers(603);
$points = $obj->exposeGetTextPathPointsFromPathData(603, 'M 0 0 s 10 20 50 0');
$this->assertIsArray($points);
$this->assertGreaterThanOrEqual(2, \count($points));
}
/**
* S-5: incomplete 'S' hits break (line 6268).
* @throws \Throwable
*/
public function testGetTextPathPointsFromPathDataIncompleteSBreaks(): void
{
$obj = $this->getInternalTestObject();
$obj->initSvgObjForHandlers(604);
$result = $obj->exposeGetTextPathPointsFromPathData(604, 'M 0 0 S 10 20');
$this->assertNull($result);
}
/**
* S-5: lowercase 'q' covers the relative Q adjustment block (lines 6319-6322).
* @throws \Throwable
*/
public function testGetTextPathPointsFromPathDataRelativeQCommand(): void
{
$obj = $this->getInternalTestObject();
$obj->initSvgObjForHandlers(605);
$points = $obj->exposeGetTextPathPointsFromPathData(605, 'M 0 0 q 25 50 50 0');
$this->assertIsArray($points);
$this->assertGreaterThanOrEqual(2, \count($points));
}
/**
* S-5: incomplete 'Q' hits break (line 6312).
* @throws \Throwable
*/
public function testGetTextPathPointsFromPathDataIncompleteQBreaks(): void
{
$obj = $this->getInternalTestObject();
$obj->initSvgObjForHandlers(606);
$result = $obj->exposeGetTextPathPointsFromPathData(606, 'M 0 0 Q 10 20');
$this->assertNull($result);
}
/**
* S-5: lowercase 'a' covers the relative A end-point adjustment (lines 6358-6359).
* @throws \Throwable
*/
public function testGetTextPathPointsFromPathDataRelativeACommand(): void
{
$obj = $this->getInternalTestObject();
$obj->initSvgObjForHandlers(607);
$points = $obj->exposeGetTextPathPointsFromPathData(607, 'M 0 0 a 50 50 0 0 1 100 0');
$this->assertIsArray($points);
$this->assertGreaterThanOrEqual(2, \count($points));
}
/**
* S-5: incomplete 'A' hits break (line 6348).
* @throws \Throwable
*/
public function testGetTextPathPointsFromPathDataIncompleteABreaks(): void
{
$obj = $this->getInternalTestObject();
$obj->initSvgObjForHandlers(608);
$result = $obj->exposeGetTextPathPointsFromPathData(608, 'M 0 0 A 50 50 0 0');
$this->assertNull($result);
}
/**
* S-5: arc with same start/end → sampleTextPathArc returns [] → fallback ptlist
* entry is appended (line 6378).
* @throws \Throwable
*/
public function testGetTextPathPointsFromPathDataSameStartEndArcAddsPoint(): void
{
$obj = $this->getInternalTestObject();
$obj->initSvgObjForHandlers(609);
// Start at (50,0), arc back to same point → sampleTextPathArc returns [].
$points = $obj->exposeGetTextPathPointsFromPathData(609, 'M 50 0 A 50 50 0 0 1 50 0');
$this->assertIsArray($points);
$this->assertGreaterThanOrEqual(2, \count($points));
}
// ── parseSVGStyleMask branch coverage ────────────────────────────────────
/**
* S-4: parseSVGStyleMask returns '' when transparency is not allowed (PDF/X-1a mode).
* @throws \Throwable
*/
public function testSvgMaskStyleNoTransparencyReturnsEmpty(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(594);
// Disable transparency by setting PDF/X mode to pdfx1a.
$this->setObjectProperty($obj, 'pdfx', true);
$this->setObjectProperty($obj, 'pdfxMode', 'pdfx1a');
$obj->patchSvgObj(594, [
'defs' => [
'myMask' => ['name' => 'mask', 'attr' => ['id' => 'myMask'], 'child' => []],
],
]);
$style = $obj->exposeDefaultSVGStyle();
$style['mask'] = 'url(#myMask)';
$out = $obj->exposeParseSVGStyleMask(594, $style);
$this->assertSame('', $out);
}
/**
* S-4: parseSVGStyleMask with non-url mask value returns '' (regex mismatch branch).
* @throws \Throwable
*/
public function testSvgMaskStyleNonUrlValueReturnsEmpty(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(595);
$style = $obj->exposeDefaultSVGStyle();
// Not 'none', not '', but also not a url(...) value.
$style['mask'] = 'invalidValue';
$out = $obj->exposeParseSVGStyleMask(595, $style);
$this->assertSame('', $out);
}
/**
* S-4: parseSVGStyleMask where def exists but has wrong type returns ''.
* @throws \Throwable
*/
public function testSvgMaskStyleWrongDefTypeReturnsEmpty(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(596);
$obj->patchSvgObj(596, [
'defs' => [
'badDef' => ['name' => 'linearGradient', 'attr' => ['id' => 'badDef']],
],
]);
$style = $obj->exposeDefaultSVGStyle();
$style['mask'] = 'url(#badDef)';
$out = $obj->exposeParseSVGStyleMask(596, $style);
$this->assertSame('', $out);
}
/**
* S-4: parseSVGStyleMask skips non-array child elements (continue branch).
* @throws \Throwable
*/
public function testSvgMaskStyleNonArrayChildIsContinued(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(597);
$obj->patchSvgObj(597, [
'defs' => [
'm1' => [
'name' => 'mask',
'attr' => ['id' => 'm1'],
// One scalar (non-array) child → triggers the continue branch.
'child' => ['not_an_array'],
],
],
]);
$style = $obj->exposeDefaultSVGStyle();
$style['mask'] = 'url(#m1)';
$out = $obj->exposeParseSVGStyleMask(597, $style);
$this->assertStringContainsString(' gs', $out);
}
/**
* S-4: parseSVGStyleMask closing_tag child with content appends to text.
* @throws \Throwable
*/
public function testSvgMaskStyleClosingTagWithContentAppendsText(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->initSvgObjForHandlers(598);
$obj->patchSvgObj(598, [
'defs' => [
'm2' => [
'name' => 'mask',
'attr' => ['id' => 'm2'],
'child' => [
'open' => [
'name' => 'rect',
'attr' => ['x' => '0', 'y' => '0', 'width' => '10', 'height' => '10'],
],
'close' => [
'name' => 'rect',
'attr' => ['closing_tag' => true, 'content' => 'hello'],
],
],
],
],
]);
$style = $obj->exposeDefaultSVGStyle();
$style['mask'] = 'url(#m2)';
$out = $obj->exposeParseSVGStyleMask(598, $style);
$this->assertStringContainsString(' gs', $out);
}
/**
* A nested <svg> with a degenerate (zero-extent) viewBox must not crash
* with a DivisionByZeroError; the viewBox is ignored as if absent.
*
* @throws \Throwable
*/
public function testNestedSvgZeroAreaViewBoxDoesNotThrow(): void
{
$obj = $this->getTestObject();
$page = $this->initFontAndPage($obj);
$svg =
'@<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40">'
. '<svg x="0" y="0" width="20" height="20" viewBox="0 0 0 100">'
. '<rect x="0" y="0" width="10" height="10" fill="#000000"/>'
. '</svg>'
. '</svg>';
$soid = $obj->addSVG($svg, 5, 6, 12, 12, $page['height']);
$this->assertNotSame('', $obj->getSetSVG($soid));
}
/**
* The nested <svg> viewBox parser must handle decimal values (not split
* "10.0" into two tokens) and preserve negative origins (not strip the
* sign). Differential check: a decimal viewBox renders identically to its
* integer equivalent, and a negative origin renders differently from the
* positive one.
*
* @throws \Throwable
*/
public function testNestedSvgViewBoxParsesDecimalsAndNegatives(): void
{
// Decimal extent must be parsed like the integer equivalent (was
// previously dropped because "10.0" split into two regex tokens).
$this->assertSame($this->renderNestedSvgViewBox('0 0 10 10'), $this->renderNestedSvgViewBox('0 0 10.0 10.0'));
// Negative origin must keep its sign (a digits-only regex stripped it,
// making these two render identically); they must now differ.
$this->assertNotSame($this->renderNestedSvgViewBox('5 5 10 10'), $this->renderNestedSvgViewBox('-5 -5 10 10'));
}
/**
* Render a nested <svg> carrying the given viewBox and return its output.
*
* @throws \Throwable
*/
private function renderNestedSvgViewBox(string $viewBox): string
{
$obj = $this->getTestObject();
$page = $this->initFontAndPage($obj);
$svg =
'@<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40">'
. '<svg x="0" y="0" width="20" height="20" viewBox="'
. $viewBox
. '">'
. '<rect x="0" y="0" width="10" height="10" fill="#000000"/>'
. '</svg>'
. '</svg>';
$soid = $obj->addSVG($svg, 5, 6, 12, 12, $page['height']);
return $obj->getSetSVG($soid);
}
/**
* A path number may omit the digit before the decimal point ('.5', '-.5')
* and may use scientific notation. The compact forms must produce the same
* geometry as the fully spelled out equivalent.
*
* @throws \Throwable
*/
public function testSvgPathParsesCompactNumberForms(): void
{
// Leading-dot numbers, with and without a sign.
$this->assertSame(
$this->renderSvgPath('M0.5 0.5C0.5 1.5 -0.5 1.5 -0.5 0.5Z'),
$this->renderSvgPath('M.5 .5C.5 1.5 -.5 1.5 -.5 .5Z'),
);
// Numbers packed without any separator between them.
$this->assertSame($this->renderSvgPath('M1.5 0.5L2.5 0.5'), $this->renderSvgPath('M1.5.5L2.5.5'));
// Scientific notation.
$this->assertSame($this->renderSvgPath('M0 0L10 20'), $this->renderSvgPath('M0 0L1e1 2E1'));
// A sign must not be dropped: mirrored coordinates must differ.
$this->assertNotSame($this->renderSvgPath('M0 0L.5 .5'), $this->renderSvgPath('M0 0L-.5 -.5'));
}
/**
* The two flags of an 'A' (elliptical arc) command are single digits that
* may be packed with the values around them, as path optimizers do.
*
* @throws \Throwable
*/
public function testSvgPathParsesPackedArcFlags(): void
{
$spaced = $this->renderSvgPath('M10 20a10 10 0 0 1 20 0');
$this->assertStringContainsString(' c', $spaced);
// Both flags packed with the following coordinate.
$this->assertSame($spaced, $this->renderSvgPath('M10 20a10 10 0 0120 0'));
// Packed flags followed by a negative coordinate, and two arcs in a row.
$this->assertSame(
$this->renderSvgPath('M10 20a10 10 0 0 1 20 0a10 10 0 0 1 -20 0'),
$this->renderSvgPath('M10 20a10 10 0 0120 0a10 10 0 01-20 0'),
);
// The flags must still be read as two separate values.
$this->assertNotSame($spaced, $this->renderSvgPath('M10 20a10 10 0 1120 0'));
}
/**
* The smooth curve commands 'S' and 'T' mirror the control point of the
* preceding curve about the current point, and fall back to the current
* point when the preceding command is not a curve of the same degree.
*
* @throws \Throwable
*/
public function testSvgPathSmoothCurvesMirrorThePreviousControlPoint(): void
{
// 'S' after 'C': the mirror of (40,60) about (50,100) is (60,140).
$this->assertSame(
$this->renderSvgPath('M10 100C20 60 40 60 50 100C60 140 80 140 90 100'),
$this->renderSvgPath('M10 100C20 60 40 60 50 100S80 140 90 100'),
);
// 'T' after 'Q': the mirror of (30,120) about (50,160) is (70,200).
$this->assertSame(
$this->renderSvgPath('M10 160Q30 120 50 160Q70 200 90 160'),
$this->renderSvgPath('M10 160Q30 120 50 160T90 160'),
);
// Chained smooth commands mirror the segment they follow.
$this->assertSame(
$this->renderSvgPath('M10 100C20 60 40 60 50 100C60 140 80 140 90 100C100 60 120 60 130 100'),
$this->renderSvgPath('M10 100C20 60 40 60 50 100S80 140 90 100S120 60 130 100'),
);
// Without a preceding curve the control point is the current point.
$this->assertSame($this->renderSvgPath('M10 40C10 40 30 0 50 40'), $this->renderSvgPath('M10 40S30 0 50 40'));
$this->assertSame($this->renderSvgPath('M110 40Q110 40 150 40'), $this->renderSvgPath('M110 40T150 40'));
// A lineto between two curves resets the mirroring.
$this->assertSame(
$this->renderSvgPath('M10 100C20 60 40 60 50 100L60 100C60 100 80 140 90 100'),
$this->renderSvgPath('M10 100C20 60 40 60 50 100L60 100S80 140 90 100'),
);
}
/**
* Render a single <path> with the given 'd' attribute and return its output.
*
* @throws \Throwable
*/
private function renderSvgPath(string $pathd): string
{
$obj = $this->getTestObject();
$page = $this->initFontAndPage($obj);
$svg =
'@<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="-10 -10 40 40">'
. '<path fill="#0066cc" d="'
. $pathd
. '"/>'
. '</svg>';
$soid = $obj->addSVG($svg, 5, 6, 12, 12, $page['height']);
return $obj->getSetSVG($soid);
}
/**
* An SVG made only of geometry must not require a font to be inserted first.
*
* @throws \Throwable
*/
public function testAddSVGWithoutTextDoesNotRequireAFont(): void
{
$obj = $this->getTestObject();
$page = $obj->addPage();
if (!isset($page['height']) || !\is_float($page['height'])) {
$this->fail('Unexpected addPage() return shape.');
}
$svg =
'@<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40">'
. '<path fill="#0066cc" d="M 4 4 H 36 V 36 H 4 Z"/>'
. '</svg>';
$soid = $obj->addSVG($svg, 5, 6, 12, 12, $page['height']);
$this->assertNotSame('', $obj->getSetSVG($soid));
}
/**
* The text-anchor offset is a text width, so it must be converted from
* points to user units before being applied to the position.
*
* @throws \Throwable
*/
public function testSvgTextAnchorShiftMatchesTextWidth(): void
{
// Advance of the '0' glyph of the 10pt Helvetica test font, in points.
$zeroWidth = 5.56;
$start = $this->renderSvgTextAnchorPosX('start', '0');
$end = $this->renderSvgTextAnchorPosX('end', '0');
$middle = $this->renderSvgTextAnchorPosX('middle', '0');
$endTwo = $this->renderSvgTextAnchorPosX('end', '00');
$this->assertEqualsWithDelta($zeroWidth, $start - $end, 0.01);
$this->assertEqualsWithDelta($zeroWidth / 2, $start - $middle, 0.01);
$this->assertEqualsWithDelta(2 * $zeroWidth, $start - $endTwo, 0.01);
}
/**
* Render a <text> with the given anchor and return the X coordinate of the
* emitted Td operator.
*
* @throws \Throwable
*/
private function renderSvgTextAnchorPosX(string $anchor, string $txt): float
{
$obj = $this->getTestObject();
$page = $this->initFontAndPage($obj);
$svg =
'@<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">'
. '<text x="50" y="50" text-anchor="'
. $anchor
. '">'
. $txt
. '</text>'
. '</svg>';
$soid = $obj->addSVG($svg, 0, 0, 100, 100, $page['height']);
$match = [];
if (\preg_match('/([\-0-9\.]+) [\-0-9\.]+ Td/', $obj->getSetSVG($soid), $match) !== 1 || !isset($match[1])) {
$this->fail('No Td operator found in the SVG text output.');
}
return \floatval($match[1]);
}
/**
* Character data outside a text-content element is not rendered: the
* white space between the tags of a pretty-printed SVG must not produce
* any text operator.
*
* @throws \Throwable
*/
public function testSvgIgnoresCharacterDataOutsideTextElements(): void
{
$obj = $this->getTestObject();
$page = $this->initFontAndPage($obj);
$svg =
"@<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100\" height=\"100\">\n"
. " <rect x=\"0\" y=\"0\" width=\"10\" height=\"10\" fill=\"#000000\"/>\n"
. " <text x=\"5\" y=\"30\">one</text>\n"
. " <text x=\"5\" y=\"60\">two</text>\n"
. '</svg>';
$soid = $obj->addSVG($svg, 0, 0, 100, 100, $page['height']);
$out = $obj->getSetSVG($soid);
// Only the two text elements are rendered, not the indentation around them.
$this->assertSame(2, \substr_count($out, ' Tj'));
$this->assertStringContainsString('(one) Tj', $out);
$this->assertStringContainsString('(two) Tj', $out);
}
/**
* The XML white space of the text content is collapsed unless
* xml:space='preserve' is set.
*
* @throws \Throwable
*/
public function testSvgTextContentWhiteSpaceHandling(): void
{
$this->assertStringContainsString('(Hello world) Tj', $this->renderSvgTextContent(
"\n Hello\n world\n ",
'',
));
$this->assertStringContainsString('( Hello world ) Tj', $this->renderSvgTextContent(
' Hello world ',
' xml:space="preserve"',
));
}
/**
* Render a <text> with the given raw content and extra attributes.
*
* @throws \Throwable
*/
private function renderSvgTextContent(string $txt, string $attr): string
{
$obj = $this->getTestObject();
$page = $this->initFontAndPage($obj);
$svg =
'@<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">'
. '<text x="5" y="50"'
. $attr
. '>'
. $txt
. '</text>'
. '</svg>';
$soid = $obj->addSVG($svg, 0, 0, 100, 100, $page['height']);
return $obj->getSetSVG($soid);
}
/**
* Consecutive text chunks of the same text element must not be stacked on
* the same position: each one starts where the previous one ends.
*
* @throws \Throwable
*/
public function testSvgTspanChunksAdvanceTheTextCursor(): void
{
$obj = $this->getTestObject();
$page = $this->initFontAndPage($obj);
$svg =
'@<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100">'
. '<text x="5" y="50">Hello <tspan fill="#ff0000">world</tspan></text>'
. '</svg>';
$soid = $obj->addSVG($svg, 0, 0, 200, 100, $page['height']);
$match = [];
$count = \preg_match_all('/([\-0-9\.]+) [\-0-9\.]+ Td/', $obj->getSetSVG($soid), $match);
$this->assertSame(2, $count);
$posx = $match[1] ?? [];
$this->assertGreaterThan(\floatval($posx[0] ?? '0'), \floatval($posx[1] ?? '0'));
}
/**
* The inheritable presentation attributes of the enclosing element apply to
* its children: the style stack must be read at its top, not one level up.
*
* @throws \Throwable
*/
public function testSvgChildrenInheritTheEnclosingElementStyle(): void
{
$obj = $this->getTestObject();
$page = $this->initFontAndPage($obj);
$svg =
'@<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">'
. '<g fill="#ff0000">'
. '<rect x="0" y="0" width="10" height="10"/>'
. '</g>'
. '<text x="5" y="50" font-size="4.5">Hi <tspan fill="#0000ff">there</tspan></text>'
. '</svg>';
$soid = $obj->addSVG($svg, 0, 0, 100, 100, $page['height']);
$out = $obj->getSetSVG($soid);
// The rect takes the fill of the group.
$this->assertSame(2, \substr_count($out, "1.000000 0.000000 0.000000 rg\n"));
// The tspan keeps the font size of the text element.
$this->assertSame(2, \substr_count($out, '/F1 4.500000 Tf'));
}
/**
* Markers are placed on the path vertices: their content must sit inside
* the marker transform, and that transform is expressed in SVG user units.
*
* @throws \Throwable
*/
public function testSvgMarkersArePlacedOnThePathVertices(): void
{
$obj = $this->getTestObject();
$page = $this->initFontAndPage($obj);
$svg =
'@<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">'
. '<defs>'
. '<marker id="sq" viewBox="0 0 4 4" refX="2" refY="2"'
. ' markerWidth="4" markerHeight="4" orient="auto">'
. '<rect x="0" y="0" width="4" height="4" fill="#ff0000"/>'
. '</marker>'
. '</defs>'
. '<path fill="none" stroke="#000000" stroke-width="2"'
. ' marker-start="url(#sq)" marker-end="url(#sq)" d="M10 50L90 50"/>'
. '</svg>';
$soid = $obj->addSVG($svg, 0, 0, 100, 100, $page['height']);
$out = $obj->getSetSVG($soid);
$match = [];
$count = \preg_match_all(
'/([\-0-9\.]+) [\-0-9\.]+ [\-0-9\.]+ [\-0-9\.]+ ([\-0-9\.]+) ([\-0-9\.]+) cm/',
$out,
$match,
);
$this->assertGreaterThanOrEqual(2, $count);
// markerUnits defaults to strokeWidth, so the 4 unit wide marker box is
// scaled by the stroke width of 2 and keeps the 1:1 viewBox ratio.
$scales = \array_map('floatval', $match[1] ?? []);
$this->assertContains(2.0, $scales);
// Both markers are centred on the path ends, 80 units apart.
$marks = [];
foreach ($scales as $idx => $scale) {
if ($scale !== 2.0) {
continue;
}
$marks[] = \floatval($match[2][$idx] ?? '0');
}
$this->assertCount(2, $marks);
$this->assertEqualsWithDelta(60.0, \abs(($marks[1] ?? 0.0) - ($marks[0] ?? 0.0)), 0.01);
// The rect of the last marker is emitted after its transform.
$lastMarker = \strrpos($out, '2.000000 0.000000 0.000000 2.000000 ');
$this->assertIsInt($lastMarker);
$this->assertGreaterThan($lastMarker, (int) \strrpos($out, ' re'));
}
}