1332 lines
48 KiB
PHP
1332 lines
48 KiB
PHP
<?php
|
|
|
|
/**
|
|
* CSSTest.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;
|
|
|
|
use PHPUnit\Framework\Attributes\DataProvider;
|
|
|
|
/**
|
|
* @phpstan-import-type TCellBound from \Com\Tecnick\Pdf\Base
|
|
* @phpstan-import-type TCSSBorderSpacing from \Com\Tecnick\Pdf\CSS
|
|
*/
|
|
class CSSTest extends TestUtil
|
|
{
|
|
/** @throws \Throwable */
|
|
protected function getTestObject(): \Com\Tecnick\Pdf\Tcpdf
|
|
{
|
|
return new \Com\Tecnick\Pdf\Tcpdf();
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
protected function getInternalTestObject(): TestableCSS
|
|
{
|
|
return new TestableCSS();
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
private function initPageContext(TestableCSS $obj): void
|
|
{
|
|
$this->initFont($obj);
|
|
$obj->addPage();
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
#[DataProvider('cssDefaultSetterProvider')]
|
|
public function testSetDefaultCSSSettersStorePointValues(string $target): void
|
|
{
|
|
$obj = $this->getTestObject();
|
|
switch ($target) {
|
|
case 'margin':
|
|
$obj->setDefaultCSSMargin(1.0, 2.0, 3.0, 4.0);
|
|
/** @var TCellBound $margin */
|
|
$margin = $this->getObjectProperty($obj, 'defCSSCellMargin');
|
|
$this->bcAssertEqualsWithDelta($obj->toPoints(1.0), $margin['T']);
|
|
$this->bcAssertEqualsWithDelta($obj->toPoints(2.0), $margin['R']);
|
|
$this->bcAssertEqualsWithDelta($obj->toPoints(3.0), $margin['B']);
|
|
$this->bcAssertEqualsWithDelta($obj->toPoints(4.0), $margin['L']);
|
|
break;
|
|
case 'padding':
|
|
$obj->setDefaultCSSPadding(0.5, 1.5, 2.5, 3.5);
|
|
/** @var TCellBound $padding */
|
|
$padding = $this->getObjectProperty($obj, 'defCSSCellPadding');
|
|
$this->bcAssertEqualsWithDelta($obj->toPoints(0.5), $padding['T']);
|
|
$this->bcAssertEqualsWithDelta($obj->toPoints(1.5), $padding['R']);
|
|
$this->bcAssertEqualsWithDelta($obj->toPoints(2.5), $padding['B']);
|
|
$this->bcAssertEqualsWithDelta($obj->toPoints(3.5), $padding['L']);
|
|
break;
|
|
case 'spacing':
|
|
$obj->setDefaultCSSBorderSpacing(1.25, 2.75);
|
|
/** @var TCSSBorderSpacing $spacing */
|
|
$spacing = $this->getObjectProperty($obj, 'defCSSBorderSpacing');
|
|
$this->bcAssertEqualsWithDelta($obj->toPoints(1.25), $spacing['V']);
|
|
$this->bcAssertEqualsWithDelta($obj->toPoints(2.75), $spacing['H']);
|
|
break;
|
|
default:
|
|
$this->fail('Unknown fixture target: ' . $target);
|
|
}
|
|
}
|
|
|
|
/** @return array<string, array{0: string}> */
|
|
public static function cssDefaultSetterProvider(): array
|
|
{
|
|
return [
|
|
'margin' => ['margin'],
|
|
'padding' => ['padding'],
|
|
'spacing' => ['spacing'],
|
|
];
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGetCSSBorderWidthPointsHandlesNamedValues(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
|
|
$this->assertSame(0.0, $obj->exposeGetCSSBorderWidthPoints(''));
|
|
$this->assertSame(2.0, $obj->exposeGetCSSBorderWidthPoints('thin'));
|
|
$this->assertSame(4.0, $obj->exposeGetCSSBorderWidthPoints('medium'));
|
|
$this->assertSame(6.0, $obj->exposeGetCSSBorderWidthPoints('thick'));
|
|
$this->assertGreaterThan(0.0, $obj->exposeGetCSSBorderWidthPoints('8px'));
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGetCSSBorderWidthConvertsToUserUnits(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
|
|
$out = $obj->exposeGetCSSBorderWidth('thin');
|
|
|
|
$this->assertGreaterThan(0.0, $out);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
#[DataProvider('cssBorderDashStyleProvider')]
|
|
public function testGetCSSBorderDashStyleMapsKnownStyles(string $style, int $expected): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
|
|
$this->assertSame($expected, $obj->exposeGetCSSBorderDashStyle($style));
|
|
}
|
|
|
|
/** @return array<string, array{0: string, 1: int}> */
|
|
public static function cssBorderDashStyleProvider(): array
|
|
{
|
|
return [
|
|
'none' => ['none', -1],
|
|
'hidden' => ['hidden', -1],
|
|
'dotted' => ['dotted', 1],
|
|
'dashed' => ['dashed', 3],
|
|
'double' => ['double', 0],
|
|
'groove' => ['groove', 0],
|
|
'ridge' => ['ridge', 0],
|
|
'inset' => ['inset', 0],
|
|
'outset' => ['outset', 0],
|
|
'solid' => ['solid', 0],
|
|
'custom' => ['custom', 0],
|
|
];
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGetCSSDefaultBorderStyleProvidesDefaults(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
|
|
$out = $obj->exposeGetCSSDefaultBorderStyle();
|
|
|
|
$this->assertSame(0, $out['lineWidth']);
|
|
$this->assertSame('black', $out['lineColor']);
|
|
$this->assertSame([], $out['dashArray']);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGetCSSBorderStyleParsesWidthStyleColor(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
|
|
$out = $obj->exposeGetCSSBorderStyle('2px dashed red');
|
|
|
|
$this->assertGreaterThan(0.0, $out['lineWidth']);
|
|
$this->assertSame(3, $out['dashPhase']);
|
|
$this->assertIsString($out['lineColor']);
|
|
$this->assertStringContainsString('rgb(', $out['lineColor']);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGetCSSBorderStyleCoversFallbackBranches(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
|
|
$two = $obj->exposeGetCSSBorderStyle('dotted blue !important');
|
|
$this->assertSame(1, $two['dashPhase']);
|
|
$this->assertGreaterThan(0.0, $two['lineWidth']);
|
|
$this->assertIsString($two['lineColor']);
|
|
$twoLineColor = $two['lineColor'];
|
|
$this->assertStringContainsString('rgb(', $twoLineColor);
|
|
|
|
$one = $obj->exposeGetCSSBorderStyle('solid');
|
|
$this->assertSame(0, $one['dashPhase']);
|
|
$this->assertGreaterThan(0.0, $one['lineWidth']);
|
|
$this->assertIsString($one['lineColor']);
|
|
$oneLineColor = $one['lineColor'];
|
|
$this->assertStringContainsString('rgb(', $oneLineColor);
|
|
|
|
$none = $obj->exposeGetCSSBorderStyle('none');
|
|
$this->assertSame(0, $none['lineWidth']);
|
|
$this->assertSame('black', $none['lineColor']);
|
|
|
|
$hidden = $obj->exposeGetCSSBorderStyle('hidden red');
|
|
$this->assertSame(0, $hidden['lineWidth']);
|
|
$this->assertSame([], $hidden['dashArray']);
|
|
$this->assertSame('black', $hidden['lineColor']);
|
|
|
|
$invalidColor = $obj->exposeGetCSSBorderStyle('1px solid transparent');
|
|
$this->assertSame('black', $invalidColor['lineColor']);
|
|
|
|
$importantOnly = $obj->exposeGetCSSBorderStyle('!important');
|
|
$this->assertSame(0, $importantOnly['dashPhase']);
|
|
$this->assertGreaterThan(0.0, $importantOnly['lineWidth']);
|
|
$this->assertIsString($importantOnly['lineColor']);
|
|
$lineColor = $importantOnly['lineColor'];
|
|
$this->assertStringContainsString('rgb(', $lineColor);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGetCSSBorderStyleParsesTokensInAnyOrder(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
|
|
$zero = $obj->exposeGetCSSBorderStyle('0');
|
|
$this->assertSame(0.0, $zero['lineWidth']);
|
|
$this->assertSame('solid', $zero['cssBorderStyle'] ?? '');
|
|
|
|
$zeroFull = $obj->exposeGetCSSBorderStyle('0 solid black');
|
|
$this->assertSame(0.0, $zeroFull['lineWidth']);
|
|
$this->assertSame('solid', $zeroFull['cssBorderStyle'] ?? '');
|
|
|
|
$unordered = $obj->exposeGetCSSBorderStyle('solid red 1px');
|
|
$this->assertGreaterThan(0.0, $unordered['lineWidth']);
|
|
$this->assertSame(0, $unordered['dashPhase']);
|
|
$this->assertIsString($unordered['lineColor']);
|
|
$unorderedLineColor = $unordered['lineColor'];
|
|
$this->assertStringContainsString('rgb(', $unorderedLineColor);
|
|
|
|
$widthOnly = $obj->exposeGetCSSBorderStyle('2px');
|
|
$this->assertGreaterThan(0.0, $widthOnly['lineWidth']);
|
|
$this->assertSame('solid', $widthOnly['cssBorderStyle'] ?? '');
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGetCSSPaddingParsesFourValues(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
|
|
$out = $obj->exposeGetCSSPadding('1 2 3 4', 200.0);
|
|
|
|
$this->assertGreaterThan(0.0, $out['T']);
|
|
$this->assertGreaterThan(0.0, $out['R']);
|
|
$this->assertGreaterThan(0.0, $out['B']);
|
|
$this->assertGreaterThan(0.0, $out['L']);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGetCSSPaddingCoversRemainingForms(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$this->initPageContext($obj);
|
|
|
|
$three = $obj->exposeGetCSSPadding('10% 20% 30%');
|
|
$this->assertGreaterThan(0.0, $three['T']);
|
|
$this->assertGreaterThan(0.0, $three['R']);
|
|
$this->bcAssertEqualsWithDelta($three['R'], $three['L']);
|
|
|
|
$two = $obj->exposeGetCSSPadding('1 2', 100.0);
|
|
$this->bcAssertEqualsWithDelta($two['T'], $two['B']);
|
|
$this->bcAssertEqualsWithDelta($two['R'], $two['L']);
|
|
|
|
$one = $obj->exposeGetCSSPadding('3', 100.0);
|
|
$this->bcAssertEqualsWithDelta($one['T'], $one['R']);
|
|
$this->bcAssertEqualsWithDelta($one['R'], $one['B']);
|
|
$this->bcAssertEqualsWithDelta($one['B'], $one['L']);
|
|
|
|
$defaults = $obj->exposeGetCSSPadding('1 2 3 4 5', 100.0);
|
|
$this->assertSame(['T' => 0.0, 'R' => 0.0, 'B' => 0.0, 'L' => 0.0], $defaults);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGetCSSMarginConvertsAutoToZero(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
|
|
$out = $obj->exposeGetCSSMargin('auto 2 auto 4', 200.0);
|
|
|
|
$this->assertSame(0.0, $out['T']);
|
|
$this->assertGreaterThan(0.0, $out['R']);
|
|
$this->assertSame(0.0, $out['B']);
|
|
$this->assertGreaterThan(0.0, $out['L']);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGetCSSMarginCoversRemainingForms(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$this->initPageContext($obj);
|
|
|
|
$three = $obj->exposeGetCSSMargin('1 auto 3');
|
|
$this->assertGreaterThan(0.0, $three['T']);
|
|
$this->assertSame(0.0, $three['R']);
|
|
$this->assertGreaterThan(0.0, $three['B']);
|
|
$this->assertSame(0.0, $three['L']);
|
|
|
|
$two = $obj->exposeGetCSSMargin('2 4', 100.0);
|
|
$this->bcAssertEqualsWithDelta($two['T'], $two['B']);
|
|
$this->bcAssertEqualsWithDelta($two['R'], $two['L']);
|
|
|
|
$one = $obj->exposeGetCSSMargin('auto', 100.0);
|
|
$this->assertSame(0.0, $one['T']);
|
|
$this->assertSame(0.0, $one['R']);
|
|
$this->assertSame(0.0, $one['B']);
|
|
$this->assertSame(0.0, $one['L']);
|
|
|
|
$defaults = $obj->exposeGetCSSMargin('1 2 3 4 5', 100.0);
|
|
$this->assertSame(['T' => 0.0, 'R' => 0.0, 'B' => 0.0, 'L' => 0.0], $defaults);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGetCSSBorderMarginParsesTwoValues(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
|
|
$out = $obj->exposeGetCSSBorderMargin('1 2', 100.0);
|
|
|
|
$this->assertGreaterThan(0.0, $out['H']);
|
|
$this->assertGreaterThan(0.0, $out['V']);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGetCSSBorderMarginCoversSingleValueAndDefault(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$this->initPageContext($obj);
|
|
|
|
$one = $obj->exposeGetCSSBorderMargin('5');
|
|
$this->assertGreaterThan(0.0, $one['H']);
|
|
$this->bcAssertEqualsWithDelta($one['H'], $one['V']);
|
|
|
|
$defaults = $obj->exposeGetCSSBorderMargin('1 2 3', 100.0);
|
|
$this->assertSame(['H' => 0, 'V' => 0], $defaults);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testIntToRomanConvertsTypicalNumber(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
|
|
$out = $obj->exposeIntToRoman(14);
|
|
|
|
$this->assertSame('XIV', $out);
|
|
$this->assertSame('I', $obj->exposeIntToRoman(1));
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testIntToRomanReturnsDecimalAboveLimit(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
|
|
$out = $obj->exposeIntToRoman(4000000000);
|
|
|
|
$this->assertSame('4000000000', $out);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testUnhtmlentitiesDecodesHtmlEntities(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
|
|
$out = $obj->exposeUnhtmlentities('<a&b>');
|
|
|
|
$this->assertSame('<a&b>', $out);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testTidyCSSKeepsAllAndPrintMediaOnly(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$css =
|
|
'/*c*/ body { color : red ; } @media screen { p{color:blue;} }'
|
|
. ' @media print { h1 { font-weight : bold ; } }';
|
|
|
|
$out = $obj->exposeTidyCSS($css);
|
|
|
|
$this->assertStringContainsString('body{color:red;}', $out);
|
|
$this->assertStringContainsString('h1{font-weight:bold;}', $out);
|
|
$this->assertStringNotContainsString('p{color:blue;}', $out);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testTidyCSSHandlesEmptyInputAndAllMedia(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
|
|
$this->assertSame('', $obj->exposeTidyCSS(''));
|
|
|
|
$out = $obj->exposeTidyCSS('@media all { h2 { color : green ; } }');
|
|
|
|
$this->assertSame('h2{color:green;}', $out);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testExtractCSSpropertiesParsesSelectorsAndValues(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
|
|
$out = $obj->exposeExtractCSSproperties('h1, h2 { color:red; } #id .x { margin:0; }');
|
|
|
|
$this->assertNotEmpty($out);
|
|
$vals = \array_values($out);
|
|
$this->assertContains('color:red;', $vals);
|
|
$this->assertContains('margin:0;', $vals);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testExtractCSSpropertiesHandlesEmptyAndOrphanBlocks(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
|
|
$this->assertSame([], $obj->exposeExtractCSSproperties(''));
|
|
|
|
$out = $obj->exposeExtractCSSproperties('a{color:red;} orphan');
|
|
|
|
$this->assertCount(1, $out);
|
|
$this->assertContains('color:red;', \array_values($out));
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testExtractCSSpropertiesConformanceEscapedSelectorsFixture(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$css = (string) \file_get_contents(__DIR__ . '/fixtures/css/parser/escaped_selectors.css');
|
|
|
|
$out = $obj->exposeExtractCSSproperties($css);
|
|
$bySelector = $this->normalizeSelectorMap($out);
|
|
|
|
$this->assertCount(3, $bySelector);
|
|
$this->assertSame('color:red;', $bySelector['.icon\\:warning[data-kind="print-a"]:first-child'] ?? null);
|
|
$this->assertSame('margin:0;', $bySelector['#hero\\#title > a.link\\+cta[href*="campaign=42"]'] ?? null);
|
|
$this->assertSame(
|
|
'text-decoration:underline;',
|
|
$bySelector['nav ul li:nth-child(2) > a[title="A > B"]'] ?? null,
|
|
);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testExtractCSSpropertiesConformanceComplexValuesFixture(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$css = (string) \file_get_contents(__DIR__ . '/fixtures/css/parser/complex_values.css');
|
|
|
|
$out = $obj->exposeExtractCSSproperties($css);
|
|
$bySelector = $this->normalizeSelectorMap($out);
|
|
|
|
$this->assertCount(3, $bySelector);
|
|
$this->assertSame(
|
|
'content:"mailto:user@example.com;subject=test";',
|
|
$bySelector['a[href^="mailto:"]::after'] ?? null,
|
|
);
|
|
|
|
$this->assertSame(
|
|
'background-image:url("https://example.com/a;b.png?x=1&y=2");'
|
|
. 'font-family:"Open Sans", "Noto Sans", sans-serif;',
|
|
$bySelector['div[data-url*="example.com?a=1&b=2"]'] ?? null,
|
|
);
|
|
|
|
$this->assertSame(
|
|
'background:linear-gradient(90deg, rgba(0,0,0,.1), rgba(255,255,255,.8));',
|
|
$bySelector['p.note'] ?? null,
|
|
);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testExtractCSSpropertiesPreservesPlannedProperties(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$css = '.probe{font-size-adjust:0.59;font-variant:small-caps;orphans:5;page:chapter;quotes:"[" "]";widows:4;}';
|
|
|
|
$out = $this->normalizeSelectorMap($obj->exposeExtractCSSproperties($css));
|
|
|
|
$this->assertSame(
|
|
'font-size-adjust:0.59;font-variant:small-caps;orphans:5;page:chapter;quotes:"[" "]";widows:4;',
|
|
$out['.probe'] ?? null,
|
|
);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testImplodeCSSDataPrefersLastDuplicateCommand(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$css = [
|
|
['c' => 'color:red;margin:1px'],
|
|
['c' => 'color:blue'],
|
|
];
|
|
|
|
$out = $obj->exposeImplodeCSSData($css);
|
|
|
|
$this->assertStringContainsString('color:blue', $out);
|
|
$this->assertStringNotContainsString('color:red', $out);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testImplodeCSSDataSkipsEmptyEntriesAndInvalidCommands(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$css = [
|
|
['c' => ''],
|
|
['c' => 'broken;color:red;;margin:1px'],
|
|
['c' => 'color:blue'],
|
|
];
|
|
|
|
$out = $obj->exposeImplodeCSSData($css);
|
|
|
|
$this->assertStringContainsString('margin:1px', $out);
|
|
$this->assertStringContainsString('color:blue', $out);
|
|
$this->assertStringNotContainsString('color:red', $out);
|
|
$this->assertStringNotContainsString(';;', $out);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testImplodeCSSDataImportantShorthandBeatsLaterNonImportantLonghand(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$css = [
|
|
['c' => 'margin:1px !important;'],
|
|
['c' => 'margin-top:5px;'],
|
|
];
|
|
|
|
$out = \str_replace(' ', '', $obj->exposeImplodeCSSData($css));
|
|
|
|
$this->assertStringContainsString('margin:1px!important;', $out);
|
|
$this->assertStringNotContainsString('margin-top:5px', $out);
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $styles
|
|
* @throws \Throwable
|
|
*/
|
|
#[DataProvider('cssCascadeImportantSourceOrderProvider')]
|
|
public function testImplodeCSSDataCascadeImportantAndSourceOrder(
|
|
string $name,
|
|
array $styles,
|
|
string $expected,
|
|
): void {
|
|
$obj = $this->getInternalTestObject();
|
|
|
|
/** @var list<array{c: string}> $css */
|
|
$css = [];
|
|
foreach ($styles as $style) {
|
|
$css[] = ['c' => $style];
|
|
}
|
|
|
|
$out = $obj->exposeImplodeCSSData($css);
|
|
|
|
$this->assertSame($expected, $out, $name);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testImplodeCSSDataPreservesPlannedPropertiesAndImportance(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$css = [
|
|
['c' => 'font-size-adjust:0.55;font-variant:normal;orphans:2;page:auto;quotes:none;widows:2;'],
|
|
['c' => 'font-variant:small-caps !important;orphans:5;page:chapter;quotes:"[" "]";widows:4;'],
|
|
['c' => 'font-variant:normal;'],
|
|
];
|
|
|
|
$out = \str_replace(' ', '', $obj->exposeImplodeCSSData($css));
|
|
|
|
$this->assertStringContainsString('font-size-adjust:0.55;', $out);
|
|
$this->assertStringContainsString('font-variant:small-caps!important;', $out);
|
|
$this->assertStringNotContainsString('font-variant:normal;', $out);
|
|
$this->assertStringContainsString('orphans:5;', $out);
|
|
$this->assertStringContainsString('page:chapter;', $out);
|
|
$this->assertStringContainsString('quotes:"[""]";', $out);
|
|
$this->assertStringContainsString('widows:4;', $out);
|
|
}
|
|
|
|
/** @return array<string, array{0: string, 1: list<string>, 2: string}> */
|
|
public static function cssCascadeImportantSourceOrderProvider(): array
|
|
{
|
|
$json = (string) \file_get_contents(__DIR__ . '/fixtures/css/cascade/important_source_order.json');
|
|
/** @var array<int, array{name: string, styles: list<string>, expected: string}>|null $rows */
|
|
$rows = \json_decode($json, true);
|
|
if (!\is_array($rows)) {
|
|
return [];
|
|
}
|
|
|
|
$out = [];
|
|
foreach ($rows as $row) {
|
|
$out[$row['name']] = [$row['name'], $row['styles'], $row['expected']];
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGetCSSArrayFromHTMLExtractsAndRemovesCssarrayTag(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$html = '<cssarray>{"h1":"color:red;"}</cssarray><p>ok</p>';
|
|
|
|
$out = $obj->exposeGetCSSArrayFromHTML($html);
|
|
|
|
$this->assertSame('color:red;', $out['h1'] ?? null);
|
|
$this->assertStringNotContainsString('<cssarray>', $html);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGetCSSArrayFromHTMLExtractsExternalAndInlineCss(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$cssfile = (string) \realpath(__DIR__ . '/fixtures/css/external.css');
|
|
$html =
|
|
'<link rel="stylesheet" type="text/css" media="screen" href="skip.css">'
|
|
. '<link rel="stylesheet" type="text/css" media="print" href="'
|
|
. $cssfile
|
|
. '">'
|
|
. '<style media="screen">ignored{color:red;}</style>'
|
|
. '<style media="all">h4{font-weight:bold;}</style>'
|
|
. '<style media="print">h5{margin:0;}</style>';
|
|
|
|
$out = $obj->exposeGetCSSArrayFromHTML($html);
|
|
$vals = \array_values($out);
|
|
|
|
$this->assertContains('color:green;', $vals);
|
|
$this->assertContains('font-weight:bold;', $vals);
|
|
$this->assertContains('margin:0;', $vals);
|
|
$this->assertNotContains('color:red;', $vals);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGetCSSArrayFromHTMLStyleWithoutMediaAttribute(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$html = '<style>h1{color:blue;}</style><style media="all">h2{font-size:14pt;}</style>';
|
|
|
|
$out = $obj->exposeGetCSSArrayFromHTML($html);
|
|
$vals = \array_values($out);
|
|
|
|
$this->assertContains('color:blue;', $vals);
|
|
$this->assertContains('font-size:14pt;', $vals);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGetCSSArrayFromHTMLPreservesPlannedPropertiesFromStyleTag(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$html = '<style>.probe{font-size-adjust:0.59;font-variant:small-caps;orphans:5;page:chapter;quotes:"[" "]";widows:4;}</style>';
|
|
|
|
$out = $this->normalizeSelectorMap($obj->exposeGetCSSArrayFromHTML($html));
|
|
|
|
$this->assertSame(
|
|
'font-size-adjust:0.59;font-variant:small-caps;orphans:5;page:chapter;quotes:"[" "]";widows:4;',
|
|
$out['.probe'] ?? null,
|
|
);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testSetGlobalCSSReplacesPreviousContent(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$obj->setGlobalCSS('p{color:red;}');
|
|
$obj->setGlobalCSS('h1{color:green;}');
|
|
|
|
$this->assertSame('h1{color:green;}', $this->getObjectProperty($obj, 'globalCSS'));
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testAddGlobalCSSAppendsWithSeparator(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$obj->setGlobalCSS('p{color:red;}');
|
|
$obj->addGlobalCSS('h1{color:green;}');
|
|
|
|
$this->assertSame("p{color:red;}\nh1{color:green;}", $this->getObjectProperty($obj, 'globalCSS'));
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testAddGlobalCSSFromEmptyDoesNotPrependSeparator(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$obj->addGlobalCSS('p{color:red;}');
|
|
|
|
$this->assertSame('p{color:red;}', $this->getObjectProperty($obj, 'globalCSS'));
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testAddGlobalCSSIgnoresEmptyString(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$obj->setGlobalCSS('p{color:red;}');
|
|
$obj->addGlobalCSS('');
|
|
|
|
$this->assertSame('p{color:red;}', $this->getObjectProperty($obj, 'globalCSS'));
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGlobalCSSInjectedAsBaseStylesheet(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$obj->setGlobalCSS('p{color:red;}');
|
|
$html = '<p>ok</p>';
|
|
|
|
$out = $this->normalizeSelectorMap($obj->exposeGetCSSArrayFromHTML($html));
|
|
|
|
$this->assertSame('color:red;', $out['p'] ?? null);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGlobalCSSIsOverriddenByDocumentStyleOnEqualSpecificity(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$obj->setGlobalCSS('p{color:red;}');
|
|
$html = '<style>p{color:blue;}</style>';
|
|
|
|
// The global rule is injected as the lowest-priority author source, so the
|
|
// document rule has the higher source order and wins the specificity tie.
|
|
$out = $this->normalizeSelectorMap($obj->exposeGetCSSArrayFromHTML($html));
|
|
|
|
$this->assertSame('color:blue;', $out['p'] ?? null);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGlobalCSSSupportsMediaAndImportantRules(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$obj->setGlobalCSS('@media print{p{margin:0;}} @media screen{p{margin:9px;}}');
|
|
$html = '<p>ok</p>';
|
|
|
|
$out = $this->normalizeSelectorMap($obj->exposeGetCSSArrayFromHTML($html));
|
|
|
|
// Global CSS goes through the same engine as document CSS: the print block is
|
|
// kept and the screen-only block is discarded.
|
|
$this->assertSame('margin:0;', $out['p'] ?? null);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testResetGlobalCSSClearsContent(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$obj->setGlobalCSS('p{color:red;}');
|
|
$obj->resetGlobalCSS();
|
|
$html = '<p>ok</p>';
|
|
|
|
$out = $this->normalizeSelectorMap($obj->exposeGetCSSArrayFromHTML($html));
|
|
|
|
$this->assertArrayNotHasKey('p', $out);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGlobalCSSNotReinjectedWhenCssArrayPresent(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$obj->setGlobalCSS('p{color:red;}');
|
|
// A <cssarray> tag marks nested table re-processing: the resolved CSS map it
|
|
// carries already includes the global CSS, so it must not be injected again.
|
|
$html = '<cssarray>{"h1":"color:blue;"}</cssarray><p>ok</p>';
|
|
|
|
$out = $this->normalizeSelectorMap($obj->exposeGetCSSArrayFromHTML($html));
|
|
|
|
$this->assertSame('color:blue;', $out['h1'] ?? null);
|
|
$this->assertArrayNotHasKey('p', $out);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGetCSSColorNormalizesValidColor(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
|
|
$out = $obj->exposeGetCSSColor('#ff0000');
|
|
|
|
$this->assertNotSame('', $out);
|
|
$this->assertStringContainsString('rgb(', $out);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGetCSSColorParsesSpotFunction(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
|
|
$out = $obj->exposeGetCSSColor('spot(cyan, 40%)');
|
|
|
|
$this->assertSame('spot(cyan,0.400000)', $out);
|
|
$this->assertStringContainsString('/CS', $obj->color->getPdfColor($out));
|
|
$this->assertStringContainsString('scn', $obj->color->getPdfColor($out));
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testExtractCSSPropertiesRegistersSpotRuleAndStripsAtRule(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$css = '@spot "Brand Orange" { cmyk: 0 62 100 0; } .brand { color: spot("Brand Orange", 0.5); }';
|
|
|
|
$out = $obj->exposeExtractCSSproperties($css);
|
|
$joined = \implode(' ', \array_values($out));
|
|
|
|
$this->assertStringContainsString('spot("Brand Orange", 0.5)', $joined);
|
|
$this->assertStringNotContainsString('@spot', $joined);
|
|
$this->assertStringContainsString('/CS', $obj->color->getPdfColor('spot("Brand Orange",0.500000)'));
|
|
}
|
|
|
|
/**
|
|
* Regression test for spot-color name preservation (issue #209).
|
|
*
|
|
* A spot color registered through an @spot CSS rule with a name containing
|
|
* spaces and uppercase letters must be emitted in the PDF Separation color
|
|
* space with its original name (escaped as a PDF name object), not with the
|
|
* normalized lowercase key.
|
|
*
|
|
* @throws \Throwable
|
|
*/
|
|
public function testAtSpotRuleNameIsPreservedInSeparationObject(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$css = '@spot "Brand Orange" { cmyk: 0 62 100 0; } .brand { color: spot("Brand Orange", 0.5); }';
|
|
|
|
$obj->exposeExtractCSSproperties($css);
|
|
|
|
$pon = 0;
|
|
$spotObjects = $obj->color->getPdfSpotObjects($pon);
|
|
|
|
$this->assertStringContainsString('[/Separation /Brand#20Orange /DeviceCMYK', $spotObjects);
|
|
$this->assertStringNotContainsString('brandorange', $spotObjects);
|
|
// CMYK alternate components from the @spot rule (0 62 100 0).
|
|
$this->assertStringContainsString('0.000000 0.620000 1.000000 0.000000', $spotObjects);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGetCSSBorderStyleParsesSpotColorToken(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$css = '@spot quality-black { cmyk: 0 0 0 100; }';
|
|
$obj->exposeExtractCSSproperties($css);
|
|
|
|
$out = $obj->exposeGetCSSBorderStyle('1px solid spot(quality-black, 75%)');
|
|
|
|
$this->assertSame('spot(quality-black,0.750000)', $out['lineColor']);
|
|
$this->assertStringContainsString('/CS', $obj->color->getPdfColor($out['lineColor']));
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGetCSSColorReturnsEmptyStringForInvalidColor(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
|
|
$this->assertSame('', $obj->exposeGetCSSColor(''));
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testResolveImportRulesStripsImportAndPrependsContent(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$base = (string) \realpath(__DIR__ . '/fixtures/css/import/base.css');
|
|
$css = '@import "' . $base . '";' . "\n" . 'h3{margin:0;}';
|
|
$seen = [];
|
|
|
|
$result = $obj->exposeResolveImportRules($css, 0, $seen);
|
|
|
|
// Imported content appears before the importing sheet's own rules
|
|
$this->assertStringContainsString('h1', $result);
|
|
$this->assertStringContainsString('h3', $result);
|
|
$pos_h1 = (int) \strpos($result, 'h1');
|
|
$pos_h3 = (int) \strpos($result, 'h3');
|
|
$this->assertLessThan($pos_h3, $pos_h1, '@import content should precede importing sheet rules');
|
|
// @import directive itself is removed
|
|
$this->assertStringNotContainsString('@import', $result);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testResolveImportRulesHandlesUrlSyntax(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$base = (string) \realpath(__DIR__ . '/fixtures/css/import/base.css');
|
|
$css = "@import url('" . $base . "');\nh4{color:green;}";
|
|
$seen = [];
|
|
|
|
$result = $obj->exposeResolveImportRules($css, 0, $seen);
|
|
|
|
$this->assertStringContainsString('h1', $result);
|
|
$this->assertStringNotContainsString('@import', $result);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testResolveImportRulesSkipsNonPrintMedia(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$base = (string) \realpath(__DIR__ . '/fixtures/css/import/base.css');
|
|
$css = '@import "' . $base . '" screen;' . "\n" . 'h3{margin:0;}';
|
|
$seen = [];
|
|
|
|
$result = $obj->exposeResolveImportRules($css, 0, $seen);
|
|
|
|
$this->assertStringNotContainsString('h1', $result);
|
|
$this->assertStringNotContainsString('@import', $result);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testResolveImportRulesAllowsPrintMedia(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$base = (string) \realpath(__DIR__ . '/fixtures/css/import/base.css');
|
|
$css = '@import "' . $base . '" print;' . "\n" . 'h3{margin:0;}';
|
|
$seen = [];
|
|
|
|
$result = $obj->exposeResolveImportRules($css, 0, $seen);
|
|
|
|
$this->assertStringContainsString('h1', $result);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testResolveImportRulesDeduplicatesUrls(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$base = (string) \realpath(__DIR__ . '/fixtures/css/import/base.css');
|
|
$css = '@import "' . $base . '";' . "\n" . '@import "' . $base . '";' . "\n" . 'h3{margin:0;}';
|
|
$seen = [];
|
|
|
|
$result = $obj->exposeResolveImportRules($css, 0, $seen);
|
|
|
|
// File should appear exactly once despite two @import rules for the same URL
|
|
$this->assertSame(1, \substr_count($result, 'h1'));
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testResolveImportRulesStopsAtMaxDepth(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$base = (string) \realpath(__DIR__ . '/fixtures/css/import/base.css');
|
|
$css = '@import "' . $base . '";' . "\n" . 'h3{margin:0;}';
|
|
$seen = [];
|
|
|
|
// At max depth the import should not be resolved
|
|
$result = $obj->exposeResolveImportRules($css, 8, $seen);
|
|
|
|
$this->assertStringNotContainsString('h1', $result);
|
|
$this->assertStringContainsString('@import', $result);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testExtractCSSpropertiesResolvesImportBeforeParsing(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$base = (string) \realpath(__DIR__ . '/fixtures/css/import/base.css');
|
|
$css = '@import "' . $base . '";' . "\n" . 'h3{margin:0;}';
|
|
|
|
$result = $this->normalizeSelectorMap($obj->exposeExtractCSSproperties($css));
|
|
|
|
$this->assertArrayHasKey('h1', $result);
|
|
$this->assertArrayHasKey('h2', $result);
|
|
$this->assertArrayHasKey('h3', $result);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGetCSSArrayFromHTMLResolvesImportInStyleTag(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$base = (string) \realpath(__DIR__ . '/fixtures/css/import/base.css');
|
|
$html = '<style>@import "' . $base . '"; h3{margin:0;}</style><p>ok</p>';
|
|
|
|
$result = $this->normalizeSelectorMap($obj->exposeGetCSSArrayFromHTML($html));
|
|
|
|
$this->assertArrayHasKey('h1', $result);
|
|
$this->assertArrayHasKey('h3', $result);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, string> $styles
|
|
*
|
|
* @return array<string, string>
|
|
*/
|
|
private function normalizeSelectorMap(array $styles): array
|
|
{
|
|
$normalized = [];
|
|
foreach ($styles as $selector => $declarations) {
|
|
// Remove specificity prefix (format: digits_digits or just digits followed by space)
|
|
$pure = (string) \preg_replace('/^[\d_]+\s+/', '', $selector);
|
|
$normalized[$pure] = $declarations;
|
|
}
|
|
|
|
return $normalized;
|
|
}
|
|
|
|
// --- isMediaPrintRelevant ---
|
|
|
|
/** @return array<string, array{string, bool}> */
|
|
public static function mediaRelevanceProvider(): array
|
|
{
|
|
return [
|
|
'print' => ['print', true],
|
|
'all' => ['all', true],
|
|
'print and condition' => ['print and (min-width:600px)', true],
|
|
'all and condition' => ['all and (orientation:portrait)', true],
|
|
'feature only' => ['(max-width:800px)', true],
|
|
'screen' => ['screen', false],
|
|
'tv' => ['tv', false],
|
|
'screen, print' => ['screen, print', true],
|
|
'screen, all' => ['screen, all', true],
|
|
'not screen' => ['not screen', true],
|
|
'not print' => ['not print', false],
|
|
'not all' => ['not all', false],
|
|
'not tv' => ['not tv', true],
|
|
'empty string' => ['', false],
|
|
];
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
#[DataProvider('mediaRelevanceProvider')]
|
|
public function testIsMediaPrintRelevant(string $query, bool $expected): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$this->assertSame($expected, $obj->exposeIsMediaPrintRelevant($query));
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testTidyCSSKeepsPrintAndCondition(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$css = '@media print and (min-width:600px) { h1{color:red;} } @media screen{p{color:blue;}}';
|
|
|
|
$out = $obj->exposeTidyCSS($css);
|
|
|
|
$this->assertStringContainsString('h1{color:red;}', $out);
|
|
$this->assertStringNotContainsString('p{color:blue;}', $out);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testTidyCSSKeepsCommaListWithPrint(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$css = '@media screen, print { h2{font-size:14pt;} }';
|
|
|
|
$out = $obj->exposeTidyCSS($css);
|
|
|
|
$this->assertStringContainsString('h2{font-size:14pt;}', $out);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testTidyCSSKeepsFeatureOnlyQuery(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$css = '@media (max-width:800px) { p{margin:0;} }';
|
|
|
|
$out = $obj->exposeTidyCSS($css);
|
|
|
|
$this->assertStringContainsString('p{margin:0;}', $out);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testTidyCSSDropsNotPrint(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$css = '@media not print { h3{color:green;} } body{color:black;}';
|
|
|
|
$out = $obj->exposeTidyCSS($css);
|
|
|
|
$this->assertStringNotContainsString('h3{color:green;}', $out);
|
|
$this->assertStringContainsString('body{color:black;}', $out);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testTidyCSSKeepsNotScreen(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$css = '@media not screen { h4{color:navy;} }';
|
|
|
|
$out = $obj->exposeTidyCSS($css);
|
|
|
|
$this->assertStringContainsString('h4{color:navy;}', $out);
|
|
}
|
|
|
|
// --- normalizeCharset ---
|
|
|
|
/** @throws \Throwable */
|
|
public function testNormalizeCharsetStripsUtf8Declaration(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$css = '@charset "UTF-8"; body { color:red; }';
|
|
|
|
$out = $obj->exposeNormalizeCharset($css);
|
|
|
|
$this->assertStringNotContainsString('@charset', $out);
|
|
$this->assertStringContainsString('body', $out);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testNormalizeCharsetStripsUtf8DeclarationCaseInsensitive(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$css = '@charset "utf-8"; h1 { font-size:12pt; }';
|
|
|
|
$out = $obj->exposeNormalizeCharset($css);
|
|
|
|
$this->assertStringNotContainsString('@charset', $out);
|
|
$this->assertStringContainsString('h1', $out);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testNormalizeCharsetStripsAsciiDeclaration(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$css = '@charset "US-ASCII"; p { margin:0; }';
|
|
|
|
$out = $obj->exposeNormalizeCharset($css);
|
|
|
|
$this->assertStringNotContainsString('@charset', $out);
|
|
$this->assertStringContainsString('p', $out);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testNormalizeCharsetStripsWithLeadingBom(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$css = "\xEF\xBB\xBF" . '@charset "UTF-8"; h2 { color:blue; }';
|
|
|
|
$out = $obj->exposeNormalizeCharset($css);
|
|
|
|
$this->assertStringNotContainsString('@charset', $out);
|
|
$this->assertStringContainsString('h2', $out);
|
|
// BOM should also be stripped
|
|
$this->assertStringNotContainsString("\xEF\xBB\xBF", $out);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testNormalizeCharsetPassesThroughWhenAbsent(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$css = 'body { color:red; }';
|
|
|
|
$out = $obj->exposeNormalizeCharset($css);
|
|
|
|
$this->assertSame($css, $out);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testNormalizeCharsetTranscodesIso88591(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
// "résumé" in ISO-8859-1 encoding
|
|
$encoded = \mb_convert_encoding('p { content:"résumé"; }', 'ISO-8859-1', 'UTF-8');
|
|
$this->assertNotFalse($encoded);
|
|
$iso = '@charset "ISO-8859-1"; ' . $encoded;
|
|
|
|
$out = $obj->exposeNormalizeCharset($iso);
|
|
|
|
$this->assertStringNotContainsString('@charset', $out);
|
|
// After transcoding the content should be valid UTF-8
|
|
$this->assertTrue(\mb_check_encoding($out, 'UTF-8'));
|
|
$this->assertStringContainsString('résumé', $out);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testExtractCSSpropertiesStripsCharset(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$css = '@charset "UTF-8"; h1 { color:red; } h2 { font-size:14pt; }';
|
|
|
|
$result = $this->normalizeSelectorMap($obj->exposeExtractCSSproperties($css));
|
|
|
|
$this->assertArrayHasKey('h1', $result);
|
|
$this->assertArrayHasKey('h2', $result);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testResolveImportRulesStripsCharsetFromImportedFile(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$charsetFile = (string) \realpath(__DIR__ . '/fixtures/css/charset/with-charset.css');
|
|
$css = '@import "' . $charsetFile . '"; h3{margin:0;}';
|
|
$seen = [];
|
|
|
|
$result = $obj->exposeResolveImportRules($css, 0, $seen);
|
|
|
|
$this->assertStringNotContainsString('@charset', $result);
|
|
$this->assertStringContainsString('h1', $result);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testImplodeCSSDataHandlesEmptyPropertyNameAndImportantReplacement(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$css = [
|
|
['c' => ':oops;color:red;'],
|
|
['c' => 'margin:1px;'],
|
|
['c' => 'margin:2px !important;'],
|
|
['c' => 'margin-top:3px;'],
|
|
];
|
|
|
|
$out = \str_replace(' ', '', $obj->exposeImplodeCSSData($css));
|
|
|
|
$this->assertStringContainsString('color:red;', $out);
|
|
$this->assertStringContainsString('margin:2px!important;', $out);
|
|
$this->assertStringNotContainsString('margin-top:3px;', $out);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testSplitCSSWhitespaceTokensPreservesQuotedAndFunctionContent(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
|
|
$tokens = $obj->exposeSplitCSSWhitespaceTokens('url("a b") "hello world" plain');
|
|
|
|
$this->assertSame(['url("a b")', '"hello world"', 'plain'], $tokens);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testSplitCSSDeclarationsPreservesQuotedAndFunctionSemicolons(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$decls = $obj->exposeSplitCSSDeclarations('content:"a;b";background:url("x;y");color:red;');
|
|
|
|
$this->assertSame(['content:"a;b"', 'background:url("x;y")', 'color:red'], $decls);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testResolveImportRulesSkipsUnreadableImports(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$css = '@import "/definitely/not/here.css"; h7{margin:0;}';
|
|
$seen = [];
|
|
|
|
$result = $obj->exposeResolveImportRules($css, 0, $seen);
|
|
|
|
$this->assertStringContainsString('h7{margin:0;}', $result);
|
|
$this->assertStringNotContainsString('@import', $result);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testIsMediaPrintRelevantNegatedFeatureOnlyQueryIsNotRelevant(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
|
|
$this->assertFalse($obj->exposeIsMediaPrintRelevant('not (min-width:600px)'));
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testTidyCssSkipsEmptyMediaBlocks(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
|
|
$out = $obj->exposeTidyCSS('@media print{} body{color:black;}');
|
|
|
|
$this->assertSame('body{color:black;}', $out);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testTidyCssSkipsEmptyMediaEntriesWhenBlocksAreParsed(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
|
|
$out = $obj->exposeTidyCSS('@media print{h1{}}@media print{h2{color:red;}}');
|
|
|
|
$this->assertStringContainsString('h2{color:red;}', $out);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testTidyCssHandlesPreTokenizedEmptyMediaEntry(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
|
|
$out = $obj->exposeTidyCSS('@media print§§@media print§h9{color:blue;}§');
|
|
|
|
$this->assertStringContainsString('h9', $out);
|
|
$this->assertStringContainsString('color:blue', $out);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testDecodeCssMapGuardsAgainstInvalidShapes(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
|
|
$this->assertSame([], $obj->exposeDecodeCSSMap('null'));
|
|
$this->assertSame([], $obj->exposeDecodeCSSMap('["x"]'));
|
|
$this->assertSame(['h2' => 'color:red;'], $obj->exposeDecodeCSSMap('{"h1":1,"h2":"color:red;"}'));
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testSpotRuleParsingCoversLabAndComponentDeclarations(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$css =
|
|
'@spot "" { cmyk: 0 0 0 0; }'
|
|
. '@spot sep { ;broken;:x;cyan:10;magenta:20;yellow:30;key:40; }'
|
|
. '@spot labone { lab: 50 10 -20; }'
|
|
. '@spot badlab { lab: a 2 3; }'
|
|
. '@spot badcmyk { cmyk: 1 2 3; }'
|
|
. '@spot incomplete { cyan:10; magenta:20; }'
|
|
. '@spot badcomponents { cyan:10;magenta:20;yellow:30;key:oops; }'
|
|
. '.x{color:spot(sep,50%);}';
|
|
|
|
$stripped = $obj->exposeStripAndRegisterCSSSpotRules($css);
|
|
|
|
$this->assertStringNotContainsString('@spot', $stripped);
|
|
$this->assertStringContainsString('/CS', $obj->color->getPdfColor('spot(sep,0.500000)'));
|
|
$this->assertStringContainsString('/CS', $obj->color->getPdfColor('spot(labone,0.500000)'));
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGetCssArrayFromHtmlLoadsStylesheetWithoutMediaAttribute(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$cssfile = (string) \realpath(__DIR__ . '/fixtures/css/external.css');
|
|
$html = '<link rel="stylesheet" type="text/css" href="' . $cssfile . '">';
|
|
|
|
$out = $obj->exposeGetCSSArrayFromHTML($html);
|
|
|
|
$this->assertContains('color:green;', \array_values($out));
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testSpotAndLabLowLevelParsersCoverInvalidAndClampPaths(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
|
|
$this->assertNull($obj->exposeParseSpotCssColorFunction('spot('));
|
|
$this->assertNull($obj->exposeParseSpotCssColorFunction('spot("",20%)'));
|
|
$this->assertNull($obj->exposeParseSpotCssColorFunction('spot(cyan,abc)'));
|
|
$this->assertSame('spot(cyan,1.000000)', $obj->exposeParseSpotCssColorFunction('spot(cyan)'));
|
|
|
|
$this->assertSame('', $obj->exposeParseSpotColorNameToken(' '));
|
|
$this->assertSame('"Brand Name"', $obj->exposeFormatSpotColorNameToken('Brand Name'));
|
|
|
|
$this->assertNull($obj->exposeParseSpotComponentList('1 2 3'));
|
|
$this->assertNull($obj->exposeParseSpotComponentList('1 2 x 4'));
|
|
$this->assertSame([0.1, 0.2, 0.3, 0.4], $obj->exposeParseSpotComponentList('10 20 30 40'));
|
|
|
|
$this->assertNull($obj->exposeParseLabComponentList('10 20'));
|
|
$this->assertNull($obj->exposeParseLabComponentList('x 20 30'));
|
|
$this->assertSame([50.0, 10.0, -20.0], $obj->exposeParseLabComponentList('50 10 -20'));
|
|
|
|
$this->assertNull($obj->exposeParseSpotTintValue(''));
|
|
$this->assertNull($obj->exposeParseSpotTintValue('abc'));
|
|
$this->assertSame(0.75, $obj->exposeParseSpotTintValue('75%'));
|
|
|
|
$this->assertNull($obj->exposeParseLabLstarValue(''));
|
|
$this->assertNull($obj->exposeParseLabLstarValue('abc'));
|
|
$this->assertSame(100.0, $obj->exposeParseLabLstarValue('120'));
|
|
$this->assertSame(25.0, $obj->exposeParseLabLstarValue('25%'));
|
|
|
|
$this->assertNull($obj->exposeParseLabAxisValue('abc'));
|
|
$this->assertSame(-128.0, $obj->exposeParseLabAxisValue('-999'));
|
|
$this->assertSame(127.0, $obj->exposeParseLabAxisValue('999'));
|
|
}
|
|
}
|