7716 lines
274 KiB
PHP
7716 lines
274 KiB
PHP
<?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'));
|
||
}
|
||
}
|