Files
putnamrec/vendor/tecnickcom/tc-lib-pdf/test/CSSTest.php
T
2026-08-20 11:41:17 +00:00

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('&lt;a&amp;b&gt;');
$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'));
}
}