Files
putnamrec/vendor/tecnickcom/tc-lib-pdf/test/SVGTest.php
T

7716 lines
274 KiB
PHP
Raw Normal View History

2026-08-20 11:41:17 +00:00
<?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'));
}
}