Initial commit

This commit is contained in:
2026-08-30 22:02:02 +00:00
commit b6bd2277f5
2334 changed files with 646393 additions and 0 deletions
@@ -0,0 +1,78 @@
<?php
/**
* AFRelationshipTest.php
*
* @since 2026-07-17
* @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 Com\Tecnick\Pdf\AFRelationship;
/**
* AFRelationship enum test
*
* @since 2026-07-17
* @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
*/
class AFRelationshipTest extends TestUtil
{
public function testCaseBackingValues(): void
{
$this->assertSame('Source', AFRelationship::Source->value);
$this->assertSame('Data', AFRelationship::Data->value);
$this->assertSame('Alternative', AFRelationship::Alternative->value);
$this->assertSame('Supplement', AFRelationship::Supplement->value);
$this->assertSame('Unspecified', AFRelationship::Unspecified->value);
}
/**
* @throws \Com\Tecnick\Pdf\Exception
*/
public function testFromLooseCanonical(): void
{
$this->assertSame(AFRelationship::Source, AFRelationship::fromLoose('Source'));
$this->assertSame(AFRelationship::Supplement, AFRelationship::fromLoose('Supplement'));
}
/**
* @throws \Com\Tecnick\Pdf\Exception
*/
public function testFromLoosePassesThroughEnumInstance(): void
{
$this->assertSame(AFRelationship::Data, AFRelationship::fromLoose(AFRelationship::Data));
}
/**
* @throws \Com\Tecnick\Pdf\Exception
*/
public function testFromLooseRoundTrip(): void
{
foreach (AFRelationship::cases() as $case) {
$this->assertSame($case, AFRelationship::fromLoose($case->value));
}
}
/**
* @throws \Com\Tecnick\Pdf\Exception
*/
public function testFromLooseUnknownThrows(): void
{
$this->bcExpectException(\Com\Tecnick\Pdf\Exception::class);
AFRelationship::fromLoose('Nope');
}
}
@@ -0,0 +1,94 @@
<?php
/**
* BaseInitClassObjectsTest.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 BaseInitClassObjectsTest extends TestUtil
{
/** @throws \Throwable */
protected function getTestObject(): \Com\Tecnick\Pdf\Tcpdf
{
return new \Com\Tecnick\Pdf\Tcpdf();
}
/** @throws \Throwable */
public function testInitClassObjectsInitializesDependencies(): void
{
$obj = $this->getTestObject();
$obj->initClassObjects();
$this->assertInstanceOf(\Com\Tecnick\Pdf\Encrypt\Encrypt::class, $this->getObjectProperty($obj, 'encrypt'));
$this->assertInstanceOf(\Com\Tecnick\Color\Pdf::class, $this->getObjectProperty($obj, 'color'));
$this->assertInstanceOf(\Com\Tecnick\Barcode\Barcode::class, $this->getObjectProperty($obj, 'barcode'));
$this->assertInstanceOf(\Com\Tecnick\Pdf\Page\Page::class, $this->getObjectProperty($obj, 'page'));
$this->assertInstanceOf(\Com\Tecnick\Pdf\Graph\Draw::class, $this->getObjectProperty($obj, 'graph'));
$this->assertInstanceOf(\Com\Tecnick\Pdf\Font\Stack::class, $this->getObjectProperty($obj, 'font'));
$this->assertInstanceOf(\Com\Tecnick\Pdf\Image\Import::class, $this->getObjectProperty($obj, 'image'));
}
/** @throws \Throwable */
public function testInitClassObjectsUsesProvidedEncryptObject(): void
{
$obj = $this->getTestObject();
$enc = new \Com\Tecnick\Pdf\Encrypt\Encrypt();
$obj->initClassObjects($enc);
$this->assertSame($enc, $this->getObjectProperty($obj, 'encrypt'));
}
/** @throws \Throwable */
public function testInitClassObjectsRaisesVersionForEncryptionV2(): void
{
$obj = $this->getTestObject();
$this->setObjectProperty($obj, 'pdfver', '1.3');
$enc = new class() extends \Com\Tecnick\Pdf\Encrypt\Encrypt {
public function getEncryptionData(): array
{
$data = parent::getEncryptionData();
$data['encrypted'] = true;
$data['V'] = 2;
return $data;
}
};
$obj->initClassObjects($enc);
$this->assertSame('1.4', $this->getObjectProperty($obj, 'pdfver'));
}
/** @throws \Throwable */
public function testInitClassObjectsRaisesVersionForEncryptionLegacyMode(): void
{
$obj = $this->getTestObject();
$this->setObjectProperty($obj, 'pdfver', '1.0');
$enc = new class() extends \Com\Tecnick\Pdf\Encrypt\Encrypt {
public function getEncryptionData(): array
{
$data = parent::getEncryptionData();
$data['encrypted'] = true;
$data['V'] = 1;
return $data;
}
};
$obj->initClassObjects($enc);
$this->assertSame('1.1', $this->getObjectProperty($obj, 'pdfver'));
}
}
+407
View File
@@ -0,0 +1,407 @@
<?php
/**
* BaseTest.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;
class BaseTest extends TestUtil
{
private function invokeBaseMethod(object $obj, string $method, mixed ...$args): mixed
{
$ref = new \ReflectionClass($obj);
return $ref->getMethod($method)->invokeArgs($obj, $args);
}
/** @throws \Throwable */
protected function getTestObject(): \Com\Tecnick\Pdf\Tcpdf
{
return new \Com\Tecnick\Pdf\Tcpdf();
}
/** @throws \Throwable */
protected function getInternalTestObject(): TestableBase
{
return new TestableBase();
}
/** @throws \Throwable */
public function testToPointsAndToUnitRoundTrip(): void
{
$obj = $this->getTestObject();
$usr = 12.34;
$pnt = $obj->toPoints($usr);
$this->assertGreaterThan(0, $pnt);
$this->bcAssertEqualsWithDelta($usr, $obj->toUnit($pnt), 0.0001);
}
/** @throws \Throwable */
public function testToYPointsAndToYUnitWithExplicitPageHeight(): void
{
$obj = $this->getTestObject();
$usr = 10.0;
$pageh = 200.0;
$yPoints = $obj->toYPoints($usr, $pageh);
$this->bcAssertEqualsWithDelta($pageh - $obj->toPoints($usr), $yPoints, 0.0001);
$yUnit = $obj->toYUnit($yPoints, $pageh);
$this->bcAssertEqualsWithDelta($usr, $yUnit, 0.0001);
}
/** @throws \Throwable */
public function testEnableDefaultPageContentTogglesFlag(): void
{
$obj = $this->getTestObject();
$obj->enableDefaultPageContent(false);
$this->assertFalse($this->getObjectProperty($obj, 'defPageContentEnabled'));
$obj->enableDefaultPageContent(true);
$this->assertTrue($this->getObjectProperty($obj, 'defPageContentEnabled'));
}
/** @throws \Throwable */
public function testSetRTLReturnsSameInstanceAndSetsProperty(): void
{
$obj = $this->getTestObject();
$ret = $obj->setRTL(true);
$this->assertSame($obj, $ret);
$this->assertTrue($this->getObjectProperty($obj, 'rtl'));
$obj->setRTL(false);
$this->assertFalse($this->getObjectProperty($obj, 'rtl'));
}
/** @throws \Throwable */
#[DataProvider('unitValueConversionProvider')]
public function testGetUnitValuePointsConvertsCommonUnits(string $input, float $expected, float $delta): void
{
$obj = $this->getInternalTestObject();
$result = $obj->exposeGetUnitValuePoints($input);
$this->bcAssertEqualsWithDelta($expected, $result, $delta);
}
/** @throws \Throwable */
public function testGetUnitValuePointsConvertsRelativeAndViewportUnits(): void
{
$obj = $this->getInternalTestObject();
$ref = [
'parent' => 120.0,
'font' => ['size' => 12.0, 'xheight' => 5.0, 'zerowidth' => 6.0, 'rootsize' => 16.0],
'viewport' => ['width' => 300.0, 'height' => 200.0],
'page' => ['width' => 300.0, 'height' => 200.0],
];
$this->bcAssertEqualsWithDelta(12.0, $obj->exposeGetUnitValuePoints('2ch', $ref), 0.0001);
$this->bcAssertEqualsWithDelta(12.0, $obj->exposeGetUnitValuePoints('10%', $ref), 0.0001);
$this->bcAssertEqualsWithDelta(24.0, $obj->exposeGetUnitValuePoints('2em', $ref), 0.0001);
$this->bcAssertEqualsWithDelta(10.0, $obj->exposeGetUnitValuePoints('2ex', $ref), 0.0001);
$this->bcAssertEqualsWithDelta(24.0, $obj->exposeGetUnitValuePoints('2pc', $ref), 0.0001);
$this->bcAssertEqualsWithDelta(32.0, $obj->exposeGetUnitValuePoints('2rem', $ref), 0.0001);
$this->bcAssertEqualsWithDelta(20.0, $obj->exposeGetUnitValuePoints('10vh', $ref), 0.0001);
$this->bcAssertEqualsWithDelta(30.0, $obj->exposeGetUnitValuePoints('10vw', $ref), 0.0001);
$this->bcAssertEqualsWithDelta(30.0, $obj->exposeGetUnitValuePoints('10vmax', $ref), 0.0001);
$this->bcAssertEqualsWithDelta(20.0, $obj->exposeGetUnitValuePoints('10vmin', $ref), 0.0001);
}
/** @throws \Throwable */
public function testGetUnitValuePointsConvertsNumericDefault(): void
{
$obj = $this->getInternalTestObject();
$result = $obj->exposeGetUnitValuePoints(5.5);
$this->assertGreaterThan(0, $result);
}
/** @throws \Throwable */
public function testGetUnitValuePointsThrowsForInvalidValue(): void
{
$obj = $this->getInternalTestObject();
$this->bcExpectException(\Com\Tecnick\Pdf\Exception::class);
$obj->exposeGetUnitValuePoints('invalid!!!');
}
/** @throws \Throwable */
public function testGetFontValuePointsConvertsNamedFontSize(): void
{
$obj = $this->getInternalTestObject();
$ref = [
'parent' => 12.0,
'font' => ['size' => 12.0, 'xheight' => 1.0, 'zerowidth' => 1.0, 'rootsize' => 16.0],
'viewport' => ['width' => 800.0, 'height' => 600.0],
'page' => ['width' => 800.0, 'height' => 600.0],
];
$result = $obj->exposeGetFontValuePoints('larger', $ref);
// 'larger' should result in parent + larger factor
$this->assertGreaterThan($ref['parent'], $result);
}
/** @throws \Throwable */
public function testGetFontValuePointsDelegatesUnknownUnitToGetUnitValuePoints(): void
{
$obj = $this->getInternalTestObject();
$result = $obj->exposeGetFontValuePoints('10mm');
// 10 mm = 10 * 72 / 25.4 ≈ 28.35 points
$this->bcAssertEqualsWithDelta(28.35, $result, 0.1);
}
/** @throws \Throwable */
#[DataProvider('tmpRtlModeProvider')]
public function testSetTmpRTLWithMode(string $mode, bool $expectedRtl): void
{
$obj = $this->getInternalTestObject();
$obj->exposeSetTmpRTL($mode);
$this->assertSame($expectedRtl, $obj->exposeIsRTL());
}
/** @throws \Throwable */
#[DataProvider('isRtlStateProvider')]
public function testIsRTLReturnsExpectedState(bool $globalRtl, bool $expected): void
{
$obj = $this->getInternalTestObject();
$obj->setRTL($globalRtl);
$result = $obj->exposeIsRTL();
$this->assertSame($expected, $result);
}
/** @throws \Throwable */
public function testIsTransparencyAllowedReturnsTrueWhenPdfxDisabled(): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'pdfx', false);
$this->setObjectProperty($obj, 'pdfxMode', 'pdfx1a');
$this->assertTrue($this->invokeBaseMethod($obj, 'isTransparencyAllowed') === true);
}
/** @throws \Throwable */
public function testIsTransparencyAllowedDependsOnPdfxMode(): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'pdfx', true);
$this->setObjectProperty($obj, 'pdfxMode', 'pdfx4');
$this->assertTrue($this->invokeBaseMethod($obj, 'isTransparencyAllowed'));
$this->setObjectProperty($obj, 'pdfxMode', 'pdfx3');
$this->assertFalse($this->invokeBaseMethod($obj, 'isTransparencyAllowed'));
}
/** @throws \Throwable */
public function testRequiresPdfxDeviceCmykReturnsFalseWhenPdfxDisabled(): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'pdfx', false);
$this->setObjectProperty($obj, 'pdfxMode', 'pdfx1a');
$this->assertFalse($this->invokeBaseMethod($obj, 'requiresPdfxDeviceCmyk'));
}
/** @throws \Throwable */
public function testRequiresPdfxDeviceCmykDependsOnPdfxMode(): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'pdfx', true);
$this->setObjectProperty($obj, 'pdfxMode', 'pdfx3');
$this->assertTrue($this->invokeBaseMethod($obj, 'requiresPdfxDeviceCmyk'));
$this->setObjectProperty($obj, 'pdfxMode', 'pdfx5');
$this->assertFalse($this->invokeBaseMethod($obj, 'requiresPdfxDeviceCmyk'));
}
/** @throws \Throwable */
public function testDefaultFileAllowedPathsIncludesKnownRoots(): void
{
$obj = $this->getTestObject();
$paths = $obj->defaultFileAllowedPaths();
$tmpRoot = \realpath(\sys_get_temp_dir());
$pkgRoot = \realpath(__DIR__ . '/..');
$fontsRoot = \defined('K_PATH_FONTS') ? \realpath((string) \constant('K_PATH_FONTS')) : false;
if (\is_string($tmpRoot) && $tmpRoot !== '') {
$this->assertContains($tmpRoot, $paths);
}
if (\is_string($pkgRoot) && $pkgRoot !== '') {
$this->assertContains($pkgRoot, $paths);
}
if (\is_string($fontsRoot) && $fontsRoot !== '') {
$this->assertContains($fontsRoot, $paths);
}
}
/** @throws \Throwable */
public function testDefaultFileAllowedPathsReturnsUniqueNonEmptyResolvedPaths(): void
{
$obj = $this->getTestObject();
$paths = $obj->defaultFileAllowedPaths();
foreach ($paths as $path) {
$this->assertNotSame('', $path);
$this->assertSame($path, \realpath($path));
}
$this->assertCount(\count(\array_unique($paths)), $paths);
}
/** @throws \Throwable */
public function testDefaultMarkupAllowedPathsExcludeSystemTemp(): void
{
$obj = $this->getTestObject();
$paths = $obj->defaultMarkupAllowedPaths();
$tmpRoot = \realpath(\sys_get_temp_dir());
if (\is_string($tmpRoot) && $tmpRoot !== '') {
$this->assertNotContains($tmpRoot, $paths);
}
$pkgRoot = \realpath(__DIR__ . '/..');
if (\is_string($pkgRoot) && $pkgRoot !== '') {
$this->assertContains($pkgRoot, $paths);
}
}
/** @throws \Throwable */
public function testVendorSiblingPackagesPathDetectsComposerDependencyLayout(): void
{
$obj = $this->getTestObject();
$this->assertSame('/proj/vendor/tecnickcom', $this->invokeBaseMethod(
$obj,
'vendorSiblingPackagesPath',
'/proj/vendor/tecnickcom/tc-lib-pdf/src',
));
$this->assertNull($this->invokeBaseMethod(
$obj,
'vendorSiblingPackagesPath',
'/home/dev/github.com/tecnickcom/tc-lib-pdf/src',
));
$this->assertNull($this->invokeBaseMethod(
$obj,
'vendorSiblingPackagesPath',
'/proj/custom-deps/tecnickcom/tc-lib-pdf/src',
));
}
/** @throws \Throwable */
public function testDefaultFileAllowedPathsMatchVendorSiblingDetection(): void
{
$obj = $this->getTestObject();
$paths = $obj->defaultFileAllowedPaths();
$baseFileClass = new \ReflectionClass(\Com\Tecnick\Pdf\Base::class);
$baseFile = $baseFileClass->getFileName();
$this->assertIsString($baseFile);
$srcDir = \dirname($baseFile);
/** @var ?string $vendorSiblings */
$vendorSiblings = $this->invokeBaseMethod($obj, 'vendorSiblingPackagesPath', $srcDir);
if ($vendorSiblings === null) {
$siblingsDir = \realpath(\dirname($srcDir, 2));
if (\is_string($siblingsDir) && $siblingsDir !== '') {
$this->assertNotContains($siblingsDir, $paths);
}
return;
}
$this->assertIsString($vendorSiblings);
$resolvedVendorSiblings = \realpath($vendorSiblings);
if (\is_string($resolvedVendorSiblings) && $resolvedVendorSiblings !== '') {
$this->assertContains($resolvedVendorSiblings, $paths);
}
}
/** @throws \Throwable */
public function testAllowedRootsMatchWindowsStylePathsWithDriveLetters(): void
{
$probe = new class extends \Com\Tecnick\File\File {
/** @param array<string> $roots */
public function probeIsPathWithinAllowedRoots(string $path, array $roots): bool
{
return $this->isPathWithinAllowedRoots($path, $roots);
}
/**
* @param array<string> $roots
* @return array<string>
*/
public function probeNormalizeAllowedPaths(array $roots): array
{
return $this->normalizeAllowedPaths($roots);
}
};
$allowedRoot = 'C:\\webdev\\project\\storage\\app\\private\\pdf\\fonts';
$allowedPath = 'c:\\webdev\\project\\storage\\app\\private\\pdf\\fonts\\core\\helvetica.json';
$blockedPath = 'C:\\webdev\\project\\storage\\app\\private\\pdf\\fontsevil\\core\\helvetica.json';
$normalizedRoots = $probe->probeNormalizeAllowedPaths([$allowedRoot]);
$this->assertSame(['c:/webdev/project/storage/app/private/pdf/fonts'], $normalizedRoots);
$this->assertTrue($probe->probeIsPathWithinAllowedRoots($allowedPath, $normalizedRoots));
$this->assertFalse($probe->probeIsPathWithinAllowedRoots($blockedPath, $normalizedRoots));
}
/** @return array<string, array{0: string, 1: float, 2: float}> */
public static function unitValueConversionProvider(): array
{
return [
'px' => ['96px', 72.0, 0.1],
'pt' => ['12pt', 12.0, 0.0001],
'cm' => ['1cm', 28.35, 0.1],
'in' => ['1in', 72.0, 0.1],
];
}
/** @return array<string, array{0: string, 1: bool}> */
public static function tmpRtlModeProvider(): array
{
return [
'R_mode' => ['R', true],
'L_mode' => ['L', false],
'empty_mode' => ['', false],
];
}
/** @return array<string, array{0: bool, 1: bool}> */
public static function isRtlStateProvider(): array
{
return [
'default_false' => [false, false],
'global_true' => [true, true],
];
}
}
@@ -0,0 +1,172 @@
<?php
/**
* CascadeContextTest.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 Com\Tecnick\Pdf\CSS\CascadeContext;
class CascadeContextTest extends TestUtil
{
public function testInitialStateHasZeroSourceOrder(): void
{
$ctx = new CascadeContext();
$this->assertSame(0, $ctx->getTotalRulesProcessed());
}
public function testGetNextNormalSourceOrderIncrementsCounter(): void
{
$ctx = new CascadeContext();
$order1 = $ctx->getNextNormalSourceOrder();
$order2 = $ctx->getNextNormalSourceOrder();
$order3 = $ctx->getNextNormalSourceOrder();
$this->assertSame(1, $order1);
$this->assertSame(2, $order2);
$this->assertSame(3, $order3);
$this->assertSame(3, $ctx->getTotalRulesProcessed());
}
public function testGetNextImportantSourceOrderStartsAboveNormalRange(): void
{
$ctx = new CascadeContext();
// Normal rules
$ctx->getNextNormalSourceOrder();
$ctx->getNextNormalSourceOrder();
// First important rule should start at MIN_IMPORTANT_SOURCE_ORDER
$importantOrder = $ctx->getNextImportantSourceOrder();
$this->assertSame(CascadeContext::MIN_IMPORTANT_SOURCE_ORDER + 1, $importantOrder);
$this->assertGreaterThan(CascadeContext::INLINE_STYLE_SOURCE_ORDER, $importantOrder);
}
public function testImportantAndNormalCountersAreIndependent(): void
{
$ctx = new CascadeContext();
$normal1 = $ctx->getNextNormalSourceOrder();
$important1 = $ctx->getNextImportantSourceOrder();
$normal2 = $ctx->getNextNormalSourceOrder();
$important2 = $ctx->getNextImportantSourceOrder();
$this->assertSame(1, $normal1);
$this->assertSame(CascadeContext::MIN_IMPORTANT_SOURCE_ORDER + 1, $important1);
$this->assertSame(2, $normal2);
$this->assertSame(CascadeContext::MIN_IMPORTANT_SOURCE_ORDER + 2, $important2);
}
public function testInlineStyleSourceOrderIsMaximumNormalValue(): void
{
$inlineOrder = CascadeContext::getInlineStyleSourceOrder();
$this->assertSame(10000000, $inlineOrder);
$this->assertGreaterThan(CascadeContext::MAX_NORMAL_SOURCE_ORDER, $inlineOrder);
$this->assertLessThan(CascadeContext::MIN_IMPORTANT_SOURCE_ORDER, $inlineOrder);
}
public function testSetCurrentSourceTypeStoresAndRetrievesValue(): void
{
$ctx = new CascadeContext();
$this->assertSame('embedded', $ctx->getCurrentSourceType());
$ctx->setCurrentSourceType('external');
$this->assertSame('external', $ctx->getCurrentSourceType());
$ctx->setCurrentSourceType('inline');
$this->assertSame('inline', $ctx->getCurrentSourceType());
}
public function testResetClearsAllCountersAndState(): void
{
$ctx = new CascadeContext();
// Add some rules
$ctx->setCurrentSourceType('external');
$ctx->getNextNormalSourceOrder();
$ctx->getNextNormalSourceOrder();
$ctx->getNextImportantSourceOrder();
$this->assertGreaterThan(0, $ctx->getTotalRulesProcessed());
$this->assertSame('external', $ctx->getCurrentSourceType());
// Reset
$ctx->reset();
$this->assertSame(0, $ctx->getTotalRulesProcessed());
$this->assertSame('embedded', $ctx->getCurrentSourceType());
}
public function testMultipleSourcesCanBeProcessedSequentially(): void
{
$ctx = new CascadeContext();
// External stylesheet: 3 rules
$ctx->setCurrentSourceType('external');
$ctx->getNextNormalSourceOrder();
$ctx->getNextNormalSourceOrder();
$ext3 = $ctx->getNextNormalSourceOrder();
// Embedded style: 2 rules
$ctx->setCurrentSourceType('embedded');
$emb1 = $ctx->getNextNormalSourceOrder();
$emb2 = $ctx->getNextNormalSourceOrder();
// Inline style: 1 rule
$ctx->setCurrentSourceType('inline');
$inl1 = $ctx->getNextNormalSourceOrder();
// Verify ordering: external < embedded < inline
$this->assertLessThan($emb1, $ext3);
$this->assertLessThan($inl1, $emb2);
$this->assertSame(6, $ctx->getTotalRulesProcessed());
}
public function testSourceOrderValuesRemainBelowImportantRange(): void
{
$ctx = new CascadeContext();
// Generate many normal rules
for ($i = 0; $i < 100; ++$i) {
$order = $ctx->getNextNormalSourceOrder();
$this->assertLessThanOrEqual(
CascadeContext::MAX_NORMAL_SOURCE_ORDER,
$order,
"Normal source order exceeded max at iteration {$i}",
);
}
// Important rules should still be higher
$importantOrder = $ctx->getNextImportantSourceOrder();
$this->assertGreaterThan(CascadeContext::MAX_NORMAL_SOURCE_ORDER, $importantOrder);
}
public function testCascadePrecedenceForDeterminism(): void
{
// This test verifies the precedence model:
// normal rules < inline styles < !important rules
$normalMax = CascadeContext::MAX_NORMAL_SOURCE_ORDER;
$inlineOrder = CascadeContext::getInlineStyleSourceOrder();
$importantMin = CascadeContext::MIN_IMPORTANT_SOURCE_ORDER;
// Verify cascade ordering: normal < inline < important
$this->assertLessThan($inlineOrder, $normalMax);
$this->assertLessThan($importantMin, $inlineOrder);
}
}
@@ -0,0 +1,189 @@
<?php
/**
* ImportanceNormalizerTest.php
*
* @since 2026-05-08
* @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 Com\Tecnick\Pdf\CSS\ImportanceNormalizer;
use PHPUnit\Framework\Attributes\DataProvider;
/**
* Test ImportanceNormalizer CSS declaration normalization
*/
class ImportanceNormalizerTest extends TestUtil
{
public function testGetAffectedLonghandsExpandsNestedBorderAliases(): void
{
$affected = ImportanceNormalizer::getAffectedLonghands('border');
$this->assertContains('border-width', $affected);
$this->assertContains('border-style', $affected);
$this->assertContains('border-color', $affected);
$this->assertContains('border-top-width', $affected);
$this->assertContains('border-right-style', $affected);
$this->assertContains('border-bottom-color', $affected);
$this->assertContains('border-left-width', $affected);
}
public function testNormalizePreservesRegularDeclarations(): void
{
$input = 'color: red; font-size: 12px;';
$result = ImportanceNormalizer::normalize($input);
$this->assertStringContainsString('color:red;', \str_replace(' ', '', $result));
$this->assertStringContainsString('font-size:12px;', \str_replace(' ', '', $result));
$this->assertStringNotContainsString('!important', $result);
}
public function testNormalizePreservesImportantFlag(): void
{
$input = 'color: red !important; font-size: 12px;';
$result = ImportanceNormalizer::normalize($input);
$this->assertStringContainsString('color:red!important;', \str_replace(' ', '', $result));
$this->assertStringNotContainsString('font-size:12px!important;', \str_replace(' ', '', $result));
}
public function testNormalizeBorderShorthandWithoutImportant(): void
{
$input = 'border: 1px solid red;';
$result = ImportanceNormalizer::normalize($input);
// Should contain the shorthand property
$this->assertStringContainsString('border:', $result);
$this->assertStringNotContainsString('!important', $result);
}
public function testNormalizeBorderShorthandWithImportant(): void
{
$input = 'border: 1px solid red !important;';
$result = ImportanceNormalizer::normalize($input);
// The shorthand should have !important
$this->assertStringContainsString('border:', $result);
$this->assertStringContainsString('!important', $result);
}
public function testNormalizeMixedShorthandAndLonghands(): void
{
$input = 'margin: 10px !important; margin-top: 20px;';
$result = ImportanceNormalizer::normalize($input);
// Should contain both margin shorthand and the longhand
$this->assertStringContainsString('margin:', $result);
$this->assertStringContainsString('margin-top:', $result);
// Shorthand !important should be preserved
$this->assertStringContainsString('!important', $result);
}
public function testNormalizePaddingShorthandWithImportant(): void
{
$input = 'padding: 5px 10px !important;';
$result = ImportanceNormalizer::normalize($input);
$this->assertStringContainsString('padding:', $result);
$this->assertStringContainsString('!important', $result);
}
public function testNormalizeEmptyString(): void
{
$result = ImportanceNormalizer::normalize('');
$this->assertSame('', $result);
}
public function testNormalizeHandlesWhitespaceAroundImportant(): void
{
$input = 'color: blue ! important;';
$result = ImportanceNormalizer::normalize($input);
$this->assertStringContainsString('!important', $result);
}
public function testNormalizeMultipleImportantDeclarations(): void
{
$input = 'color: red !important; background: blue !important; font-size: 14px;';
$result = ImportanceNormalizer::normalize($input);
$normalized = \str_replace(' ', '', $result);
$this->assertStringContainsString('color:red!important;', $normalized);
$this->assertStringContainsString('background:blue!important;', $normalized);
$this->assertStringNotContainsString('font-size:14px!important;', $normalized);
}
public function testNormalizeHandlesTrailingSemicolon(): void
{
$input = 'color: red;';
$result = ImportanceNormalizer::normalize($input);
$this->assertStringEndsWith(';', $result);
}
public function testNormalizeCaseInsensitive(): void
{
$input = 'COLOR: red !IMPORTANT;';
$result = ImportanceNormalizer::normalize($input);
// Property should be lowercased
$this->assertStringContainsString('color:', $result);
// !important flag should be present (case-insensitive)
$this->assertStringContainsString('!important', $result);
}
public function testNormalizeSkipsMalformedDeclarationSegments(): void
{
$input = 'broken; :12px; color: red;';
$result = ImportanceNormalizer::normalize($input);
$normalized = \str_replace(' ', '', $result);
$this->assertSame('color:red;', $normalized);
}
public function testNormalizePromotesExistingLonghandWhenShorthandImportantAppearsLater(): void
{
$input = 'margin-top: 2px; margin: 10px !important;';
$result = ImportanceNormalizer::normalize($input);
$normalized = \str_replace(' ', '', $result);
$this->assertStringContainsString('margin-top:2px!important;', $normalized);
$this->assertStringContainsString('margin:10px!important;', $normalized);
}
#[DataProvider('realWorldDeclarationsProvider')]
public function testNormalizeRealWorldDeclarations(string $input, string $expectedProperty): void
{
$result = ImportanceNormalizer::normalize($input);
$this->assertStringContainsString($expectedProperty, $result);
}
/** @return array<string, array{0: string, 1: string}> */
public static function realWorldDeclarationsProvider(): array
{
return [
'button_styling' => [
'padding: 10px 20px !important; color: white; background: blue;',
'padding:',
],
'text_emphasis' => [
'font-weight: bold !important; font-size: 14px;',
'font-weight:',
],
'border_reset' => [
'border: none !important; margin: 0;',
'border:',
],
];
}
}
@@ -0,0 +1,235 @@
<?php
/**
* SpecificityTest.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 Com\Tecnick\Pdf\CSS\Specificity;
use PHPUnit\Framework\Attributes\DataProvider;
class SpecificityTest extends TestUtil
{
public function testConstructorInitializesValues(): void
{
$spec = new Specificity(1, 2, 3);
$this->assertSame(1, $spec->idCount);
$this->assertSame(2, $spec->classCount);
$this->assertSame(3, $spec->typeCount);
}
public function testConstructorNormalizesNegativeValues(): void
{
$spec = new Specificity(-1, -2, -3);
$this->assertSame(0, $spec->idCount);
$this->assertSame(0, $spec->classCount);
$this->assertSame(0, $spec->typeCount);
}
#[DataProvider('selectorSpecificityProvider')]
public function testFromSelectorCalculatesCorrectSpecificity(
string $selector,
int $expectedA,
int $expectedB,
int $expectedC,
): void {
$spec = Specificity::fromSelector($selector);
$this->assertSame($expectedA, $spec->idCount, "ID count mismatch for: {$selector}");
$this->assertSame(
$expectedB,
$spec->classCount,
"Class/attribute/pseudo-class count mismatch for: {$selector}",
);
$this->assertSame($expectedC, $spec->typeCount, "Type/pseudo-element count mismatch for: {$selector}");
}
/** @return array<string, array{0: string, 1: int, 2: int, 3: int}> */
public static function selectorSpecificityProvider(): array
{
return [
'universal selector' => ['*', 0, 0, 0],
'single type' => ['div', 0, 0, 1],
'single class' => ['.highlight', 0, 1, 0],
'single id' => ['#main', 1, 0, 0],
'type with class' => ['div.highlight', 0, 1, 1],
'type with id' => ['div#main', 1, 0, 1],
'type with attribute' => ['div[role="button"]', 0, 1, 1],
'type with pseudo-class' => ['div:hover', 0, 1, 1],
'descendant combinator' => ['div p', 0, 0, 2],
'child combinator' => ['div > p', 0, 0, 2],
'adjacent sibling' => ['h1 + p', 0, 0, 2],
'general sibling' => ['h1 ~ p', 0, 0, 2],
'multiple ids' => ['#main #article', 2, 0, 0],
'multiple classes' => ['.header.highlight.active', 0, 3, 0],
'complex selector 1' => ['.header nav li a:hover', 0, 2, 3],
'complex selector 2' => ['#nav .menu li a:visited', 1, 2, 2],
'multiple pseudo-classes' => ['a:link:visited:hover', 0, 3, 1],
'pseudo-element before' => ['p::before', 0, 0, 2],
'pseudo-element after' => ['p::after', 0, 0, 2],
'type and pseudo-element' => ['div::before', 0, 0, 2],
'multiple attributes' => ['div[data-foo][data-bar]', 0, 2, 1],
];
}
#[DataProvider('specificityComparisonProvider')]
public function testComparisonMethods(
int $idCount1,
int $classCount1,
int $typeCount1,
int $idCount2,
int $classCount2,
int $typeCount2,
int $expectedComparison,
): void {
$spec1 = new Specificity($idCount1, $classCount1, $typeCount1);
$spec2 = new Specificity($idCount2, $classCount2, $typeCount2);
$result = $spec1->compareTo($spec2);
$this->assertSame($expectedComparison, $result);
if ($expectedComparison < 0) {
$this->assertTrue($spec1->isLessThan($spec2));
$this->assertFalse($spec1->isGreaterThan($spec2));
$this->assertFalse($spec1->equals($spec2));
} elseif ($expectedComparison > 0) {
$this->assertTrue($spec1->isGreaterThan($spec2));
$this->assertFalse($spec1->isLessThan($spec2));
$this->assertFalse($spec1->equals($spec2));
} else {
$this->assertTrue($spec1->equals($spec2));
$this->assertFalse($spec1->isLessThan($spec2));
$this->assertFalse($spec1->isGreaterThan($spec2));
}
}
/**
* @return array<string, array{0: int, 1: int, 2: int, 3: int, 4: int, 5: int, 6: int}>
*/
public static function specificityComparisonProvider(): array
{
return [
'equal' => [0, 0, 0, 0, 0, 0, 0],
'a determines precedence' => [1, 0, 0, 0, 9, 9, 1],
'b determines when a equal' => [0, 1, 0, 0, 0, 9, 1],
'c determines when a and b equal' => [0, 0, 1, 0, 0, 0, 1],
'less: all components' => [0, 0, 0, 1, 1, 1, -1],
'greater: all components' => [1, 1, 1, 0, 0, 0, 1],
'edge case: high b vs low a' => [0, 99, 99, 1, 0, 0, -1],
'edge case: high c vs low a' => [0, 0, 99, 1, 0, 0, -1],
'edge case: all high vs single a' => [0, 99, 99, 1, 0, 0, -1],
];
}
public function testSortKeyFormatting(): void
{
$spec = new Specificity(1, 2, 3);
$key = $spec->toSortKey(0);
$this->assertStringMatchesFormat('%s_%s', $key);
$this->assertStringContainsString('0001', $key);
$this->assertStringContainsString('0002', $key);
$this->assertStringContainsString('0003', $key);
$this->assertStringContainsString('000000', $key);
}
public function testSortKeyWithSourceOrder(): void
{
$spec = new Specificity(1, 2, 3);
$key1 = $spec->toSortKey(0);
$key2 = $spec->toSortKey(1);
// Same specificity, different source order - should be ordered by index
$this->assertLessThan(0, \strcmp($key1, $key2));
}
public function testSortKeyOrderingBySpecificity(): void
{
$low = new Specificity(0, 0, 1);
$medium = new Specificity(0, 1, 0);
$high = new Specificity(1, 0, 0);
$keys = [
$high->toSortKey(0),
$low->toSortKey(0),
$medium->toSortKey(0),
];
$sorted = $keys;
\sort($sorted);
// After string sort, low < medium < high
$this->assertSame($low->toSortKey(0), $sorted[0]);
assert(isset($sorted[1]), "\$sorted[1] must be set");
$this->assertSame($medium->toSortKey(0), $sorted[1]);
assert(isset($sorted[2]), "\$sorted[2] must be set");
$this->assertSame($high->toSortKey(0), $sorted[2]);
}
public function testToStringRepresentation(): void
{
$spec = new Specificity(1, 2, 3);
$str = $spec->toString();
$this->assertSame('(1,2,3)', $str);
}
public function testLegacyStringConversion(): void
{
$spec = new Specificity(1, 2, 3);
$legacy = $spec->toLegacyString(0);
$this->assertSame('0123', $legacy);
$legacyInline = $spec->toLegacyString(1);
$this->assertSame('1123', $legacyInline);
}
public function testLegacyStringParsing(): void
{
$spec = Specificity::fromLegacyString('0123');
$this->assertSame(1, $spec->idCount);
$this->assertSame(2, $spec->classCount);
$this->assertSame(3, $spec->typeCount);
}
public function testLegacyStringRoundTrip(): void
{
$original = new Specificity(2, 3, 4);
$legacy = $original->toLegacyString(0);
$restored = Specificity::fromLegacyString($legacy);
$this->assertTrue($original->equals($restored));
}
public function testRealWorldSelectorComparisons(): void
{
// From CSS spec examples
$headingOne = Specificity::fromSelector('h1');
$h1_foo = Specificity::fromSelector('h1.foo');
$h1_foo_bar = Specificity::fromSelector('h1.foo.bar');
$div_p_a = Specificity::fromSelector('div p a');
$foo_bar_baz = Specificity::fromSelector('.foo .bar .baz');
$id_selector = Specificity::fromSelector('#main');
// h1 (0,0,1) < h1.foo (0,1,1)
$this->assertTrue($headingOne->isLessThan($h1_foo));
// h1.foo (0,1,1) < h1.foo.bar (0,2,1)
$this->assertTrue($h1_foo->isLessThan($h1_foo_bar));
// div p a (0,0,3) < .foo .bar .baz (0,3,0)
$this->assertTrue($div_p_a->isLessThan($foo_bar_baz));
// All < #main (1,0,0)
$this->assertTrue($foo_bar_baz->isLessThan($id_selector));
$this->assertTrue($h1_foo_bar->isLessThan($id_selector));
}
}
File diff suppressed because it is too large Load Diff
+169
View File
@@ -0,0 +1,169 @@
<?php
/**
* CacheTest.php
*
* @since 2026-06-16
* @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 Com\Tecnick\Pdf\Cache\CacheInterface;
use Com\Tecnick\Pdf\Cache\FontSubsetCacheAdapter;
use Com\Tecnick\Pdf\Cache\ImageCacheAdapter;
/**
* Test the external cache interface, adapters, and Tcpdf wiring.
*/
class CacheTest extends TestUtil
{
public function testFontSubsetCacheAdapterStoresAndRetrievesString(): void
{
$fake = new FakeCache();
$adapter = new FontSubsetCacheAdapter($fake);
$adapter->set('fontkey', 'subset-program');
$this->assertSame(['fontkey'], $fake->setKeys);
$this->assertSame('subset-program', $fake->store['fontkey'] ?? null);
$this->assertSame('subset-program', $adapter->get('fontkey'));
$this->assertSame(['fontkey'], $fake->getKeys);
}
public function testFontSubsetCacheAdapterReturnsNullOnMiss(): void
{
$adapter = new FontSubsetCacheAdapter(new FakeCache());
$this->assertNull($adapter->get('missing'));
}
public function testFontSubsetCacheAdapterDegradesNonStringToNull(): void
{
$fake = new FakeCache();
$fake->store['fontkey'] = ['not', 'a', 'string'];
$adapter = new FontSubsetCacheAdapter($fake);
$this->assertNull($adapter->get('fontkey'));
}
public function testImageCacheAdapterRetrievesArray(): void
{
$fake = new FakeCache();
$fake->store['imgkey'] = ['width' => 10, 'height' => 20];
$adapter = new ImageCacheAdapter($fake);
$this->assertSame(['width' => 10, 'height' => 20], $adapter->get('imgkey'));
}
public function testImageCacheAdapterReturnsNullOnMiss(): void
{
$adapter = new ImageCacheAdapter(new FakeCache());
$this->assertNull($adapter->get('missing'));
}
public function testImageCacheAdapterDegradesNonArrayToNull(): void
{
$fake = new FakeCache();
$fake->store['imgkey'] = 'not-an-array';
$adapter = new ImageCacheAdapter($fake);
$this->assertNull($adapter->get('imgkey'));
}
/** @throws \Throwable */
public function testConstructorWithoutCacheLeavesExternalCacheNull(): void
{
$obj = new \Com\Tecnick\Pdf\Tcpdf();
$this->assertNull($this->getObjectProperty($obj, 'extCache'));
}
/** @throws \Throwable */
public function testConstructorStoresProvidedCache(): void
{
$fake = new FakeCache();
$obj = new \Com\Tecnick\Pdf\Tcpdf('mm', true, false, true, '', null, null, $fake);
$this->assertSame($fake, $this->getObjectProperty($obj, 'extCache'));
}
/** @throws \Throwable */
public function testImageObjectReceivesAdapterWhenCacheProvided(): void
{
$fake = new FakeCache();
$obj = new \Com\Tecnick\Pdf\Tcpdf('mm', true, false, true, '', null, null, $fake);
/** @var mixed $image */
$image = $this->getObjectProperty($obj, 'image');
$this->assertInstanceOf(\Com\Tecnick\Pdf\Image\Import::class, $image);
$this->assertInstanceOf(ImageCacheAdapter::class, $this->getObjectProperty($image, 'imageCache'));
}
/** @throws \Throwable */
public function testImageObjectHasNoAdapterWithoutCache(): void
{
$obj = new \Com\Tecnick\Pdf\Tcpdf();
/** @var mixed $image */
$image = $this->getObjectProperty($obj, 'image');
$this->assertInstanceOf(\Com\Tecnick\Pdf\Image\Import::class, $image);
$this->assertNull($this->getObjectProperty($image, 'imageCache'));
}
/** @throws \Throwable */
public function testExternalCacheDisabledWhenNotConfigured(): void
{
$obj = new TestableTcpdf();
$this->assertFalse($obj->exposeExtCacheEnabledFor(CacheInterface::TYPE_FONT));
$this->assertFalse($obj->exposeExtCacheEnabledFor(CacheInterface::TYPE_IMAGE));
$this->assertNull($obj->exposeFontSubsetCacheAdapter());
$this->assertNull($obj->exposeImageCacheAdapter());
}
/** @throws \Throwable */
public function testPlainCacheEnablesAllTypes(): void
{
$obj = new TestableTcpdf('mm', true, false, true, '', null, null, new FakeCache());
$this->assertTrue($obj->exposeExtCacheEnabledFor(CacheInterface::TYPE_FONT));
$this->assertTrue($obj->exposeExtCacheEnabledFor(CacheInterface::TYPE_IMAGE));
$this->assertInstanceOf(FontSubsetCacheAdapter::class, $obj->exposeFontSubsetCacheAdapter());
$this->assertInstanceOf(ImageCacheAdapter::class, $obj->exposeImageCacheAdapter());
}
/** @throws \Throwable */
public function testSelectiveCacheEnablesFontsOnly(): void
{
$fake = new FakeSelectiveCache();
$fake->supported = [CacheInterface::TYPE_FONT];
$obj = new TestableTcpdf('mm', true, false, true, '', null, null, $fake);
$this->assertTrue($obj->exposeExtCacheEnabledFor(CacheInterface::TYPE_FONT));
$this->assertFalse($obj->exposeExtCacheEnabledFor(CacheInterface::TYPE_IMAGE));
$this->assertInstanceOf(FontSubsetCacheAdapter::class, $obj->exposeFontSubsetCacheAdapter());
$this->assertNull($obj->exposeImageCacheAdapter());
}
/** @throws \Throwable */
public function testSelectiveCacheEnablesImagesOnly(): void
{
$fake = new FakeSelectiveCache();
$fake->supported = [CacheInterface::TYPE_IMAGE];
$obj = new TestableTcpdf('mm', true, false, true, '', null, null, $fake);
$this->assertFalse($obj->exposeExtCacheEnabledFor(CacheInterface::TYPE_FONT));
$this->assertTrue($obj->exposeExtCacheEnabledFor(CacheInterface::TYPE_IMAGE));
$this->assertNull($obj->exposeFontSubsetCacheAdapter());
$this->assertInstanceOf(ImageCacheAdapter::class, $obj->exposeImageCacheAdapter());
}
}
+75
View File
@@ -0,0 +1,75 @@
<?php
/**
* CacheTypeTest.php
*
* @since 2026-07-17
* @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 Com\Tecnick\Pdf\Cache\CacheType;
/**
* CacheType enum test
*
* @since 2026-07-17
* @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
*/
class CacheTypeTest extends TestUtil
{
public function testCaseBackingValues(): void
{
$this->assertSame('font', CacheType::Font->value);
$this->assertSame('image', CacheType::Image->value);
}
/**
* @throws \Com\Tecnick\Pdf\Exception
*/
public function testFromLooseCanonical(): void
{
$this->assertSame(CacheType::Font, CacheType::fromLoose('font'));
$this->assertSame(CacheType::Image, CacheType::fromLoose('image'));
}
/**
* @throws \Com\Tecnick\Pdf\Exception
*/
public function testFromLoosePassesThroughEnumInstance(): void
{
$this->assertSame(CacheType::Font, CacheType::fromLoose(CacheType::Font));
}
/**
* @throws \Com\Tecnick\Pdf\Exception
*/
public function testFromLooseRoundTrip(): void
{
foreach (CacheType::cases() as $case) {
$this->assertSame($case, CacheType::fromLoose($case->value));
}
}
/**
* @throws \Com\Tecnick\Pdf\Exception
*/
public function testFromLooseUnknownThrows(): void
{
$this->bcExpectException(\Com\Tecnick\Pdf\Exception::class);
CacheType::fromLoose('svg');
}
}
+341
View File
@@ -0,0 +1,341 @@
<?php
/**
* CellTest.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;
/**
* @phpstan-import-type TCellDef from \Com\Tecnick\Pdf\Cell
*/
class CellTest extends TestUtil
{
/** @throws \Throwable */
protected function getTestObject(): \Com\Tecnick\Pdf\Tcpdf
{
return new \Com\Tecnick\Pdf\Tcpdf();
}
/** @throws \Throwable */
protected function getInternalTestObject(): TestableCell
{
return new TestableCell();
}
/** @throws \Throwable */
public function testSetDefaultCellMarginStoresPointValues(): void
{
$obj = $this->getTestObject();
$obj->setDefaultCellMargin(1.0, 2.0, 3.0, 4.0);
/** @var TCellDef $defcell */
$defcell = $this->getObjectProperty($obj, 'defcell');
$this->bcAssertEqualsWithDelta($obj->toPoints(1.0), $defcell['margin']['T']);
$this->bcAssertEqualsWithDelta($obj->toPoints(2.0), $defcell['margin']['R']);
$this->bcAssertEqualsWithDelta($obj->toPoints(3.0), $defcell['margin']['B']);
$this->bcAssertEqualsWithDelta($obj->toPoints(4.0), $defcell['margin']['L']);
}
/** @throws \Throwable */
public function testSetDefaultCellPaddingStoresPointValues(): void
{
$obj = $this->getTestObject();
$obj->setDefaultCellPadding(0.5, 1.5, 2.5, 3.5);
/** @var TCellDef $defcell */
$defcell = $this->getObjectProperty($obj, 'defcell');
$this->bcAssertEqualsWithDelta($obj->toPoints(0.5), $defcell['padding']['T']);
$this->bcAssertEqualsWithDelta($obj->toPoints(1.5), $defcell['padding']['R']);
$this->bcAssertEqualsWithDelta($obj->toPoints(2.5), $defcell['padding']['B']);
$this->bcAssertEqualsWithDelta($obj->toPoints(3.5), $defcell['padding']['L']);
}
/** @throws \Throwable */
public function testSetDefaultCellBorderPosStoresValidValueAndDefaultsInvalid(): void
{
$obj = $this->getTestObject();
$obj->setDefaultCellBorderPos(\Com\Tecnick\Pdf\Base::BORDERPOS_INTERNAL);
/** @var TCellDef $defcell */
$defcell = $this->getObjectProperty($obj, 'defcell');
$this->assertSame(\Com\Tecnick\Pdf\Base::BORDERPOS_INTERNAL, $defcell['borderpos']);
$obj->setDefaultCellBorderPos(99.0);
/** @var TCellDef $defcell */
$defcell = $this->getObjectProperty($obj, 'defcell');
$this->assertSame(\Com\Tecnick\Pdf\Base::BORDERPOS_DEFAULT, $defcell['borderpos']);
}
/** @throws \Throwable */
public function testAdjustMinCellPaddingIncreasesPaddingWithBorderStyle(): void
{
$obj = $this->getInternalTestObject();
/** @var TCellDef $cell */
$cell = $this->getObjectProperty($obj, 'defcell');
$cell['padding'] = ['T' => 0.0, 'R' => 0.0, 'B' => 0.0, 'L' => 0.0];
$styles = ['all' => ['lineWidth' => 1.0]];
$out = $obj->exposeAdjustMinCellPadding($styles, $cell);
$this->assertGreaterThanOrEqual(0.0, $out['padding']['T']);
$this->assertGreaterThanOrEqual(0.0, $out['padding']['R']);
}
/** @throws \Throwable */
public function testAdjustMinCellPaddingSupportsSideSpecificWidths(): void
{
$obj = $this->getInternalTestObject();
/** @var TCellDef $cell */
$cell = $this->getObjectProperty($obj, 'defcell');
$cell['padding'] = ['T' => 0.0, 'R' => 0.0, 'B' => 0.0, 'L' => 0.0];
$styles = [
0 => ['lineWidth' => 0.5],
1 => ['lineWidth' => 1.0],
2 => ['lineWidth' => 1.5],
3 => ['lineWidth' => 2.0],
];
$out = $obj->exposeAdjustMinCellPadding($styles, $cell);
$this->assertGreaterThan(0.0, $out['padding']['T']);
$this->assertGreaterThan(0.0, $out['padding']['R']);
$this->assertGreaterThan(0.0, $out['padding']['B']);
$this->assertGreaterThan(0.0, $out['padding']['L']);
}
/** @throws \Throwable */
public function testAdjustMinCellPaddingUsesCurrentStyleAndInvalidStyleFallback(): void
{
$obj = $this->getInternalTestObject();
/** @var TCellDef $cell */
$cell = $this->getObjectProperty($obj, 'defcell');
$outCurrent = $obj->exposeAdjustMinCellPadding([], $cell);
$outInvalid = $obj->exposeAdjustMinCellPadding(['all' => []], $cell);
$this->assertArrayHasKey('padding', $outCurrent);
$this->assertSame($cell, $outInvalid);
}
/** @throws \Throwable */
public function testCellMinHeightReturnsPositiveForCenterAlign(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$out = $obj->exposeCellMinHeight(10.0, 'C');
$this->assertGreaterThan(0.0, $out);
}
/** @throws \Throwable */
public function testCellMinWidthHandlesCenterAlignment(): void
{
$obj = $this->getInternalTestObject();
$out = $obj->exposeCellMinWidth(20.0, 'C');
$this->assertGreaterThanOrEqual(20.0, $out);
}
/** @throws \Throwable */
public function testCellPositionHelpersReturnFloats(): void
{
$obj = $this->getInternalTestObject();
/** @var TCellDef $cell */
$cell = $this->getObjectProperty($obj, 'defcell');
$verticalPos = $obj->exposeCellVPos(10.0, 5.0, 'T', $cell);
$horizontalPos = $obj->exposeCellHPos(10.0, 5.0, 'L', $cell);
$this->assertGreaterThan(-1000000.0, $verticalPos);
$this->assertGreaterThan(-1000000.0, $horizontalPos);
}
/** @throws \Throwable */
public function testCellTextAlignHelpersReturnFloats(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
/** @var TCellDef $cell */
$cell = $this->getObjectProperty($obj, 'defcell');
$verticalAlign = $obj->exposeCellTextVAlign(20.0, 10.0, 'C', $cell);
$horizontalAlign = $obj->exposeCellTextHAlign(30.0, 12.0, 'C', $cell);
$this->assertGreaterThan(-1000000.0, $verticalAlign);
$this->assertGreaterThan(-1000000.0, $horizontalAlign);
}
/** @throws \Throwable */
public function testCellAlignmentBranchesHandleAllVariants(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
/** @var TCellDef $cell */
$cell = $this->getObjectProperty($obj, 'defcell');
$this->assertGreaterThan(-1000000.0, $obj->exposeCellMinHeight(10.0, 'T', $cell));
$this->assertGreaterThan(-1000000.0, $obj->exposeCellMinHeight(0.0, 'C'));
$this->assertGreaterThan(-1000000.0, $obj->exposeCellMinHeight(10.0, 'L', $cell));
$this->assertGreaterThan(-1000000.0, $obj->exposeCellMinHeight(10.0, 'A', $cell));
$this->assertGreaterThan(-1000000.0, $obj->exposeCellMinHeight(10.0, 'D', $cell));
$this->assertGreaterThanOrEqual(20.0, $obj->exposeCellMinWidth(20.0, 'J'));
$this->assertGreaterThan(-1000000.0, $obj->exposeCellVPos(10.0, 5.0, 'C', $cell));
$this->assertGreaterThan(-1000000.0, $obj->exposeCellVPos(10.0, 5.0, 'B', $cell));
$this->assertGreaterThan(-1000000.0, $obj->exposeCellVPos(10.0, 5.0, 'X', $cell));
$this->assertGreaterThan(-1000000.0, $obj->exposeCellVPos(10.0, 5.0, 'T'));
$this->assertGreaterThan(-1000000.0, $obj->exposeCellHPos(10.0, 5.0, 'R', $cell));
$this->assertGreaterThan(-1000000.0, $obj->exposeCellHPos(10.0, 5.0, 'C', $cell));
$this->assertGreaterThan(-1000000.0, $obj->exposeCellHPos(10.0, 5.0, 'J', $cell));
$this->assertGreaterThan(-1000000.0, $obj->exposeCellHPos(10.0, 5.0, 'L'));
$this->assertGreaterThan(-1000000.0, $obj->exposeCellTextVAlign(20.0, 10.0, 'T', $cell));
$this->assertGreaterThan(-1000000.0, $obj->exposeCellTextVAlign(20.0, 10.0, 'B', $cell));
$this->assertGreaterThan(-1000000.0, $obj->exposeCellTextVAlign(20.0, 10.0, 'L', $cell));
$this->assertGreaterThan(-1000000.0, $obj->exposeCellTextVAlign(20.0, 10.0, 'A', $cell));
$this->assertGreaterThan(-1000000.0, $obj->exposeCellTextVAlign(20.0, 10.0, 'D', $cell));
$this->assertGreaterThan(-1000000.0, $obj->exposeCellTextVAlign(20.0, 0.0, 'C'));
$this->assertGreaterThan(-1000000.0, $obj->exposeCellTextHAlign(30.0, 12.0, 'R', $cell));
$this->assertGreaterThan(-1000000.0, $obj->exposeCellTextHAlign(30.0, 12.0, 'J', $cell));
$this->assertGreaterThan(-1000000.0, $obj->exposeCellTextHAlign(30.0, 12.0, 'L'));
$this->assertGreaterThan(0.0, $obj->exposeCellMaxWidth(0.0));
$this->assertGreaterThan(0.0, $obj->exposeTextMaxWidth(50.0));
$this->assertGreaterThan(-1000000.0, $obj->exposeTextMaxHeight(50.0, 'B', $cell));
$this->assertGreaterThan(-1000000.0, $obj->exposeTextMaxHeight(50.0, 'L', $cell));
$this->assertGreaterThan(-1000000.0, $obj->exposeTextMaxHeight(50.0, 'A', $cell));
$this->assertGreaterThan(-1000000.0, $obj->exposeTextMaxHeight(50.0, 'D', $cell));
$this->assertGreaterThan(-1000000.0, $obj->exposeTextMaxHeight(50.0, 'C', $cell));
}
/** @throws \Throwable */
public function testCellAndTextPositionConversionsAreCallable(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
/** @var TCellDef $cell */
$cell = $this->getObjectProperty($obj, 'defcell');
$cellVerticalPos = $obj->exposeCellVPosFromText(10.0, 20.0, 10.0, 'C', $cell);
$cellHorizontalPos = $obj->exposeCellHPosFromText(10.0, 30.0, 12.0, 'L', $cell);
$textVerticalPos = $obj->exposeTextVPosFromCell(10.0, 20.0, 10.0, 'C', $cell);
$textHorizontalPos = $obj->exposeTextHPosFromCell(10.0, 30.0, 12.0, 'L', $cell);
$this->assertGreaterThan(-1000000.0, $cellVerticalPos);
$this->assertGreaterThan(-1000000.0, $cellHorizontalPos);
$this->assertGreaterThan(-1000000.0, $textVerticalPos);
$this->assertGreaterThan(-1000000.0, $textHorizontalPos);
}
/** @throws \Throwable */
public function testCellAndTextMaxHelpersReturnPositiveValues(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
/** @var TCellDef $cell */
$cell = $this->getObjectProperty($obj, 'defcell');
$cellMax = $obj->exposeCellMaxWidth(0.0, $cell);
$txtW = $obj->exposeTextMaxWidth(50.0, $cell);
$txtH = $obj->exposeTextMaxHeight(50.0, 'T', $cell);
$this->assertGreaterThan(0.0, $cellMax);
$this->assertGreaterThan(0.0, $txtW);
$this->assertGreaterThan(0.0, $txtH);
}
/** @throws \Throwable */
public function testDrawCellReturnsEmptyWhenNoFillOrBorder(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$out = $obj->exposeDrawCell(10.0, 10.0, 20.0, 8.0, ['all' => []]);
$this->assertSame('', $out);
}
/** @throws \Throwable */
public function testDrawCellHandlesFillAndBorderBranches(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$fillAndBorder = $obj->exposeDrawCell(10.0, 10.0, 20.0, 8.0, ['all' => [
'fillColor' => 'gray',
'lineWidth' => 0.2,
]]);
$this->assertNotSame('', $fillAndBorder);
$fillOnly = $obj->exposeDrawCell(10.0, 10.0, 20.0, 8.0, ['all' => ['fillColor' => 'gray']]);
$this->assertNotSame('', $fillOnly);
$perSideBorder = $obj->exposeDrawCell(10.0, 10.0, 20.0, 8.0, [
0 => ['lineWidth' => 0.2],
1 => ['lineWidth' => 0.3],
2 => ['lineWidth' => 0.4],
3 => ['lineWidth' => 0.5],
'all' => ['fillColor' => ''],
]);
$this->assertNotSame('', $perSideBorder);
$fallbackAdjustBorder = $obj->exposeDrawCell(10.0, 10.0, 20.0, 8.0, [
1 => ['lineWidth' => 0.3],
'all' => [],
]);
$this->assertNotSame('', $fallbackAdjustBorder);
}
/** @throws \Throwable */
public function testDrawCellIgnoresZeroWidthSideBorders(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$rightSideOnly = $obj->exposeDrawCell(10.0, 10.0, 20.0, 8.0, [
1 => ['lineWidth' => 0.3],
'all' => ['fillColor' => ''],
]);
$rightWithZeroSides = $obj->exposeDrawCell(10.0, 10.0, 20.0, 8.0, [
0 => ['lineWidth' => 0.0],
1 => ['lineWidth' => 0.3],
2 => ['lineWidth' => 0.0],
3 => ['lineWidth' => 0.0],
'all' => ['fillColor' => ''],
]);
$this->assertNotSame('', $rightSideOnly);
$this->assertSame($rightSideOnly, $rightWithZeroSides);
}
/** @throws \Throwable */
public function testGetOutTextStringReturnsEscapedStringAndBomChangesOutput(): void
{
$obj = $this->getInternalTestObject();
$textWithoutBom = $obj->exposeGetOutTextString('Hello', 1, false);
$textWithBom = $obj->exposeGetOutTextString('Hello', 1, true);
$this->assertNotSame('', $textWithoutBom);
$this->assertNotSame('', $textWithBom);
$this->assertNotSame($textWithoutBom, $textWithBom);
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
/**
* DisplayZoomTest.php
*
* @since 2026-07-17
* @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 Com\Tecnick\Pdf\DisplayZoom;
/**
* DisplayZoom enum test
*
* @since 2026-07-17
* @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
*/
class DisplayZoomTest extends TestUtil
{
public function testCaseBackingValues(): void
{
$this->assertSame('fullpage', DisplayZoom::FullPage->value);
$this->assertSame('fullwidth', DisplayZoom::FullWidth->value);
$this->assertSame('real', DisplayZoom::Real->value);
$this->assertSame('default', DisplayZoom::DefaultZoom->value);
}
public function testFromLooseCanonical(): void
{
$this->assertSame(DisplayZoom::FullPage, DisplayZoom::fromLoose('fullpage'));
$this->assertSame(DisplayZoom::Real, DisplayZoom::fromLoose('real'));
}
public function testFromLoosePassesThroughEnumInstance(): void
{
$this->assertSame(DisplayZoom::Real, DisplayZoom::fromLoose(DisplayZoom::Real));
}
public function testFromLooseRoundTrip(): void
{
foreach (DisplayZoom::cases() as $case) {
$this->assertSame($case, DisplayZoom::fromLoose($case->value));
}
}
public function testFromLooseUnknownFallsBack(): void
{
$this->assertSame(DisplayZoom::DefaultZoom, DisplayZoom::fromLoose('FullPage'));
$this->assertSame(DisplayZoom::DefaultZoom, DisplayZoom::fromLoose('zoom'));
$this->assertSame(DisplayZoom::DefaultZoom, DisplayZoom::fromLoose(''));
}
}
@@ -0,0 +1,79 @@
<?php
/**
* ExternalSignatureEncodingTest.php
*
* @since 2026-07-17
* @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 Com\Tecnick\Pdf\Signature\ExternalSignatureEncoding;
/**
* ExternalSignatureEncoding enum test
*
* @since 2026-07-17
* @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
*/
class ExternalSignatureEncodingTest extends TestUtil
{
public function testCaseBackingValues(): void
{
$this->assertSame('binary', ExternalSignatureEncoding::Binary->value);
$this->assertSame('base64', ExternalSignatureEncoding::Base64->value);
$this->assertSame('hex', ExternalSignatureEncoding::Hex->value);
}
/**
* @throws \Com\Tecnick\Pdf\Exception
*/
public function testFromLooseCanonical(): void
{
$this->assertSame(ExternalSignatureEncoding::Hex, ExternalSignatureEncoding::fromLoose('HEX'));
$this->assertSame(ExternalSignatureEncoding::Base64, ExternalSignatureEncoding::fromLoose(' base64 '));
}
/**
* @throws \Com\Tecnick\Pdf\Exception
*/
public function testFromLoosePassesThroughEnumInstance(): void
{
$this->assertSame(
ExternalSignatureEncoding::Hex,
ExternalSignatureEncoding::fromLoose(ExternalSignatureEncoding::Hex),
);
}
/**
* @throws \Com\Tecnick\Pdf\Exception
*/
public function testFromLooseRoundTrip(): void
{
foreach (ExternalSignatureEncoding::cases() as $case) {
$this->assertSame($case, ExternalSignatureEncoding::fromLoose($case->value));
}
}
/**
* @throws \Com\Tecnick\Pdf\Exception
*/
public function testFromLooseUnknownThrows(): void
{
$this->bcExpectException(\Com\Tecnick\Pdf\Exception::class);
ExternalSignatureEncoding::fromLoose('rot13');
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
/**
* FakeCache.php
*
* @since 2026-06-16
* @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 Com\Tecnick\Pdf\Cache\CacheInterface;
/**
* In-memory CacheInterface test double that records the keys it is asked about.
*/
class FakeCache implements CacheInterface
{
/** @var array<string, mixed> */
public array $store = [];
/** @var list<string> */
public array $getKeys = [];
/** @var list<string> */
public array $setKeys = [];
public function get(string $key): mixed
{
$this->getKeys[] = $key;
return $this->store[$key] ?? null;
}
public function set(string $key, mixed $value): void
{
$this->setKeys[] = $key;
$this->store[$key] = $value;
}
}
@@ -0,0 +1,34 @@
<?php
/**
* FakeSelectiveCache.php
*
* @since 2026-06-16
* @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 Com\Tecnick\Pdf\Cache\CacheInterface;
use Com\Tecnick\Pdf\Cache\SelectiveCacheInterface;
/**
* SelectiveCacheInterface test double with a configurable supported-type list.
*/
class FakeSelectiveCache extends FakeCache implements SelectiveCacheInterface
{
/** @var list<CacheInterface::TYPE_*> */
public array $supported = [];
public function supports(string $type): bool
{
return \in_array($type, $this->supported, true);
}
}
+213
View File
@@ -0,0 +1,213 @@
<?php
/**
* FontCloneStyleTest.php
*
* @since 2026-07-12
* @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;
/**
* Regression tests for the font style cloning.
*
* Com\Tecnick\Pdf\Font\Stack::cloneFont() used to forward the definition file of the source font
* to the requested style, so a style that was not already loaded was silently rendered with the
* glyphs and the metrics of the source style.
* See: https://github.com/tecnickcom/tc-lib-pdf-font/issues/19
*
* @since 2026-07-12
* @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
*
* @phpstan-import-type TFontData from \Com\Tecnick\Pdf\Font\Load
*/
class FontCloneStyleTest extends TestUtil
{
/** @throws \Throwable */
protected function getTestObject(): \Com\Tecnick\Pdf\Tcpdf
{
self::setUpFontsPath();
return new \Com\Tecnick\Pdf\Tcpdf(unit: 'mm', isunicode: true);
}
/**
* Returns the raw buffer data of the given font key.
*
* @return TFontData
*
* @throws \Throwable
*/
private function getFontData(\Com\Tecnick\Pdf\Tcpdf $obj, string $key): array
{
$this->assertArrayHasKey($key, $obj->font->getFonts(), 'The font ' . $key . ' has not been loaded');
return $obj->font->getFont($key);
}
/** @throws \Throwable */
public function testCloneFontLoadsTheDefinitionFileOfTheRequestedStyle(): void
{
$obj = $this->getTestObject();
$obj->addPage();
$obj->font->insert($obj->pon, 'freesans', '', 12);
$bold = $obj->font->cloneFont($obj->pon, null, 'B', 12);
$this->assertSame('freesansB', $bold['key']);
$this->assertSame('B', $bold['style']);
$data = $this->getFontData($obj, 'freesansB');
$this->assertSame('FreeSansBold', $data['name']);
$this->assertStringEndsWith('freesansb.json', $data['ifile']);
$this->assertFalse($data['fakestyle'], 'The real bold definition file must be used');
}
/** @throws \Throwable */
public function testCloneFontLoadsTheDefinitionFileOfCombinedStyles(): void
{
$obj = $this->getTestObject();
$obj->addPage();
$obj->font->insert($obj->pon, 'freesans', '', 12);
$italic = $obj->font->cloneFont($obj->pon, null, 'I', 12);
$this->assertSame('freesansI', $italic['key']);
$this->assertSame('FreeSansOblique', $this->getFontData($obj, 'freesansI')['name']);
$bolditalic = $obj->font->cloneFont($obj->pon, null, 'BI', 12);
$this->assertSame('freesansBI', $bolditalic['key']);
$this->assertSame('FreeSansBoldOblique', $this->getFontData($obj, 'freesansBI')['name']);
}
/**
* Cloning a style that was not loaded before must return the same font as cloning a style
* that was already loaded: this is the inconsistency reported in the upstream issue.
*
* @throws \Throwable
*/
public function testCloneFontIsConsistentWhenTheStyleIsAlreadyLoaded(): void
{
$preloaded = $this->getTestObject();
$preloaded->addPage();
$preloaded->font->insert($preloaded->pon, 'freesans', '', 12);
$preloaded->font->insert($preloaded->pon, 'freesans', 'B', 12);
$preloaded->font->insert($preloaded->pon, 'freesans', '', 12);
$expected = $preloaded->font->cloneFont($preloaded->pon, null, 'B', 12);
$cloned = $this->getTestObject();
$cloned->addPage();
$cloned->font->insert($cloned->pon, 'freesans', '', 12);
$actual = $cloned->font->cloneFont($cloned->pon, null, 'B', 12);
$this->assertSame($expected['key'], $actual['key']);
$this->assertSame($expected['cw'], $actual['cw'], 'The cloned font must have the bold glyph widths');
$this->assertSame($expected['height'], $actual['height']);
}
/**
* The bold glyphs of FreeSans are wider than the regular ones: a wrongly loaded bold font
* would return the regular widths.
*
* @throws \Throwable
*/
public function testCloneFontUsesTheMetricsOfTheRequestedStyle(): void
{
$obj = $this->getTestObject();
$obj->addPage();
$regular = $obj->font->insert($obj->pon, 'freesans', '', 12);
$regularwidth = $obj->font->getCharWidth(0x48); // 'H'
$bold = $obj->font->cloneFont($obj->pon, null, 'B', 12);
$boldwidth = $obj->font->getCharWidth(0x48); // 'H'
$this->assertNotSame($regular['key'], $bold['key']);
$this->assertGreaterThan($regularwidth, $boldwidth, 'The bold glyph must be wider than the regular one');
}
/**
* The source font is loaded with an explicit definition file (as tc-lib-pdf does for the
* default font): the style variant must still be resolved.
*
* @throws \Throwable
*/
public function testCloneFontResolvesTheStyleOfAFontLoadedWithAnExplicitFile(): void
{
$obj = $this->getTestObject();
$obj->addPage();
$ifile = (string) \realpath(__DIR__ . '/../vendor/tecnickcom/tc-lib-pdf-font/target/fonts/core/helvetica.json');
$obj->font->insert($obj->pon, 'helvetica', '', 10, null, null, $ifile);
$bold = $obj->font->cloneFont($obj->pon, null, 'B', 10);
$this->assertSame('helveticaB', $bold['key']);
$data = $this->getFontData($obj, 'helveticaB');
$this->assertSame('Helvetica-Bold', $data['name']);
$this->assertStringEndsWith('helveticab.json', $data['ifile']);
}
/**
* When no definition file exists for the requested style, the artificial style must be used.
*
* @throws \Throwable
*/
public function testCloneFontFallsBackToTheArtificialStyle(): void
{
$obj = $this->getTestObject();
$obj->addPage();
$obj->font->insert($obj->pon, 'dejavumathtexgyre', '', 12);
$bold = $obj->font->cloneFont($obj->pon, null, 'B', 12);
$this->assertSame('dejavumathtexgyreB', $bold['key']);
$data = $this->getFontData($obj, 'dejavumathtexgyreB');
$this->assertTrue($data['fakestyle'], 'The artificial bold style must be enabled');
$this->assertTrue($data['mode']['bold']);
}
/** @throws \Throwable */
public function testCloneFontReturnsToTheRegularStyle(): void
{
$obj = $this->getTestObject();
$obj->addPage();
$obj->font->insert($obj->pon, 'freesans', 'B', 12);
$regular = $obj->font->cloneFont($obj->pon, null, '', 12);
$this->assertSame('freesans', $regular['key']);
$this->assertSame('FreeSans', $this->getFontData($obj, 'freesans')['name']);
}
/**
* Tcpdf::addTOC() clones the current font to render the top level bookmarks in bold.
*
* @throws \Throwable
*/
public function testAddTOCRendersTopLevelBookmarksWithTheRealBoldFont(): void
{
$obj = $this->getTestObject();
$page = $obj->addPage();
if (!isset($page['pid']) || !\is_int($page['pid'])) {
$this->fail('Unexpected addPage() return shape.');
}
$obj->font->insert($obj->pon, 'freesans', '', 12);
$obj->setBookmark('Chapter One', '', 0, $page['pid']);
$obj->addTOC($page['pid']);
$data = $this->getFontData($obj, 'freesansB');
$this->assertSame('FreeSansBold', $data['name']);
$this->assertStringEndsWith('freesansb.json', $data['ifile']);
}
}
@@ -0,0 +1,219 @@
<?php
/**
* HTMLRealPageCorpusTest.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;
class HTMLRealPageCorpusTest extends TestUtil
{
private const CORPUS_FILE = __DIR__ . '/fixtures/html/real_pages/corpus.json';
/**
* @param array<array-key, mixed> $value
* @return array<string, mixed>
*/
private static function toStringKeyMap(array $value): array
{
$typed = [];
foreach (\array_keys($value) as $key) {
if (!\is_string($key)) {
continue;
}
$typed[$key] = $value[$key] ?? null;
}
return $typed;
}
private static function scalarToString(mixed $value): string
{
return \is_scalar($value) ? (string) $value : '';
}
/** @return list<string> */
private static function toStringList(mixed $value): array
{
if (!\is_array($value)) {
return [];
}
$list = [];
$items = \array_filter($value, static fn(mixed $item): bool => \is_scalar($item));
return \array_values(\array_map(static fn(string|int|float|bool $item): string => (string) $item, $items));
}
/** @return array<int, array<string, mixed>> */
private static function toPageList(mixed $value): array
{
if (!\is_array($value)) {
return [];
}
/** @var array<int, array<string, mixed>> $pages */
$pages = [];
/** @var array<int, array<array-key, mixed>> $pageRows */
$pageRows = \array_values(\array_filter($value, static fn(mixed $page): bool => \is_array($page)));
foreach ($pageRows as $page) {
$pages[] = self::toStringKeyMap($page);
}
return $pages;
}
public static function setUpBeforeClass(): void
{
self::setUpFontsPath();
}
/**
* @return array{
* version: int,
* failure_tags: array<int, string>,
* severity_levels: array<int, string>,
* pages: array<int, array<string, mixed>>
* }
* @throws \Throwable
*/
private static function loadCorpus(): array
{
$raw = \file_get_contents(self::CORPUS_FILE);
if ($raw === false) {
throw new \RuntimeException('Unable to read corpus manifest: ' . self::CORPUS_FILE);
}
/** @var array<string, mixed>|null $data */
$data = \json_decode($raw, true);
if (!\is_array($data)) {
throw new \RuntimeException('Invalid JSON in corpus manifest: ' . self::CORPUS_FILE);
}
/** @var array{
* version: int,
* failure_tags: array<int, string>,
* severity_levels: array<int, string>,
* pages: array<int, array<string, mixed>>
* } $typed
*/
return [
'version' => \is_int($data['version'] ?? null) ? $data['version'] : 0,
'failure_tags' => self::toStringList($data['failure_tags'] ?? []),
'severity_levels' => self::toStringList($data['severity_levels'] ?? []),
'pages' => self::toPageList($data['pages'] ?? []),
];
}
/** @throws \Throwable */
public function testRealPageCorpusManifestHasRequiredArchetypesAndSeverityTaggedFailures(): void
{
$corpus = self::loadCorpus();
$this->assertSame(1, $corpus['version']);
$expectedArchetypes = [
'long-form article',
'invoice and statement',
'product/documentation page',
'admin/report dashboard with tables',
'form-heavy page',
];
$this->assertNotEmpty($corpus['failure_tags']);
$this->assertNotEmpty($corpus['severity_levels']);
$this->assertNotEmpty($corpus['pages']);
$archetypes = [];
$totalFailures = 0;
foreach ($corpus['pages'] as $page) {
$pageId = self::scalarToString($page['id'] ?? '');
$fixture = self::scalarToString($page['fixture'] ?? '');
$archetype = self::scalarToString($page['archetype'] ?? '');
$this->assertNotSame('', $pageId);
$this->assertNotSame('', $fixture);
$this->assertNotSame('', $archetype);
$fixturePath = __DIR__ . '/fixtures/html/real_pages/' . $fixture;
$this->assertFileExists($fixturePath, 'Missing corpus fixture for page id: ' . $pageId);
$archetypes[$archetype] = true;
$failures = [];
if (isset($page['failures']) && \is_array($page['failures'])) {
$failures = $page['failures'];
}
/** @var array<int, array<array-key, mixed>> $failureRows */
$failureRows = \array_values(\array_filter($failures, static fn(mixed $failure): bool => \is_array(
$failure,
)));
foreach ($failureRows as $failure) {
$typedFailure = self::toStringKeyMap($failure);
$tag = self::scalarToString($typedFailure['tag'] ?? '');
$severity = self::scalarToString($typedFailure['severity'] ?? '');
$this->assertContains($tag, $corpus['failure_tags']);
$this->assertContains($severity, $corpus['severity_levels']);
$totalFailures++;
}
}
\sort($expectedArchetypes);
$actualArchetypes = \array_keys($archetypes);
\sort($actualArchetypes);
$this->assertSame($expectedArchetypes, $actualArchetypes);
$this->assertGreaterThanOrEqual(0, $totalFailures, 'Corpus should track severity-tagged failures.');
}
/**
* @return array<string, array{0: string, 1: string}>
* @throws \Throwable
*/
public static function corpusPageProvider(): array
{
$corpus = self::loadCorpus();
$dataset = [];
foreach ($corpus['pages'] as $page) {
$pageId = self::scalarToString($page['id'] ?? 'unknown');
$fixture = self::scalarToString($page['fixture'] ?? '');
$dataset[$pageId] = [$pageId, $fixture];
}
return $dataset;
}
/** @throws \Throwable */
#[DataProvider('corpusPageProvider')]
public function testRealPageCorpusFixturesRenderWithoutFatal(string $pageId, string $fixture): void
{
$obj = new \Com\Tecnick\Pdf\Tcpdf();
$this->initFontAndPage($obj);
$fixturePath = __DIR__ . '/fixtures/html/real_pages/' . $fixture;
$html = \file_get_contents($fixturePath);
$this->assertNotFalse($html, 'Unable to read fixture for page id: ' . $pageId);
$obj->addHTMLCell($html, 10, 10, 190, 0);
$pdf = $obj->getOutPDFString();
$this->assertNotSame('', $pdf, 'Expected rendered PDF output for page id: ' . $pageId);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,906 @@
<?php
/**
* ImporterTest.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\Import;
use Com\Tecnick\File\File as ObjFile;
use Com\Tecnick\Pdf\Import\ImportCorruptedSourceException;
use Com\Tecnick\Pdf\Import\Importer;
use Com\Tecnick\Pdf\Import\ImportPageOutOfRangeException;
use Com\Tecnick\Pdf\Import\ImportSourceNotFoundException;
use Com\Tecnick\Pdf\Import\ImportUnsupportedFeatureException;
use Com\Tecnick\Pdf\Import\ObjectMap;
use Com\Tecnick\Pdf\Import\PageTemplate;
use Com\Tecnick\Pdf\Import\SourceDocument;
use PHPUnit\Framework\TestCase;
class ImporterTest extends TestCase
{
private function getObjectProperty(object $obj, string $name): mixed
{
$ref = new \ReflectionClass($obj);
while ($ref !== false) {
if ($ref->hasProperty($name)) {
return $ref->getProperty($name)->getValue($obj);
}
$ref = $ref->getParentClass();
}
$this->fail('Property not found: ' . $name);
}
private function setObjectProperty(object $obj, string $name, mixed $value): void
{
$ref = new \ReflectionClass($obj);
while ($ref !== false) {
if ($ref->hasProperty($name)) {
$ref->getProperty($name)->setValue($obj, $value);
return;
}
$ref = $ref->getParentClass();
}
$this->fail('Property not found: ' . $name);
}
private function invokeImporterMethod(Importer $importer, string $method, mixed ...$args): mixed
{
$ref = new \ReflectionClass($importer);
return $ref->getMethod($method)->invokeArgs($importer, $args);
}
private function fixtureData(): string
{
$path = __DIR__ . '/../fixtures/simple_import.pdf';
$data = file_get_contents($path);
$this->assertNotFalse($data);
return $data;
}
private function multipageFixtureData(): string
{
$path = __DIR__ . '/../fixtures/multipage_import.pdf';
$data = file_get_contents($path);
$this->assertNotFalse($data);
return $data;
}
private function encryptedFixtureData(): string
{
$path = __DIR__ . '/../fixtures/encrypted_import_stub.pdf';
$data = file_get_contents($path);
$this->assertNotFalse($data);
return $data;
}
private function rotatedFixtureData(): string
{
$path = __DIR__ . '/../fixtures/rotated_import.pdf';
$data = file_get_contents($path);
$this->assertNotFalse($data);
return $data;
}
private function makeObjFile(): ObjFile
{
return new ObjFile(allowedPaths: ['*']);
}
private function makeImporter(): Importer
{
$xobjects = [];
$pon = 0;
return new Importer($xobjects, $pon, $this->makeObjFile());
}
/**
* Build a minimal classic-xref donor PDF with a configurable declared
* /Count and a configurable number of real pages, to verify that the
* declared value never drives page counting or import loops.
* Only small values must ever be used here.
*/
private function buildDonorPdf(?int $declaredCount, int $realPages): string
{
$objects = [];
$objects[1] = "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n";
$kids = [];
for ($idx = 0; $idx < $realPages; ++$idx) {
$kids[] = (3 + $idx) . ' 0 R';
}
$countEntry = $declaredCount === null ? '' : ' /Count ' . $declaredCount;
$objects[2] = "2 0 obj\n<< /Type /Pages /Kids [" . implode(' ', $kids) . ']' . $countEntry . " >>\nendobj\n";
for ($idx = 0; $idx < $realPages; ++$idx) {
$num = 3 + $idx;
$objects[$num] =
$num . " 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> >>\nendobj\n";
}
$pdf = "%PDF-1.7\n";
$offsets = [];
foreach ($objects as $num => $text) {
$offsets[$num] = strlen($pdf);
$pdf .= $text;
}
$xrefStart = strlen($pdf);
$size = count($objects) + 1;
$pdf .= "xref\n0 {$size}\n0000000000 65535 f \n";
foreach (array_keys($objects) as $num) {
$pdf .= sprintf("%010d 00000 n \n", $offsets[$num]);
}
return $pdf . "trailer\n<< /Size {$size} /Root 1 0 R >>\nstartxref\n{$xrefStart}\n%%EOF";
}
/** @throws \Throwable */
public function testSetImportSourceDataReturnsSha256Id(): void
{
$data = $this->fixtureData();
$importer = $this->makeImporter();
$srcId = $importer->setImportSourceData($data);
$this->assertSame(hash('sha256', $data), $srcId);
}
/** @throws \Throwable */
public function testSetImportSourceFileReturnsSourceId(): void
{
$path = __DIR__ . '/../fixtures/simple_import.pdf';
$importer = $this->makeImporter();
$srcId = $importer->setImportSourceFile($path);
$this->assertNotEmpty($srcId);
}
/** @throws \Throwable */
public function testSetImportSourceFileThrowsForMissingFile(): void
{
$importer = $this->makeImporter();
$this->expectException(ImportSourceNotFoundException::class);
$importer->setImportSourceFile('/nonexistent/path/to/file.pdf');
}
/** @throws \Throwable */
public function testSetImportSourceDataIsIdempotent(): void
{
$data = $this->fixtureData();
$importer = $this->makeImporter();
$id1 = $importer->setImportSourceData($data);
$id2 = $importer->setImportSourceData($data);
$this->assertSame($id1, $id2);
}
/** @throws \Throwable */
public function testSetImportSourceDataAcceptsPasswordOptionForUnencryptedPdf(): void
{
$data = $this->fixtureData();
$importer = $this->makeImporter();
$srcId = $importer->setImportSourceData($data, ['password' => 'secret']);
$this->assertNotEmpty($srcId);
}
/** @throws \Throwable */
public function testSetImportSourceDataThrowsForEncryptedPdf(): void
{
$data = $this->encryptedFixtureData();
$importer = $this->makeImporter();
$this->expectException(ImportUnsupportedFeatureException::class);
$this->expectExceptionMessageMatches('/' . preg_quote('encrypted PDF', '/') . '/');
$importer->setImportSourceData($data);
}
/** @throws \Throwable */
public function testSetImportSourceDataWithPasswordStillThrowsForEncryptedPdf(): void
{
$data = $this->encryptedFixtureData();
$importer = $this->makeImporter();
$this->expectException(ImportUnsupportedFeatureException::class);
$this->expectExceptionMessageMatches('/' . preg_quote('password-based import is not supported', '/') . '/');
$importer->setImportSourceData($data, ['password' => 'secret']);
}
/** @throws \Throwable */
public function testGetSourcePageCountReturnsOne(): void
{
$data = $this->fixtureData();
$importer = $this->makeImporter();
$srcId = $importer->setImportSourceData($data);
$this->assertSame(1, $importer->getSourcePageCount($srcId));
}
/** @throws \Throwable */
public function testGetSourcePageCountThrowsForUnknownSource(): void
{
$importer = $this->makeImporter();
$this->expectException(ImportSourceNotFoundException::class);
$importer->getSourcePageCount('invalid-source-id');
}
/** @throws \Throwable */
public function testGetSourcePageCountIgnoresForgedOversizedCount(): void
{
// The donor declares /Count 50 but only one page is reachable
// through /Kids; the declared value must not be trusted.
$importer = $this->makeImporter();
$srcId = $importer->setImportSourceData($this->buildDonorPdf(50, 1));
$this->assertSame(1, $importer->getSourcePageCount($srcId));
}
/** @throws \Throwable */
public function testGetSourcePageCountIgnoresUndersizedCount(): void
{
$importer = $this->makeImporter();
$srcId = $importer->setImportSourceData($this->buildDonorPdf(1, 2));
$this->assertSame(2, $importer->getSourcePageCount($srcId));
}
/** @throws \Throwable */
public function testGetSourcePageCountWorksWithoutDeclaredCount(): void
{
$importer = $this->makeImporter();
$srcId = $importer->setImportSourceData($this->buildDonorPdf(null, 2));
$this->assertSame(2, $importer->getSourcePageCount($srcId));
}
/** @throws \Throwable */
public function testImportPageReturnsPageTemplate(): void
{
$data = $this->fixtureData();
$xobjects = [];
$pon = 0;
$importer = new Importer($xobjects, $pon, $this->makeObjFile());
$srcId = $importer->setImportSourceData($data);
$tpl = $importer->importPage($srcId, 1);
$this->assertInstanceOf(PageTemplate::class, $tpl);
}
/** @throws \Throwable */
public function testImportPageRegistersXobject(): void
{
$data = $this->fixtureData();
$xobjects = [];
$pon = 0;
$importer = new Importer($xobjects, $pon, $this->makeObjFile());
$srcId = $importer->setImportSourceData($data);
$tpl = $importer->importPage($srcId, 1);
$this->assertArrayHasKey($tpl->getXobjId(), $xobjects);
}
/** @throws \Throwable */
public function testImportPageRebuildsMissingObjectMapForKnownSource(): void
{
$data = $this->fixtureData();
$xobjects = [];
$pon = 0;
$importer = new Importer($xobjects, $pon, $this->makeObjFile());
$srcId = $importer->setImportSourceData($data);
$this->setObjectProperty($importer, 'objectMaps', []);
$tpl = $importer->importPage($srcId, 1, ['cache' => false]);
/** @var array<string, ObjectMap> $maps */
$maps = $this->getObjectProperty($importer, 'objectMaps');
$this->assertInstanceOf(PageTemplate::class, $tpl);
$this->assertIsArray($maps);
$this->assertArrayHasKey($srcId, $maps);
$this->assertInstanceOf(ObjectMap::class, $maps[$srcId] ?? null);
}
/** @throws \Throwable */
public function testImportPageXobjectHasCorrectObjectNumber(): void
{
$data = $this->fixtureData();
$xobjects = [];
$pon = 0;
$importer = new Importer($xobjects, $pon, $this->makeObjFile());
$srcId = $importer->setImportSourceData($data);
$tpl = $importer->importPage($srcId, 1);
// The xobject's object number must be a positive integer allocated from pon.
$xobjId = $tpl->getXobjId();
$xobj = [];
if (isset($xobjects[$xobjId]) && \is_array($xobjects[$xobjId])) {
$xobj = $xobjects[$xobjId];
}
$this->assertIsArray($xobj);
$this->assertArrayHasKey('n', $xobj);
$this->assertGreaterThan(0, $xobj['n'] ?? 0);
}
/** @throws \Throwable */
public function testImportPageSwapsDimensionsForQuarterTurnRotation(): void
{
$data = $this->rotatedFixtureData();
$xobjects = [];
$pon = 0;
$importer = new Importer($xobjects, $pon, $this->makeObjFile());
$srcId = $importer->setImportSourceData($data);
$tpl = $importer->importPage($srcId, 1);
$this->assertSame(90, $tpl->getRotation());
$this->assertSame(500.0, $tpl->getWidth());
$this->assertSame(300.0, $tpl->getHeight());
}
/** @throws \Throwable */
public function testImportPageCanIgnoreRotationWhenRequested(): void
{
$data = $this->rotatedFixtureData();
$xobjects = [];
$pon = 0;
$importer = new Importer($xobjects, $pon, $this->makeObjFile());
$srcId = $importer->setImportSourceData($data);
$tpl = $importer->importPage($srcId, 1, ['respectRotation' => false, 'cache' => false]);
$this->assertSame(0, $tpl->getRotation());
$this->assertSame(300.0, $tpl->getWidth());
$this->assertSame(500.0, $tpl->getHeight());
}
/** @throws \Throwable */
public function testImportPageTemplateHasExpectedDimensions(): void
{
$data = $this->fixtureData();
$xobjects = [];
$pon = 0;
$importer = new Importer($xobjects, $pon, $this->makeObjFile());
$srcId = $importer->setImportSourceData($data);
$tpl = $importer->importPage($srcId, 1);
// fixture mediabox is 612x792; cropbox falls back to mediabox
$this->assertEqualsWithDelta(612.0, $tpl->getWidth(), 0.01);
$this->assertEqualsWithDelta(792.0, $tpl->getHeight(), 0.01);
}
/** @throws \Throwable */
public function testImportPageCacheReturnsIdenticalTemplate(): void
{
$data = $this->fixtureData();
$xobjects = [];
$pon = 0;
$importer = new Importer($xobjects, $pon, $this->makeObjFile());
$srcId = $importer->setImportSourceData($data);
$tpl1 = $importer->importPage($srcId, 1);
$tpl2 = $importer->importPage($srcId, 1);
$this->assertSame($tpl1->getXobjId(), $tpl2->getXobjId());
}
/** @throws \Throwable */
public function testImportPageThrowsForOutOfRangePage(): void
{
$data = $this->fixtureData();
$importer = $this->makeImporter();
$srcId = $importer->setImportSourceData($data);
$this->expectException(ImportPageOutOfRangeException::class);
$importer->importPage($srcId, 999);
}
/** @throws \Throwable */
public function testImportPageThrowsForUnknownSourceId(): void
{
$importer = $this->makeImporter();
$this->expectException(ImportSourceNotFoundException::class);
$importer->importPage('unknown-id', 1);
}
/** @throws \Throwable */
public function testGetOutImportedObjectsReturnsNonEmptyString(): void
{
$data = $this->fixtureData();
$xobjects = [];
$pon = 0;
$importer = new Importer($xobjects, $pon, $this->makeObjFile());
$srcId = $importer->setImportSourceData($data);
$importer->importPage($srcId, 1);
$out = $importer->getOutImportedObjects();
$this->assertNotEmpty($out);
$this->assertStringContainsString(' 0 obj', $out);
$this->assertStringContainsString('endobj', $out);
}
/** @throws \Throwable */
public function testGetOutImportedObjectsClearsQueue(): void
{
$data = $this->fixtureData();
$xobjects = [];
$pon = 0;
$importer = new Importer($xobjects, $pon, $this->makeObjFile());
$srcId = $importer->setImportSourceData($data);
$importer->importPage($srcId, 1);
$importer->getOutImportedObjects();
$this->assertSame('', $importer->getOutImportedObjects());
}
/** @throws \Throwable */
public function testCleanUpReleasesState(): void
{
$data = $this->fixtureData();
$importer = $this->makeImporter();
$srcId = $importer->setImportSourceData($data);
$importer->cleanUp();
$this->expectException(ImportSourceNotFoundException::class);
$importer->getSourcePageCount($srcId);
}
/** @throws \Throwable */
public function testCleanUpClearsPageIndexCache(): void
{
$data = $this->fixtureData();
$importer = $this->makeImporter();
$srcId = $importer->setImportSourceData($data);
$this->assertSame(1, $importer->getSourcePageCount($srcId));
$importer->cleanUp();
$this->assertSame([], $this->getObjectProperty($importer, 'pageIndexes'));
}
public function testSelectBoxFallsBackToMediaBoxWhenRequestedBoxIsMissing(): void
{
$importer = $this->makeImporter();
/** @var array{0: float, 1: float, 2: float, 3: float} $box */
$box = $this->invokeImporterMethod(
$importer,
'selectBox',
[
'mediaBox' => [10, 20, 210, 420],
],
'BleedBox',
);
$this->assertSame([10.0, 20.0, 210.0, 420.0], $box);
}
public function testSelectBoxReturnsZeroBoxForInvalidCoordinates(): void
{
$importer = $this->makeImporter();
/** @var array{0: float, 1: float, 2: float, 3: float} $box */
$box = $this->invokeImporterMethod(
$importer,
'selectBox',
[
'cropBox' => [0, 1, 2, 'bad'],
],
'CropBox',
);
$this->assertSame([0.0, 0.0, 0.0, 0.0], $box);
}
public function testRotationMatrixSupportsHalfAndThreeQuarterTurns(): void
{
$importer = $this->makeImporter();
/** @var array<int, float> $halfTurn */
$halfTurn = $this->invokeImporterMethod($importer, 'rotationMatrix', 180, 200.0, 400.0);
/** @var array<int, float> $threeQuarterTurn */
$threeQuarterTurn = $this->invokeImporterMethod($importer, 'rotationMatrix', 270, 200.0, 400.0);
/** @var array<int, float> $negativeQuarterTurn */
$negativeQuarterTurn = $this->invokeImporterMethod($importer, 'rotationMatrix', -90, 200.0, 400.0);
$this->assertSame([-1.0, 0.0, 0.0, -1.0, 200.0, 400.0], $halfTurn);
$this->assertSame([0.0, 1.0, -1.0, 0.0, 400.0, 0.0], $threeQuarterTurn);
$this->assertSame($threeQuarterTurn, $negativeQuarterTurn);
}
/** @throws \Throwable */
public function testGetSourcePageCountThrowsWhenRootDictionaryHasNoPagesEntry(): void
{
$importer = $this->makeImporter();
$sourceId = 'stub-source';
$src = $this->createStub(SourceDocument::class);
$src->method('getTrailer')->willReturn(['root' => '1 0 R']);
$src->method('getObject')->willReturnCallback(static fn(string $ref): array => match ($ref) {
'1_0' => [[
'<<',
[
['/', 'Type'],
['/', 'Catalog'],
],
]],
default => [],
});
$this->setObjectProperty($importer, 'sources', [$sourceId => $src]);
$this->expectException(ImportCorruptedSourceException::class);
$importer->getSourcePageCount($sourceId);
}
/** @throws \Throwable */
public function testGetSourcePageCountReturnsZeroForEmptyPageTree(): void
{
$importer = $this->makeImporter();
$sourceId = 'stub-source';
$src = $this->createStub(SourceDocument::class);
$src->method('getTrailer')->willReturn(['root' => '1 0 R']);
$src->method('getObject')->willReturnCallback(static fn(string $ref): array => match ($ref) {
'1_0' => [[
'<<',
[
['/', 'Pages'],
['objref', '2 0 R'],
],
]],
'2_0' => [[
'<<',
[
['/', 'Type'],
['/', 'Pages'],
['/', 'Kids'],
['[', []],
],
]],
default => [],
});
$this->setObjectProperty($importer, 'sources', [$sourceId => $src]);
$this->assertSame(0, $importer->getSourcePageCount($sourceId));
}
/** @throws \Throwable */
public function testGetSourcePageCountThrowsWhenPagesNodeHasNoKids(): void
{
$importer = $this->makeImporter();
$sourceId = 'stub-source';
$src = $this->createStub(SourceDocument::class);
$src->method('getTrailer')->willReturn(['root' => '1 0 R']);
$src->method('getObject')->willReturnCallback(static fn(string $ref): array => match ($ref) {
'1_0' => [[
'<<',
[
['/', 'Pages'],
['objref', '2 0 R'],
],
]],
'2_0' => [[
'<<',
[
['/', 'Type'],
['/', 'Pages'],
],
]],
default => [],
});
$this->setObjectProperty($importer, 'sources', [$sourceId => $src]);
$this->expectException(ImportCorruptedSourceException::class);
$this->expectExceptionMessageMatches('/' . preg_quote('/Kids', '/') . '/');
$importer->getSourcePageCount($sourceId);
}
/** @throws \Throwable */
public function testGetSourcePageCountIsCachedPerSource(): void
{
$importer = $this->makeImporter();
$sourceId = 'stub-source';
$src = $this->createStub(SourceDocument::class);
$calls = 0;
$src->method('getTrailer')->willReturn(['root' => '1 0 R']);
$src->method('getObject')->willReturnCallback(static function (string $ref) use (&$calls): array {
++$calls;
return match ($ref) {
'1_0' => [[
'<<',
[
['/', 'Pages'],
['objref', '2 0 R'],
],
]],
'2_0' => [[
'<<',
[
['/', 'Type'],
['/', 'Pages'],
['/', 'Kids'],
[
'[',
[
['objref', '3 0 R'],
],
],
],
]],
'3_0' => [[
'<<',
[
['/', 'Type'],
['/', 'Page'],
],
]],
default => [],
};
});
$this->setObjectProperty($importer, 'sources', [$sourceId => $src]);
$this->assertSame(1, $importer->getSourcePageCount($sourceId));
$callsAfterFirst = $calls;
$this->assertGreaterThan(0, $callsAfterFirst);
$this->assertSame(1, $importer->getSourcePageCount($sourceId));
$this->assertSame($callsAfterFirst, $calls);
}
// -------------------------------------------------------------------------
// importPages
// -------------------------------------------------------------------------
/** @throws \Throwable */
public function testImportPagesWithNullRangeImportsAllPages(): void
{
$data = $this->fixtureData();
$importer = $this->makeImporter();
$srcId = $importer->setImportSourceData($data);
$templates = $importer->importPages($srcId);
// Fixture has one page.
$this->assertCount(1, $templates);
assert(isset($templates[0]), "\$templates[0] must be set");
$this->assertInstanceOf(PageTemplate::class, $templates[0]);
}
/** @throws \Throwable */
public function testImportPagesNullRangeIgnoresForgedOversizedCount(): void
{
// With a forged /Count the null range must import only the pages
// actually reachable through /Kids, without materializing any
// /Count-sized structure.
$importer = $this->makeImporter();
$srcId = $importer->setImportSourceData($this->buildDonorPdf(50, 1));
$templates = $importer->importPages($srcId, null);
$this->assertCount(1, $templates);
assert(isset($templates[0]), "\$templates[0] must be set");
$this->assertInstanceOf(PageTemplate::class, $templates[0]);
}
/** @throws \Throwable */
public function testImportPagesRangeBeyondReachablePagesThrows(): void
{
// The bounds check must use the verified page count, not the
// forged declared /Count.
$importer = $this->makeImporter();
$srcId = $importer->setImportSourceData($this->buildDonorPdf(50, 1));
$this->expectException(ImportPageOutOfRangeException::class);
$this->expectExceptionMessageMatches('/' . preg_quote('out of range [1,1]', '/') . '/');
$importer->importPages($srcId, [2]);
}
/** @throws \Throwable */
public function testImportPagesWithExplicitRange(): void
{
$data = $this->fixtureData();
$importer = $this->makeImporter();
$srcId = $importer->setImportSourceData($data);
$templates = $importer->importPages($srcId, [1]);
$this->assertCount(1, $templates);
assert(isset($templates[0]), "\$templates[0] must be set");
$this->assertInstanceOf(PageTemplate::class, $templates[0]);
}
/** @throws \Throwable */
public function testImportPagesMatchesImportPageResult(): void
{
$data = $this->fixtureData();
$xobjects = [];
$pon = 0;
$importer = new Importer($xobjects, $pon, $this->makeObjFile());
$srcId = $importer->setImportSourceData($data);
$single = $importer->importPage($srcId, 1);
$batch = $importer->importPages($srcId, [1]);
assert(isset($batch[0]), "\$batch[0] must be set");
// Same page imported again (cache hit) — must return the exact same template.
$this->assertSame($single->getXobjId(), $batch[0]->getXobjId());
}
/**
* Counting the pages and importing all of them must walk the page tree
* exactly once per source: the flattened page index built on first use is
* reused for every subsequent page resolution.
*
* @throws \Throwable
*/
public function testImportPagesWalksPageTreeOnlyOnce(): void
{
$importer = $this->makeImporter();
$src = new class($this->buildDonorPdf(null, 2)) extends SourceDocument {
/** @var array<string, int> */
public array $fetches = [];
public function getObject(string $ref): array
{
$this->fetches[$ref] = ($this->fetches[$ref] ?? 0) + 1;
return parent::getObject($ref);
}
};
$sourceId = 'counting-source';
$this->setObjectProperty($importer, 'sources', [$sourceId => $src]);
$this->assertSame(2, $importer->getSourcePageCount($sourceId));
$templates = $importer->importPages($sourceId);
$this->assertCount(2, $templates);
// Catalog, /Pages node, and each page leaf fetched exactly once for
// the count plus the whole batch import.
$this->assertSame(1, $src->fetches['1_0'] ?? 0);
$this->assertSame(1, $src->fetches['2_0'] ?? 0);
$this->assertSame(1, $src->fetches['3_0'] ?? 0);
$this->assertSame(1, $src->fetches['4_0'] ?? 0);
}
/** @throws \Throwable */
public function testImportPagesThrowsForUnknownSource(): void
{
$importer = $this->makeImporter();
$this->expectException(ImportSourceNotFoundException::class);
$importer->importPages('unknown-id');
}
/** @throws \Throwable */
public function testImportPagesThrowsForOutOfRangePage(): void
{
$data = $this->fixtureData();
$importer = $this->makeImporter();
$srcId = $importer->setImportSourceData($data);
$this->expectException(ImportPageOutOfRangeException::class);
$importer->importPages($srcId, [1, 999]);
}
// -------------------------------------------------------------------------
// Dedup: repeated import without cache must not inflate pon
// -------------------------------------------------------------------------
/** @throws \Throwable */
public function testRepeatedImportNoCacheUsesSharedObjectMap(): void
{
$data = $this->fixtureData();
$xobjects = [];
$pon = 0;
$importer = new Importer($xobjects, $pon, $this->makeObjFile());
$srcId = $importer->setImportSourceData($data);
// First import (no cache): allocates objects from the source.
$importer->importPage($srcId, 1, ['cache' => false]);
$ponAfterFirst = $pon;
$this->assertGreaterThan(0, $ponAfterFirst);
// Second import of the same page (cache off): shared resources are already
// in the ObjectMap — only the new Form XObject itself increments pon.
$importer->importPage($srcId, 1, ['cache' => false]);
$ponAfterSecond = $pon;
// pon must have increased by exactly 1 (the new XObject), not by the full
// resource set again.
$this->assertSame(1, $ponAfterSecond - $ponAfterFirst);
}
/** @throws \Throwable */
public function testRepeatedImportNoCacheDoesNotDuplicateAuxObjects(): void
{
$data = $this->fixtureData();
$xobjects = [];
$pon = 0;
$importer = new Importer($xobjects, $pon, $this->makeObjFile());
$srcId = $importer->setImportSourceData($data);
$importer->importPage($srcId, 1, ['cache' => false]);
$importer->importPage($srcId, 1, ['cache' => false]);
$out = $importer->getOutImportedObjects();
// Count occurrences of the font object serialized string to verify
// it appears exactly once (dedup works).
$xobjCount = \substr_count($out, '/Type /XObject');
$this->assertSame(2, $xobjCount, 'Each non-cached import should produce exactly one XObject');
// The font object (5_0) should be written exactly once despite two imports.
$fontCount = \substr_count($out, '/Type /Font');
$this->assertSame(1, $fontCount, 'Shared font object must not be duplicated across imports');
}
// -------------------------------------------------------------------------
// Multi-page fixture tests
// -------------------------------------------------------------------------
/** @throws \Throwable */
public function testGetSourcePageCountMultipage(): void
{
$data = $this->multipageFixtureData();
$importer = $this->makeImporter();
$srcId = $importer->setImportSourceData($data);
$this->assertSame(2, $importer->getSourcePageCount($srcId));
}
/** @throws \Throwable */
public function testImportPagesNullRangeImportsAllMultipagePages(): void
{
$data = $this->multipageFixtureData();
$xobjects = [];
$pon = 0;
$importer = new Importer($xobjects, $pon, $this->makeObjFile());
$srcId = $importer->setImportSourceData($data);
$templates = $importer->importPages($srcId);
$this->assertCount(2, $templates);
assert(isset($templates[0]), "\$templates[0] must be set");
$this->assertInstanceOf(PageTemplate::class, $templates[0]);
assert(isset($templates[1]), "\$templates[1] must be set");
$this->assertInstanceOf(PageTemplate::class, $templates[1]);
}
/** @throws \Throwable */
public function testImportPagesMultipagePartialRange(): void
{
$data = $this->multipageFixtureData();
$importer = $this->makeImporter();
$srcId = $importer->setImportSourceData($data);
$templates = $importer->importPages($srcId, [2]);
$this->assertCount(1, $templates);
assert(isset($templates[0]), "\$templates[0] must be set");
$this->assertEqualsWithDelta(612.0, $templates[0]->getWidth(), 0.01);
}
/** @throws \Throwable */
public function testImportAllPagesMultipageSharedFontNotDuplicated(): void
{
$data = $this->multipageFixtureData();
$xobjects = [];
$pon = 0;
$importer = new Importer($xobjects, $pon, $this->makeObjFile());
$srcId = $importer->setImportSourceData($data);
$importer->importPages($srcId);
$out = $importer->getOutImportedObjects();
// Two pages should produce two Form XObjects.
$this->assertSame(2, \substr_count($out, '/Type /XObject'));
// The shared font (5_0) must appear exactly once in the output.
$this->assertSame(1, \substr_count($out, '/Type /Font'));
}
/**
* importPages(null) must import nothing when the source reports a missing or
* negative page count. Otherwise \range(1, $total) would yield a descending
* sequence (e.g. [1, 0]) and attempt to import bogus page numbers.
*
* @throws \Throwable
*/
public function testImportPagesWithNonPositivePageCountReturnsEmpty(): void
{
$xobjects = [];
$pon = 0;
$importer = new class($xobjects, $pon, $this->makeObjFile()) extends Importer {
public int $fakeCount = 0;
public function getSourcePageCount(string $sourceId): int
{
return $this->fakeCount;
}
};
$importer->fakeCount = 0;
$this->assertSame([], $importer->importPages('any-source', null));
$importer->fakeCount = -3;
$this->assertSame([], $importer->importPages('any-source', null));
}
}
@@ -0,0 +1,138 @@
<?php
/**
* ObjectMapTest.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\Import;
use Com\Tecnick\Pdf\Import\ImportException;
use Com\Tecnick\Pdf\Import\ObjectMap;
use PHPUnit\Framework\TestCase;
class ObjectMapTest extends TestCase
{
public function testHasReturnsFalseForUnknownRef(): void
{
$map = new ObjectMap();
$this->assertFalse($map->has('1_0'));
}
public function testAllocateAssignsIncreasingObjectNumbers(): void
{
$map = new ObjectMap();
$pon = 10;
$num1 = $map->allocate('1_0', $pon);
$num2 = $map->allocate('2_0', $pon);
$this->assertSame(11, $num1);
$this->assertSame(12, $num2);
$this->assertSame(12, $pon);
}
public function testAllocateIsIdempotentForSameRef(): void
{
$map = new ObjectMap();
$pon = 5;
$first = $map->allocate('3_0', $pon);
$second = $map->allocate('3_0', $pon);
$this->assertSame($first, $second);
}
public function testHasReturnsTrueAfterAllocate(): void
{
$map = new ObjectMap();
$pon = 0;
$map->allocate('5_0', $pon);
$this->assertTrue($map->has('5_0'));
}
public function testIsInProgressTrueBeforeEnqueue(): void
{
$map = new ObjectMap();
$pon = 0;
$map->allocate('6_0', $pon);
$this->assertTrue($map->isInProgress('6_0'));
}
public function testIsInProgressFalseAfterEnqueue(): void
{
$map = new ObjectMap();
$pon = 0;
$map->allocate('7_0', $pon);
$map->enqueue('7_0', '7 0 obj null endobj');
$this->assertFalse($map->isInProgress('7_0'));
}
public function testFlushReturnsQueuedDataAndClears(): void
{
$map = new ObjectMap();
$pon = 0;
$map->allocate('8_0', $pon);
$map->enqueue('8_0', '8 0 obj null endobj');
$out = $map->flush();
$this->assertStringContainsString('8 0 obj', $out);
// second flush should be empty
$this->assertSame('', $map->flush());
}
/** @throws \Throwable */
public function testGetThrowsForUnallocatedRef(): void
{
$map = new ObjectMap();
$this->expectException(ImportException::class);
$map->get('99_0');
}
public function testGetMapReturnsFullMap(): void
{
$map = new ObjectMap();
$pon = 0;
$map->allocate('1_0', $pon);
$map->allocate('2_0', $pon);
$full = $map->getMap();
$this->assertCount(2, $full);
$this->assertArrayHasKey('1_0', $full);
$this->assertArrayHasKey('2_0', $full);
}
/** @throws \Throwable */
public function testFlushPreservesMapForDedup(): void
{
$map = new ObjectMap();
$pon = 0;
$map->allocate('10_0', $pon);
$map->enqueue('10_0', '10 0 obj null endobj');
$map->flush();
// After flush the queue is empty but the mapping must still be intact.
$this->assertTrue($map->has('10_0'));
$this->assertSame(1, $map->get('10_0'));
// A second flush of the now-empty queue should produce an empty string.
$this->assertSame('', $map->flush());
}
public function testDedupAcrossMultipleAllocations(): void
{
$map = new ObjectMap();
$pon = 0;
$num1 = $map->allocate('20_0', $pon);
$map->enqueue('20_0', '1 0 obj null endobj');
$map->flush();
// Re-allocating the same ref after flush must return the original number.
$num2 = $map->allocate('20_0', $pon);
$this->assertSame($num1, $num2);
// pon must not have been incremented again.
$this->assertSame(1, $pon);
}
}
@@ -0,0 +1,982 @@
<?php
/**
* PageResolverTest.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\Import;
use Com\Tecnick\Pdf\Import\ImportCorruptedSourceException;
use Com\Tecnick\Pdf\Import\ImportPageOutOfRangeException;
use Com\Tecnick\Pdf\Import\PageResolver;
use Com\Tecnick\Pdf\Import\SourceDocument;
use PHPUnit\Framework\TestCase;
class PageResolverTest extends TestCase
{
private function invokeResolverMethod(PageResolver $resolver, string $method, mixed ...$args): mixed
{
$ref = new \ReflectionClass($resolver);
return $ref->getMethod($method)->invokeArgs($resolver, $args);
}
/** @throws \Throwable */
private function loadDoc(): SourceDocument
{
$path = __DIR__ . '/../fixtures/simple_import.pdf';
$data = file_get_contents($path);
$this->assertNotFalse($data);
return new SourceDocument($data);
}
/**
* @param array<int, mixed> $pairs
* @return array<int, mixed>
*/
private function dictObject(array $pairs): array
{
return [['<<', $pairs]];
}
/**
* @param array<string, array<int, mixed>> $objects
* @throws \Throwable
*/
private function mockDoc(array $objects): SourceDocument
{
$doc = $this->createStub(SourceDocument::class);
$doc->method('getTrailer')->willReturn(['root' => '1 0 R']);
$doc->method('getObject')->willReturnCallback(static fn(string $ref): array => $objects[$ref] ?? []);
$doc->method('findObject')->willReturnCallback(static fn(string $ref): ?array => $objects[$ref] ?? null);
return $doc;
}
/** @throws \Throwable */
public function testResolvePage1ReturnsExpectedMediaBox(): void
{
$resolver = new PageResolver();
$resolved = $resolver->resolve($this->loadDoc(), 1);
$this->assertArrayHasKey('mediaBox', $resolved);
$this->assertCount(4, $resolved['mediaBox']);
$this->assertEqualsWithDelta(612.0, $resolved['mediaBox'][2], 0.001);
$this->assertEqualsWithDelta(792.0, $resolved['mediaBox'][3], 0.001);
}
/** @throws \Throwable */
public function testResolvePage1HasResources(): void
{
$resolver = new PageResolver();
$resolved = $resolver->resolve($this->loadDoc(), 1);
$this->assertArrayHasKey('resources', $resolved);
}
/** @throws \Throwable */
public function testResolvePage1RotateIsZero(): void
{
$resolver = new PageResolver();
$resolved = $resolver->resolve($this->loadDoc(), 1);
$this->assertSame(0, $resolved['rotate']);
}
/** @throws \Throwable */
public function testResolveThrowsForPageZero(): void
{
$resolver = new PageResolver();
$this->expectException(ImportPageOutOfRangeException::class);
$resolver->resolve($this->loadDoc(), 0);
}
/** @throws \Throwable */
public function testResolveThrowsForPageOutOfRange(): void
{
$resolver = new PageResolver();
$this->expectException(ImportPageOutOfRangeException::class);
$resolver->resolve($this->loadDoc(), 999);
}
/** @throws \Throwable */
public function testResolveThrowsWhenRootPagesEntryIsMissing(): void
{
$resolver = new PageResolver();
$doc = $this->mockDoc([
'1_0' => $this->dictObject([
['/', 'Type'],
['/', 'Catalog'],
]),
]);
$this->expectException(ImportCorruptedSourceException::class);
$this->expectExceptionMessageMatches('/' . preg_quote('missing /Pages entry', '/') . '/');
$resolver->resolve($doc, 1);
}
/** @throws \Throwable */
public function testResolveParsesInheritedRotateAndIndirectResources(): void
{
$resolver = new PageResolver();
$doc = $this->mockDoc([
'1_0' => $this->dictObject([
['/', 'Pages'],
['objref', '2 0 R'],
]),
'2_0' => $this->dictObject([
['/', 'Type'],
['/', 'Pages'],
['/', 'Kids'],
[
'[',
[
['objref', '3 0 R'],
],
],
['/', 'MediaBox'],
[
'[',
[
['numeric', 0],
['numeric', 0],
['numeric', 612],
['numeric', 792],
],
],
['/', 'Rotate'],
['numeric', '180'],
['/', 'Resources'],
['objref', '4 0 R'],
]),
'3_0' => $this->dictObject([
['/', 'Type'],
['/', 'Page'],
]),
'4_0' => $this->dictObject([
['/', 'Font'],
[
'<<',
[
['/', 'F1'],
['objref', '5 0 R'],
],
],
]),
'5_0' => $this->dictObject([
['/', 'Type'],
['/', 'Font'],
]),
]);
$resolved = $resolver->resolve($doc, 1);
$this->assertSame(180, $resolved['rotate']);
$this->assertArrayHasKey('Font', $resolved['resources']);
if (!isset($resolved['resources']['Font']) || !\is_array($resolved['resources']['Font'])) {
$this->fail('Expected Font resources array.');
}
$this->assertArrayHasKey('F1', $resolved['resources']['Font']);
}
/** @throws \Throwable */
public function testResolveSkipsInvalidKidsAndUsesInlineResourcesAndBoxFallbacks(): void
{
$resolver = new PageResolver();
$doc = $this->mockDoc([
'1_0' => $this->dictObject([
['/', 'Pages'],
['objref', '2 0 R'],
]),
'2_0' => $this->dictObject([
['/', 'Type'],
['/', 'Pages'],
['/', 'Kids'],
[
'[',
[
['numeric', 7],
['objref', '3 0 R'],
],
],
['/', 'MediaBox'],
[
'[',
[
['numeric', 0],
['numeric', 0],
['numeric', 400],
['numeric', 600],
],
],
]),
'3_0' => $this->dictObject([
['/', 'Type'],
['/', 'Page'],
['/', 'CropBox'],
['string', 'invalid'],
['/', 'Rotate'],
['numeric', 90],
['/', 'Resources'],
[
'<<',
[
['/', 'ProcSet'],
[
'[',
[
['/', '/PDF'],
['/', '/Text'],
],
],
],
],
]),
]);
$resolved = $resolver->resolve($doc, 1);
$this->assertSame(90, $resolved['rotate']);
$this->assertSame([0.0, 0.0, 400.0, 600.0], $resolved['mediaBox']);
$this->assertSame($resolved['mediaBox'], $resolved['cropBox']);
$this->assertSame($resolved['cropBox'], $resolved['bleedBox']);
$this->assertSame($resolved['cropBox'], $resolved['trimBox']);
$this->assertSame($resolved['cropBox'], $resolved['artBox']);
$this->assertSame(['/PDF', '/Text'], $resolved['resources']['ProcSet'] ?? null);
}
/** @throws \Throwable */
public function testResolveThrowsWhenResolvedPageIsMissingMediaBox(): void
{
$resolver = new PageResolver();
$doc = $this->mockDoc([
'1_0' => $this->dictObject([
['/', 'Pages'],
['objref', '2 0 R'],
]),
'2_0' => $this->dictObject([
['/', 'Type'],
['/', 'Pages'],
['/', 'Kids'],
[
'[',
[
['objref', '3 0 R'],
],
],
]),
'3_0' => $this->dictObject([
['/', 'Type'],
['/', 'Page'],
]),
]);
$this->expectException(ImportCorruptedSourceException::class);
$this->expectExceptionMessageMatches('/' . preg_quote('missing /MediaBox', '/') . '/');
$resolver->resolve($doc, 1);
}
/** @throws \Throwable */
public function testResolveAcceptsIndirectMediaBoxReference(): void
{
$resolver = new PageResolver();
$doc = $this->mockDoc([
'1_0' => $this->dictObject([
['/', 'Pages'],
['objref', '2 0 R'],
]),
'2_0' => $this->dictObject([
['/', 'Type'],
['/', 'Pages'],
['/', 'Kids'],
[
'[',
[
['objref', '3 0 R'],
],
],
]),
'3_0' => $this->dictObject([
['/', 'Type'],
['/', 'Page'],
['/', 'MediaBox'],
['objref', '8 0 R'],
]),
'8_0' => [
[
'[',
[
['numeric', 0],
['numeric', 0],
['numeric', 612],
['numeric', 792],
],
],
],
]);
$resolved = $resolver->resolve($doc, 1);
$this->assertSame([0.0, 0.0, 612.0, 792.0], $resolved['mediaBox']);
$this->assertSame($resolved['mediaBox'], $resolved['cropBox']);
}
/** @throws \Throwable */
public function testResolveThrowsWhenIndirectMediaBoxReferenceIsMissing(): void
{
$resolver = new PageResolver();
$doc = $this->mockDoc([
'1_0' => $this->dictObject([
['/', 'Pages'],
['objref', '2 0 R'],
]),
'2_0' => $this->dictObject([
['/', 'Type'],
['/', 'Pages'],
['/', 'Kids'],
[
'[',
[
['objref', '3 0 R'],
],
],
]),
'3_0' => $this->dictObject([
['/', 'Type'],
['/', 'Page'],
['/', 'MediaBox'],
['objref', '999 0 R'],
]),
]);
$this->expectException(ImportCorruptedSourceException::class);
$this->expectExceptionMessageMatches('/' . preg_quote('missing /MediaBox', '/') . '/');
$resolver->resolve($doc, 1);
}
/** @throws \Throwable */
public function testResolveThrowsWhenIndirectMediaBoxObjectIsMalformed(): void
{
$resolver = new PageResolver();
$doc = $this->mockDoc([
'1_0' => $this->dictObject([
['/', 'Pages'],
['objref', '2 0 R'],
]),
'2_0' => $this->dictObject([
['/', 'Type'],
['/', 'Pages'],
['/', 'Kids'],
[
'[',
[
['objref', '3 0 R'],
],
],
]),
'3_0' => $this->dictObject([
['/', 'Type'],
['/', 'Page'],
['/', 'MediaBox'],
['objref', '8 0 R'],
]),
'8_0' => [
['keyword', 'invalid-box-token'],
],
]);
$this->expectException(ImportCorruptedSourceException::class);
$this->expectExceptionMessageMatches('/' . preg_quote('missing /MediaBox', '/') . '/');
$resolver->resolve($doc, 1);
}
/** @throws \Throwable */
public function testResolveMergesParentAndChildResourceDictionaries(): void
{
$resolver = new PageResolver();
$doc = $this->mockDoc([
'1_0' => $this->dictObject([
['/', 'Pages'],
['objref', '2 0 R'],
]),
'2_0' => $this->dictObject([
['/', 'Type'],
['/', 'Pages'],
['/', 'Kids'],
[
'[',
[
['objref', '3 0 R'],
],
],
['/', 'MediaBox'],
[
'[',
[
['numeric', 0],
['numeric', 0],
['numeric', 200],
['numeric', 300],
],
],
['/', 'Resources'],
[
'<<',
[
['/', 'Font'],
[
'<<',
[
['/', 'F1'],
['objref', '10 0 R'],
],
],
['/', 'XObject'],
[
'<<',
[
['/', 'Im1'],
['objref', '11 0 R'],
],
],
],
],
]),
'3_0' => $this->dictObject([
['/', 'Type'],
['/', 'Page'],
['/', 'Resources'],
[
'<<',
[
['/', 'Font'],
[
'<<',
[
['/', 'F2'],
['objref', '12 0 R'],
],
],
],
],
]),
'10_0' => $this->dictObject([
['/', 'Type'],
['/', 'Font'],
]),
'11_0' => $this->dictObject([
['/', 'Type'],
['/', 'XObject'],
]),
'12_0' => $this->dictObject([
['/', 'Type'],
['/', 'Font'],
]),
]);
$resolved = $resolver->resolve($doc, 1);
$resources = $resolved['resources'];
$this->assertIsArray($resources);
if (!isset($resources['Font']) || !\is_array($resources['Font'])) {
$this->fail('Expected merged Font dictionary.');
}
if (!isset($resources['XObject']) || !\is_array($resources['XObject'])) {
$this->fail('Expected inherited XObject dictionary.');
}
$this->assertArrayHasKey('F1', $resources['Font']);
$this->assertArrayHasKey('F2', $resources['Font']);
$this->assertArrayHasKey('Im1', $resources['XObject']);
}
/** @throws \Throwable */
public function testResolveThrowsForPagesNodeWithoutKidsArray(): void
{
$resolver = new PageResolver();
$doc = $this->mockDoc([
'1_0' => $this->dictObject([
['/', 'Pages'],
['objref', '2 0 R'],
]),
'2_0' => $this->dictObject([
['/', 'Type'],
['/', 'Pages'],
]),
]);
$this->expectException(ImportCorruptedSourceException::class);
$this->expectExceptionMessageMatches('/' . preg_quote('/Kids', '/') . '/');
$resolver->resolve($doc, 1);
}
/** @throws \Throwable */
public function testResolveThrowsForUnexpectedPageTreeNodeType(): void
{
$resolver = new PageResolver();
$doc = $this->mockDoc([
'1_0' => $this->dictObject([
['/', 'Pages'],
['objref', '2 0 R'],
]),
'2_0' => $this->dictObject([
['/', 'Type'],
['/', 'Catalog'],
['/', 'Kids'],
[
'[',
[
['objref', '3 0 R'],
],
],
]),
'3_0' => $this->dictObject([
['/', 'Type'],
['/', 'Page'],
]),
]);
$this->expectException(ImportCorruptedSourceException::class);
$this->expectExceptionMessageMatches('/' . preg_quote('Unexpected page tree node type', '/') . '/');
$resolver->resolve($doc, 1);
}
public function testObjectToDictThrowsWhenDictionaryElementIsMissing(): void
{
$resolver = new PageResolver();
$this->expectException(ImportCorruptedSourceException::class);
$this->expectExceptionMessageMatches('/' . preg_quote('Expected dictionary object', '/') . '/');
$this->invokeResolverMethod($resolver, 'objectToDict', ['not-an-array-element']);
}
public function testParseDictArraySkipsMalformedKeysAndParsesFallbackValueTypes(): void
{
$resolver = new PageResolver();
/** @var array<string, mixed> $parsed */
$parsed = $this->invokeResolverMethod($resolver, 'parseDictArray', [
['numeric', 1],
['string', 'ignored-non-name-key'],
['/', 123],
['string', 'ignored-non-string-name'],
['/', '/Literal'],
'plain-text',
['/', '/Ref'],
['objref'],
['/', '/Unknown'],
['keyword', 'fallback-value'],
['/', '/Empty'],
[],
]);
$this->assertSame(
[
'Literal' => 'plain-text',
'Ref' => '',
'Unknown' => 'fallback-value',
'Empty' => null,
],
\array_intersect_key($parsed, [
'Literal' => true,
'Ref' => true,
'Unknown' => true,
'Empty' => true,
]),
);
$this->assertArrayNotHasKey('123', $parsed);
}
/**
* A cyclic /Pages tree (intermediate nodes referencing one another, with no
* Page leaf to terminate the descent) must be rejected instead of recursing
* until the stack/memory is exhausted.
*
* @throws \Throwable
*/
public function testResolveThrowsOnCyclicPageTree(): void
{
$resolver = new PageResolver();
$doc = $this->mockDoc([
'1_0' => $this->dictObject([
['/', 'Pages'],
['objref', '2 0 R'],
]),
'2_0' => $this->dictObject([
['/', 'Type'],
['/', 'Pages'],
['/', 'Kids'],
[
'[',
[
['objref', '3 0 R'],
],
],
]),
'3_0' => $this->dictObject([
['/', 'Type'],
['/', 'Pages'],
['/', 'Kids'],
[
'[',
[
['objref', '2 0 R'],
],
],
]),
]);
$this->expectException(ImportCorruptedSourceException::class);
$this->expectExceptionMessageMatches('/' . preg_quote('Duplicate or cyclic reference', '/') . '/');
$resolver->resolve($doc, 1);
}
/**
* A /Pages node listing the same kid twice is malformed (every node has a
* single /Parent) and, if tolerated, would multiply the traversal
* exponentially with tree depth besides corrupting page numbering.
*
* @throws \Throwable
*/
public function testResolveThrowsOnDuplicateSiblingReference(): void
{
$resolver = new PageResolver();
$doc = $this->mockDoc([
'1_0' => $this->dictObject([
['/', 'Pages'],
['objref', '2 0 R'],
]),
'2_0' => $this->dictObject([
['/', 'Type'],
['/', 'Pages'],
['/', 'Kids'],
[
'[',
[
['objref', '3 0 R'],
['objref', '3 0 R'],
],
],
]),
'3_0' => $this->dictObject([
['/', 'Type'],
['/', 'Page'],
]),
]);
$this->expectException(ImportCorruptedSourceException::class);
$this->expectExceptionMessageMatches('/' . preg_quote('Duplicate or cyclic reference', '/') . '/');
$resolver->resolve($doc, 2);
}
// -------------------------------------------------------------------------
// countPages
// -------------------------------------------------------------------------
/** @throws \Throwable */
public function testCountPagesReturnsReachablePageCount(): void
{
$resolver = new PageResolver();
$this->assertSame(1, $resolver->countPages($this->loadDoc()));
}
/** @throws \Throwable */
public function testCountPagesThrowsWhenRootPagesEntryIsMissing(): void
{
$resolver = new PageResolver();
$doc = $this->mockDoc([
'1_0' => $this->dictObject([
['/', 'Type'],
['/', 'Catalog'],
]),
]);
$this->expectException(ImportCorruptedSourceException::class);
$this->expectExceptionMessageMatches('/' . preg_quote('missing /Pages entry', '/') . '/');
$resolver->countPages($doc);
}
/** @throws \Throwable */
public function testCountPagesReturnsZeroForEmptyKidsArray(): void
{
$resolver = new PageResolver();
$doc = $this->mockDoc([
'1_0' => $this->dictObject([
['/', 'Pages'],
['objref', '2 0 R'],
]),
'2_0' => $this->dictObject([
['/', 'Type'],
['/', 'Pages'],
['/', 'Kids'],
['[', []],
]),
]);
$this->assertSame(0, $resolver->countPages($doc));
}
/** @throws \Throwable */
public function testCountPagesThrowsOnDuplicateKidReference(): void
{
$resolver = new PageResolver();
$doc = $this->mockDoc([
'1_0' => $this->dictObject([
['/', 'Pages'],
['objref', '2 0 R'],
]),
'2_0' => $this->dictObject([
['/', 'Type'],
['/', 'Pages'],
['/', 'Kids'],
[
'[',
[
['objref', '3 0 R'],
['objref', '3 0 R'],
],
],
]),
'3_0' => $this->dictObject([
['/', 'Type'],
['/', 'Page'],
]),
]);
$this->expectException(ImportCorruptedSourceException::class);
$this->expectExceptionMessageMatches('/' . preg_quote('Duplicate or cyclic reference', '/') . '/');
$resolver->countPages($doc);
}
/** @throws \Throwable */
public function testCountPagesThrowsWhenNodeBudgetExceeded(): void
{
$resolver = new PageResolver();
$doc = $this->mockDoc([
'1_0' => $this->dictObject([
['/', 'Pages'],
['objref', '2 0 R'],
]),
'2_0' => $this->dictObject([
['/', 'Type'],
['/', 'Pages'],
['/', 'Kids'],
[
'[',
[
['objref', '3 0 R'],
],
],
]),
'3_0' => $this->dictObject([
['/', 'Type'],
['/', 'Page'],
]),
]);
$this->expectException(ImportCorruptedSourceException::class);
$this->expectExceptionMessageMatches('/' . preg_quote('maximum node budget', '/') . '/');
$resolver->countPages($doc, 1);
}
/** @throws \Throwable */
public function testCountPagesThrowsForUnexpectedNodeType(): void
{
$resolver = new PageResolver();
$doc = $this->mockDoc([
'1_0' => $this->dictObject([
['/', 'Pages'],
['objref', '2 0 R'],
]),
'2_0' => $this->dictObject([
['/', 'Type'],
['/', 'Catalog'],
]),
]);
$this->expectException(ImportCorruptedSourceException::class);
$this->expectExceptionMessageMatches('/' . preg_quote('Unexpected page tree node type', '/') . '/');
$resolver->countPages($doc);
}
/** @throws \Throwable */
public function testCountPagesThrowsForPagesNodeWithoutKids(): void
{
$resolver = new PageResolver();
$doc = $this->mockDoc([
'1_0' => $this->dictObject([
['/', 'Pages'],
['objref', '2 0 R'],
]),
'2_0' => $this->dictObject([
['/', 'Type'],
['/', 'Pages'],
]),
]);
$this->expectException(ImportCorruptedSourceException::class);
$this->expectExceptionMessageMatches('/' . preg_quote('missing /Kids', '/') . '/');
$resolver->countPages($doc);
}
// -------------------------------------------------------------------------
// buildPageIndex / resolveFromIndex
// -------------------------------------------------------------------------
/** @throws \Throwable */
public function testBuildPageIndexReturnsPagesInDocumentOrderWithInheritedAttributes(): void
{
$resolver = new PageResolver();
$doc = $this->mockDoc([
'1_0' => $this->dictObject([
['/', 'Pages'],
['objref', '2 0 R'],
]),
'2_0' => $this->dictObject([
['/', 'Type'],
['/', 'Pages'],
['/', 'Kids'],
[
'[',
[
['objref', '3 0 R'],
['objref', '6 0 R'],
],
],
['/', 'MediaBox'],
[
'[',
[
['numeric', 0],
['numeric', 0],
['numeric', 300],
['numeric', 500],
],
],
]),
'3_0' => $this->dictObject([
['/', 'Type'],
['/', 'Pages'],
['/', 'Kids'],
[
'[',
[
['objref', '4 0 R'],
['objref', '5 0 R'],
],
],
]),
'4_0' => $this->dictObject([
['/', 'Type'],
['/', 'Page'],
['/', 'Rotate'],
['numeric', 90],
]),
'5_0' => $this->dictObject([
['/', 'Type'],
['/', 'Page'],
['/', 'Rotate'],
['numeric', 180],
]),
'6_0' => $this->dictObject([
['/', 'Type'],
['/', 'Page'],
['/', 'Rotate'],
['numeric', 270],
]),
]);
$index = $resolver->buildPageIndex($doc);
$this->assertCount(3, $index);
// Leaves of the nested /Pages node come before the root's second kid.
$this->assertSame([90, 180, 270], \array_column($index, 'Rotate'));
// The root /Pages MediaBox is inherited by every leaf.
foreach ($index as $pageDict) {
$this->assertSame([0, 0, 300, 500], $pageDict['MediaBox'] ?? null);
}
}
/** @throws \Throwable */
public function testResolveFromIndexThrowsForPageZero(): void
{
$resolver = new PageResolver();
$doc = $this->loadDoc();
$index = $resolver->buildPageIndex($doc);
$this->expectException(ImportPageOutOfRangeException::class);
$resolver->resolveFromIndex($doc, $index, 0);
}
/** @throws \Throwable */
public function testResolveFromIndexThrowsForPageBeyondIndex(): void
{
$resolver = new PageResolver();
$doc = $this->loadDoc();
$index = $resolver->buildPageIndex($doc);
$this->expectException(ImportPageOutOfRangeException::class);
$this->expectExceptionMessageMatches('/' . preg_quote('document has fewer pages', '/') . '/');
$resolver->resolveFromIndex($doc, $index, 2);
}
/** @throws \Throwable */
public function testResolveFromIndexDoesNotWalkTheTreeAgain(): void
{
$resolver = new PageResolver();
$calls = 0;
$objects = [
'1_0' => $this->dictObject([
['/', 'Pages'],
['objref', '2 0 R'],
]),
'2_0' => $this->dictObject([
['/', 'Type'],
['/', 'Pages'],
['/', 'Kids'],
[
'[',
[
['objref', '3 0 R'],
],
],
['/', 'MediaBox'],
[
'[',
[
['numeric', 0],
['numeric', 0],
['numeric', 200],
['numeric', 200],
],
],
]),
'3_0' => $this->dictObject([
['/', 'Type'],
['/', 'Page'],
]),
];
$doc = $this->createStub(SourceDocument::class);
$doc->method('getTrailer')->willReturn(['root' => '1 0 R']);
$doc->method('getObject')->willReturnCallback(static function (string $ref) use ($objects, &$calls): array {
++$calls;
return $objects[$ref] ?? [];
});
$index = $resolver->buildPageIndex($doc);
$callsAfterBuild = $calls;
$this->assertGreaterThan(0, $callsAfterBuild);
$resolved = $resolver->resolveFromIndex($doc, $index, 1);
$this->assertSame([0.0, 0.0, 200.0, 200.0], $resolved['mediaBox']);
$this->assertSame($callsAfterBuild, $calls);
}
}
@@ -0,0 +1,51 @@
<?php
/**
* PageTemplateTest.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\Import;
use Com\Tecnick\Pdf\Import\PageTemplate;
use PHPUnit\Framework\TestCase;
class PageTemplateTest extends TestCase
{
public function testGettersReturnConstructorValues(): void
{
$mediaBox = [0.0, 0.0, 612.0, 792.0];
$template = new PageTemplate('TPL42', 612.0, 792.0, 90, 'source-abc', 3, $mediaBox);
$this->assertSame('TPL42', $template->getXobjId());
$this->assertSame(612.0, $template->getWidth());
$this->assertSame(792.0, $template->getHeight());
$this->assertSame(90, $template->getRotation());
$this->assertSame('source-abc', $template->getSourceId());
$this->assertSame(3, $template->getSourcePage());
$this->assertSame($mediaBox, $template->getMediaBox());
}
public function testReadonlyPropertiesMirrorGetters(): void
{
$mediaBox = [10.0, 20.0, 210.0, 297.0];
$template = new PageTemplate('TPL99', 200.0, 277.0, 0, 'source-def', 12, $mediaBox);
$this->assertSame($template->xobjId, $template->getXobjId());
$this->assertSame($template->width, $template->getWidth());
$this->assertSame($template->height, $template->getHeight());
$this->assertSame($template->rotation, $template->getRotation());
$this->assertSame($template->sourceId, $template->getSourceId());
$this->assertSame($template->sourcePage, $template->getSourcePage());
$this->assertSame($template->mediaBox, $template->getMediaBox());
}
}
@@ -0,0 +1,873 @@
<?php
/**
* ResourceClonerTest.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\Import;
use Com\Tecnick\Pdf\Import\ImportCorruptedSourceException;
use Com\Tecnick\Pdf\Import\ObjectMap;
use Com\Tecnick\Pdf\Import\ResourceCloner;
use Com\Tecnick\Pdf\Import\SourceDocument;
use PHPUnit\Framework\TestCase;
class ResourceClonerTest extends TestCase
{
/** @throws \Throwable */
private function loadFixture(): SourceDocument
{
$path = __DIR__ . '/../fixtures/simple_import.pdf';
$data = \file_get_contents($path);
$this->assertNotFalse($data);
return new SourceDocument($data);
}
/**
* @param array<string, mixed> $objects
* @throws \Throwable
*/
private function makeMockSourceDocument(array $objects): SourceDocument
{
$src = $this->createStub(SourceDocument::class);
$src->method('getObject')->willReturnCallback(static fn(string $ref): array => (
isset($objects[$ref]) && \is_array($objects[$ref]) ? $objects[$ref] : []
));
$src->method('findObject')->willReturnCallback(static fn(string $ref): ?array => isset($objects[$ref])
&& \is_array($objects[$ref])
? $objects[$ref]
: null);
return $src;
}
// -------------------------------------------------------------------------
// getPon
// -------------------------------------------------------------------------
public function testGetPonReturnsInitialValue(): void
{
$cloner = new ResourceCloner(10);
$this->assertSame(10, $cloner->getPon());
}
/** @throws \Throwable */
public function testGetPonUpdatesAfterEnqueue(): void
{
$src = $this->loadFixture();
$map = new ObjectMap();
$cloner = new ResourceCloner(0);
// Enqueuing font object 5_0 must allocate a new destination number.
$destNum = $cloner->enqueueObject('5_0', $src, $map);
$this->assertGreaterThan(0, $destNum);
$this->assertSame($destNum, $cloner->getPon());
}
// -------------------------------------------------------------------------
// getContentStream
// -------------------------------------------------------------------------
/** @throws \Throwable */
public function testGetContentStreamEmptyPageReturnsEmptyStream(): void
{
$src = $this->loadFixture();
$cloner = new ResourceCloner(0);
$result = $cloner->getContentStream([], $src);
$this->assertSame('', $result['bytes']);
$this->assertSame('', $result['filter']);
$this->assertSame(0, $result['length']);
}
/** @throws \Throwable */
public function testGetContentStreamSingleRef(): void
{
$src = $this->loadFixture();
$cloner = new ResourceCloner(0);
// Object 4_0 is the content stream in the fixture.
$pageDict = ['Contents' => '4_0'];
$result = $cloner->getContentStream($pageDict, $src);
$this->assertNotEmpty($result['bytes']);
$this->assertStringContainsString('BT', $result['bytes']);
$this->assertSame(\strlen($result['bytes']), $result['length']);
}
/** @throws \Throwable */
public function testGetContentStreamArrayWithSingleRef(): void
{
$src = $this->loadFixture();
$cloner = new ResourceCloner(0);
// Array of one ref — should behave identically to single-ref case.
$pageDict = ['Contents' => ['4_0']];
$result = $cloner->getContentStream($pageDict, $src);
$this->assertNotEmpty($result['bytes']);
$this->assertStringContainsString('BT', $result['bytes']);
}
/** @throws \Throwable */
public function testGetContentStreamMultipleRefsAreConcatenated(): void
{
$src = $this->loadFixture();
$cloner = new ResourceCloner(0);
// Use the same stream twice to test the concatenation path.
$pageDict = ['Contents' => ['4_0', '4_0']];
$result = $cloner->getContentStream($pageDict, $src);
// Bytes must appear twice in the concatenated output.
$singleStream = $cloner->getContentStream(['Contents' => '4_0'], $src);
$this->assertStringContainsString($singleStream['bytes'], $result['bytes']);
$this->assertGreaterThan($singleStream['length'], $result['length']);
// Multi-stream concatenation always returns empty filter.
$this->assertSame('', $result['filter']);
}
/** @throws \Throwable */
public function testGetContentStreamThrowsForInvalidArrayEntry(): void
{
$src = $this->loadFixture();
$cloner = new ResourceCloner(0);
// A non-string element inside the /Contents array is invalid.
$pageDict = ['Contents' => [42]];
$this->expectException(ImportCorruptedSourceException::class);
$cloner->getContentStream($pageDict, $src);
}
// -------------------------------------------------------------------------
// cloneResources
// -------------------------------------------------------------------------
/** @throws \Throwable */
public function testCloneResourcesEmptyDictReturnsEmptyString(): void
{
$src = $this->loadFixture();
$map = new ObjectMap();
$cloner = new ResourceCloner(0);
$this->assertSame('', $cloner->cloneResources([], $src, $map));
}
/** @throws \Throwable */
public function testCloneResourcesFontRefsAreRemapped(): void
{
$src = $this->loadFixture();
$map = new ObjectMap();
$cloner = new ResourceCloner(0);
// Minimal resource dict: Font -> F1 -> indirect ref to object 5_0.
$resources = ['Font' => ['F1' => '5_0']];
$output = $cloner->cloneResources($resources, $src, $map);
// Output must start with << and contain a /Font entry.
$this->assertStringStartsWith('<<', $output);
$this->assertStringContainsString('/Font', $output);
// The ref to 5_0 must be remapped to a new object number in "N 0 R" format.
$this->assertMatchesRegularExpression('/\d+ 0 R/', $output);
}
/** @throws \Throwable */
public function testCloneResourcesProcSetSkipped(): void
{
$src = $this->loadFixture();
$map = new ObjectMap();
$cloner = new ResourceCloner(0);
$resources = ['ProcSet' => ['/PDF', '/Text']];
$output = $cloner->cloneResources($resources, $src, $map);
// ProcSet is re-emitted as a standard fixed list, so source array is ignored.
$this->assertStringContainsString('/ProcSet', $output);
}
/** @throws \Throwable */
public function testCloneResourcesSerializesNestedEntriesAndPreservesInlineScalars(): void
{
$src = $this->loadFixture();
$map = new ObjectMap();
$cloner = new ResourceCloner(0);
$output = $cloner->cloneResources(
[
'ColorSpace' => [
'CS1' => '/DeviceRGB',
'CS2' => ['/DeviceCMYK'],
],
],
$src,
$map,
);
$this->assertStringContainsString('/CS1 /DeviceRGB', $output);
$this->assertStringContainsString('/CS2 [ /DeviceCMYK ]', $output);
}
/** @throws \Throwable */
public function testCloneResourcesSupportsScalarResourceValues(): void
{
$src = $this->makeMockSourceDocument([
'5_0' => [
['numeric', 99],
],
]);
$map = new ObjectMap();
$cloner = new ResourceCloner(0);
$indirectOutput = $cloner->cloneResources(['XObject' => '5 0 R'], $src, $map);
$inlineOutput = $cloner->cloneResources(['ColorSpace' => '/DeviceCMYK'], $src, $map);
$nullOutput = $cloner->cloneResources(['Pattern' => 123], $src, $map);
$this->assertMatchesRegularExpression('/\/XObject \d+ 0 R/', $indirectOutput);
$this->assertStringContainsString('/ColorSpace /DeviceCMYK', $inlineOutput);
$this->assertStringContainsString('/Pattern null', $nullOutput);
}
/** @throws \Throwable */
public function testCloneResourcesPreservesNumericResourceNames(): void
{
$src = $this->makeMockSourceDocument([
'9_0' => [
['numeric', 1],
],
]);
$map = new ObjectMap();
$cloner = new ResourceCloner(0);
$output = $cloner->cloneResources(
[
'Font' => [
9 => '9_0',
'n' => '9_0',
],
],
$src,
$map,
);
$this->assertStringContainsString('/Font << /9 ', $output);
$this->assertStringContainsString('/n ', $output);
}
/** @throws \Throwable */
public function testGetContentStreamRejectsUnexpectedContentsType(): void
{
$src = $this->loadFixture();
$cloner = new ResourceCloner(0);
$this->expectException(ImportCorruptedSourceException::class);
$cloner->getContentStream(['Contents' => 42], $src);
}
/** @throws \Throwable */
public function testGetContentStreamMultipleRefsSkipsInvalidEntriesAndReturnsNamedFilter(): void
{
$src = $this->makeMockSourceDocument([
'1_0' => [
[
'<<',
[
['/', 'Filter'],
['/', 'FlateDecode'],
],
],
['stream', 'alpha'],
],
'2_0' => [
[
'<<',
[
['/', 'Length'],
['numeric', 4],
],
],
['stream', 'beta'],
],
]);
$cloner = new ResourceCloner(0);
$single = $cloner->getContentStream(['Contents' => '1_0'], $src);
$combined = $cloner->getContentStream(['Contents' => ['1_0', 99, '2_0']], $src);
$this->assertSame('/FlateDecode', $single['filter']);
$this->assertSame('alpha beta', $combined['bytes']);
$this->assertSame('', $combined['filter']);
}
/** @throws \Throwable */
public function testGetContentStreamParsesArrayFilterToken(): void
{
$src = $this->makeMockSourceDocument([
'1_0' => [
[
'<<',
[
['/', 'Filter'],
[
'[',
[
['/', 'FlateDecode'],
],
],
],
],
['stream', 'alpha'],
],
]);
$cloner = new ResourceCloner(0);
$single = $cloner->getContentStream(['Contents' => '1_0'], $src);
$this->assertSame('/FlateDecode', $single['filter']);
}
/** @throws \Throwable */
public function testGetContentStreamParsesArrayFilterTokenWithMultipleNames(): void
{
$src = $this->makeMockSourceDocument([
'1_0' => [
[
'<<',
[
['/', 'Filter'],
[
'[',
[
['/', 'ASCII85Decode'],
['/', 'FlateDecode'],
],
],
],
],
['stream', 'alpha'],
],
]);
$cloner = new ResourceCloner(0);
$single = $cloner->getContentStream(['Contents' => '1_0'], $src);
$this->assertSame('[ /ASCII85Decode /FlateDecode ]', $single['filter']);
}
/** @throws \Throwable */
public function testGetContentStreamMultipleFlateRefsAreDecodedBeforeConcatenation(): void
{
$streamA = \gzcompress('q 1 0 0 1 10 20 cm');
$streamB = \gzcompress('BT /F1 12 Tf ET');
$this->assertNotFalse($streamA);
$this->assertNotFalse($streamB);
$src = $this->makeMockSourceDocument([
'1_0' => [
[
'<<',
[
['/', 'Filter'],
['/', 'FlateDecode'],
],
],
['stream', $streamA],
],
'2_0' => [
[
'<<',
[
['/', 'Filter'],
['/', 'FlateDecode'],
],
],
['stream', $streamB],
],
]);
$cloner = new ResourceCloner(0);
$combined = $cloner->getContentStream(['Contents' => ['1_0', '2_0']], $src);
$this->assertSame('', $combined['filter']);
$this->assertStringContainsString('q 1 0 0 1 10 20 cm', $combined['bytes']);
$this->assertStringContainsString('BT /F1 12 Tf ET', $combined['bytes']);
}
/** @throws \Throwable */
public function testCloneResourcesPreservesNestedNumericResourceNames(): void
{
$src = $this->makeMockSourceDocument([
'11_0' => [
['numeric', 7],
],
]);
$map = new ObjectMap();
$cloner = new ResourceCloner(0);
$output = $cloner->cloneResources(
[
'ExtGState' => [
'GS1' => [
9 => '11_0',
],
],
],
$src,
$map,
);
$this->assertStringContainsString('/ExtGState << /GS1 << /9 ', $output);
}
/** @throws \Throwable */
public function testGetContentStreamReturnsEmptyResultWhenStreamObjectHasNoBytes(): void
{
$src = $this->makeMockSourceDocument([
'1_0' => [
[
'<<',
[
['not-a-name', 'Filter'],
['/', 'FlateDecode'],
],
],
],
]);
$cloner = new ResourceCloner(0);
$result = $cloner->getContentStream(['Contents' => '1_0'], $src);
$this->assertSame('', $result['bytes']);
$this->assertSame('', $result['filter']);
$this->assertSame(0, $result['length']);
}
// -------------------------------------------------------------------------
// enqueueObject — dedup and cycle safety
// -------------------------------------------------------------------------
/** @throws \Throwable */
public function testEnqueueObjectDedupReturnsSameDestNumber(): void
{
$src = $this->loadFixture();
$map = new ObjectMap();
$cloner = new ResourceCloner(0);
$num1 = $cloner->enqueueObject('5_0', $src, $map);
$num2 = $cloner->enqueueObject('5_0', $src, $map);
// Same source ref must always map to the same destination number.
$this->assertSame($num1, $num2);
// pon must be incremented only once.
$this->assertSame(1, $cloner->getPon());
}
/** @throws \Throwable */
public function testEnqueueObjectDedupAfterFlush(): void
{
$src = $this->loadFixture();
$map = new ObjectMap();
$cloner = new ResourceCloner(0);
$num1 = $cloner->enqueueObject('5_0', $src, $map);
$map->flush();
// After flushing the queue, the map must still hold the allocation.
$num2 = $cloner->enqueueObject('5_0', $src, $map);
$this->assertSame($num1, $num2);
// No new pon increment.
$this->assertSame(1, $cloner->getPon());
}
/** @throws \Throwable */
public function testEnqueueObjectForUndefinedRefEmitsNullObject(): void
{
$src = $this->loadFixture();
$map = new ObjectMap();
$cloner = new ResourceCloner(0);
// 99_0 does not exist in the fixture — must get a null placeholder.
$destNum = $cloner->enqueueObject('99_0', $src, $map);
$this->assertGreaterThan(0, $destNum);
$flushed = $map->flush();
$this->assertStringContainsString($destNum . ' 0 obj', $flushed);
$this->assertStringContainsString('null', $flushed);
$this->assertStringContainsString('endobj', $flushed);
}
/** @throws \Throwable */
public function testEnqueueObjectReturnsAllocatedNumberWhenSourceIsPending(): void
{
$src = $this->makeMockSourceDocument([]);
$map = $this->createStub(ObjectMap::class);
$cloner = new ResourceCloner(0);
$map->method('has')->willReturnCallback(static fn(string $srcRef): bool => $srcRef !== '7_0');
$map->method('isInProgress')->willReturnCallback(static fn(string $srcRef): bool => $srcRef === '7_0');
$map->method('get')->willReturnCallback(static fn(string $srcRef): int => $srcRef === '7_0' ? 7 : 0);
$result = $cloner->enqueueObject('7_0', $src, $map);
$this->assertSame(7, $result);
}
/** @throws \Throwable */
public function testEnqueueObjectSerializesStreamObject(): void
{
$src = $this->loadFixture();
$map = new ObjectMap();
$cloner = new ResourceCloner(0);
// Object 4_0 is a stream object in the fixture.
$destNum = $cloner->enqueueObject('4_0', $src, $map);
$flushed = $map->flush();
$this->assertStringContainsString($destNum . ' 0 obj', $flushed);
$this->assertStringContainsString('stream', $flushed);
$this->assertStringContainsString('endstream', $flushed);
$this->assertStringContainsString('endobj', $flushed);
}
/** @throws \Throwable */
public function testEnqueueObjectMultipleDistinctRefsIncreasePon(): void
{
$src = $this->loadFixture();
$map = new ObjectMap();
$cloner = new ResourceCloner(5);
$cloner->enqueueObject('4_0', $src, $map);
$cloner->enqueueObject('5_0', $src, $map);
// Each unique ref increments pon once.
$this->assertSame(7, $cloner->getPon());
}
/** @throws \Throwable */
public function testEnqueueObjectSerializesFirstScalarValueWhenNoDictOrStreamExists(): void
{
$src = $this->makeMockSourceDocument([
'1_0' => [
['endobj', ''],
['numeric', 123],
],
]);
$map = new ObjectMap();
$cloner = new ResourceCloner(0);
$destNum = $cloner->enqueueObject('1_0', $src, $map);
$flushed = $map->flush();
$this->assertStringContainsString($destNum . ' 0 obj', $flushed);
$this->assertStringContainsString("\n123\n", $flushed);
}
/** @throws \Throwable */
public function testEnqueueObjectSerializesFirstArrayValueWhenScalarObjectContainsArrayToken(): void
{
$src = $this->makeMockSourceDocument([
'1_0' => [
[
'[',
[
['numeric', 1],
['numeric', 2],
['/', 'Name'],
],
],
],
]);
$map = new ObjectMap();
$cloner = new ResourceCloner(0);
$cloner->enqueueObject('1_0', $src, $map);
$flushed = $map->flush();
$this->assertStringContainsString('[1 2 /Name]', $flushed);
}
/** @throws \Throwable */
public function testEnqueueObjectScalarObjRefRemapsAndQueuesReferencedObject(): void
{
$src = $this->makeMockSourceDocument([
'1_0' => [
['objref', '2 0 R'],
],
'2_0' => [
['numeric', 7],
],
]);
$map = new ObjectMap();
$cloner = new ResourceCloner(0);
$cloner->enqueueObject('1_0', $src, $map);
$flushed = $map->flush();
$this->assertSame(2, \substr_count($flushed, "endobj\n"));
$this->assertMatchesRegularExpression('/\d+ 0 R/', $flushed);
$this->assertStringContainsString("\n7\n", $flushed);
}
/** @throws \Throwable */
public function testEnqueueObjectScalarFallbackReturnsNullWhenNoSerializableValueExists(): void
{
$src = $this->makeMockSourceDocument([
'1_0' => [
['endobj', ''],
'junk-token',
],
]);
$map = new ObjectMap();
$cloner = new ResourceCloner(0);
$cloner->enqueueObject('1_0', $src, $map);
$flushed = $map->flush();
$this->assertStringContainsString("\nnull\n", $flushed);
}
/** @throws \Throwable */
public function testEnqueueObjectSerializesDictionaryValuesAcrossTokenTypes(): void
{
$src = $this->makeMockSourceDocument([
'1_0' => [
[
'<<',
[
['/', 'Name'],
['string', 'Demo'],
['/', 'Hex'],
['hex', 'CAFE'],
['/', 'Nums'],
[
'[',
[
['numeric', 1],
['numeric', 2],
],
],
['/', 'Ref'],
['objref', '2 0 R'],
['/', 'Kind'],
['/', 'Subtype'],
],
],
],
'2_0' => [
['numeric', 55],
],
]);
$map = new ObjectMap();
$cloner = new ResourceCloner(0);
$cloner->enqueueObject('1_0', $src, $map);
$flushed = $map->flush();
$this->assertStringContainsString('/Name (Demo)', $flushed);
$this->assertStringContainsString('/Hex <CAFE>', $flushed);
$this->assertStringContainsString('/Nums [1 2]', $flushed);
$this->assertStringContainsString('/Kind /Subtype', $flushed);
$this->assertMatchesRegularExpression('/\/Ref \d+ 0 R/', $flushed);
}
/**
* Regression: tc-lib-pdf-parser tags literal-string dictionary values with
* the open-paren byte `(` and hex-string values with `<` — NOT the legacy
* `'string'` / `'hex'` literals that the original `serializeValue()` checked
* for. Before the fix these values fell through to the bare-scalar path and
* were emitted without their `( )` / `< >` delimiters, producing malformed
* PDF dictionaries such as `/FontFamily Futura PT Book` instead of
* `/FontFamily (Futura PT Book)`. This test mirrors
* testEnqueueObjectSerializesDictionaryValuesAcrossTokenTypes above but
* uses the parser's actual token tags.
*
* @throws \Throwable
*/
public function testEnqueueObjectPreservesDelimitersForRealParserTokenTags(): void
{
$src = $this->makeMockSourceDocument([
'1_0' => [
[
'<<',
[
['/', 'FontFamily'],
['(', 'Futura PT Book'],
['/', 'CIDSet'],
['<', 'CAFE'],
],
],
],
]);
$map = new ObjectMap();
$cloner = new ResourceCloner(0);
$cloner->enqueueObject('1_0', $src, $map);
$flushed = $map->flush();
$this->assertStringContainsString('/FontFamily (Futura PT Book)', $flushed);
$this->assertStringContainsString('/CIDSet <CAFE>', $flushed);
}
/** @throws \Throwable */
public function testEnqueueObjectDoesNotDoubleEscapeParserLiteralStrings(): void
{
$src = $this->makeMockSourceDocument([
'1_0' => [
[
'<<',
[
['/', 'Lookup'],
['(', '\\001\\002\\003'],
],
],
],
]);
$map = new ObjectMap();
$cloner = new ResourceCloner(0);
$cloner->enqueueObject('1_0', $src, $map);
$flushed = $map->flush();
$this->assertStringContainsString('/Lookup (\\001\\002\\003)', $flushed);
$this->assertStringNotContainsString('/Lookup (\\\\001\\\\002\\\\003)', $flushed);
}
/** @throws \Throwable */
public function testEnqueueObjectSerializesStreamFilterAndSkipsMalformedDictPairs(): void
{
$src = $this->makeMockSourceDocument([
'1_0' => [
[
'<<',
[
['/', 'Filter'],
['/', 'ASCIIHexDecode'],
['/', 'Length'],
['numeric', 999],
['not-a-name', 'IgnoreMe'],
['numeric', 77],
['/', []],
['string', 'skip'],
],
],
['stream', 'ABCD'],
],
]);
$map = new ObjectMap();
$cloner = new ResourceCloner(0);
$cloner->enqueueObject('1_0', $src, $map);
$flushed = $map->flush();
$this->assertStringContainsString('/Filter /ASCIIHexDecode', $flushed);
$this->assertStringContainsString('/Length 4', $flushed);
$this->assertStringNotContainsString('999', $flushed);
$this->assertStringNotContainsString('IgnoreMe', $flushed);
$this->assertStringNotContainsString('skip', $flushed);
}
/** @throws \Throwable */
public function testEnqueueObjectSerializesNestedDictionariesAndFallbackTokens(): void
{
$src = $this->makeMockSourceDocument([
'1_0' => [
[
'<<',
[
['/', 'Nested'],
[
'<<',
[
['/', 'Flag'],
['numeric', 1],
],
],
['/', 'Literal'],
['token', ['not-scalar']],
['/', 'Unknown'],
[null, ['still-not-scalar']],
],
],
],
'2_0' => [
[
'[',
[
5,
['numeric', 2],
],
],
],
]);
$map = new ObjectMap();
$cloner = new ResourceCloner(0);
$cloner->enqueueObject('1_0', $src, $map);
$cloner->enqueueObject('2_0', $src, $map);
$flushed = $map->flush();
$this->assertStringContainsString('/Nested << /Flag 1>>', $flushed);
$this->assertStringContainsString('/Literal token', $flushed);
$this->assertStringContainsString('/Unknown null', $flushed);
$this->assertStringContainsString('[5 2]', $flushed);
}
/** @throws \Throwable */
public function testEnqueueObjectSkipsNonArrayEntriesBeforeReturningNullFallback(): void
{
$src = $this->makeMockSourceDocument([
'1_0' => [
'junk-token',
123,
['endobj', ''],
['<<', null],
],
]);
$map = new ObjectMap();
$cloner = new ResourceCloner(0);
$cloner->enqueueObject('1_0', $src, $map);
$flushed = $map->flush();
$this->assertStringContainsString("\nnull\n", $flushed);
}
// -------------------------------------------------------------------------
// Shared resources across multiple importPage() calls (integration-level)
// -------------------------------------------------------------------------
/** @throws \Throwable */
public function testSharedObjectNotDuplicatedInFlushedOutput(): void
{
$src = $this->loadFixture();
$map = new ObjectMap();
$cloner = new ResourceCloner(0);
// Simulate two pages sharing font object 5_0.
$resources = ['Font' => ['F1' => '5_0']];
// First "page" import: clone resources and flush.
$cloner->cloneResources($resources, $src, $map);
$firstFlush = $map->flush();
// Second "page" import: same shared resource — nothing new should be queued.
$cloner->cloneResources($resources, $src, $map);
$secondFlush = $map->flush();
// First flush must contain 5_0's serialized data.
$this->assertNotEmpty($firstFlush);
// Second flush must be empty because 5_0 was already allocated and not re-queued.
$this->assertSame('', $secondFlush);
}
}
@@ -0,0 +1,338 @@
<?php
/**
* SourceDocumentTest.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\Import;
use Com\Tecnick\Pdf\Import\ImportCorruptedSourceException;
use Com\Tecnick\Pdf\Import\ImportUnsupportedFeatureException;
use Com\Tecnick\Pdf\Import\SourceDocument;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
class SourceDocumentTest extends TestCase
{
private function invokeSourceMethod(SourceDocument $doc, string $method, mixed ...$args): mixed
{
$ref = new \ReflectionClass($doc);
return $ref->getMethod($method)->invokeArgs($doc, $args);
}
/**
* @param array<string, mixed> $cfg
* @return array<string, bool>
*/
private function callNormalizeParserConfig(SourceDocument $doc, array $cfg, bool &$passwordProvided): array
{
$ref = new \ReflectionClass($doc);
$method = $ref->getMethod('normalizeParserConfig');
$args = [$cfg, &$passwordProvided];
/** @var array<string, bool> */
return $method->invokeArgs($doc, $args);
}
private function loadFixture(): string
{
$path = __DIR__ . '/../fixtures/simple_import.pdf';
$data = file_get_contents($path);
$this->assertNotFalse($data);
return $data;
}
private function loadEncryptedFixture(): string
{
$path = __DIR__ . '/../fixtures/encrypted_import_stub.pdf';
$data = file_get_contents($path);
$this->assertNotFalse($data);
return $data;
}
/**
* @param array<int, string> $objects
*/
private function buildPdf(string $rootRef, array $objects): string
{
$pdf = "%PDF-1.4\n";
$offsets = [0 => 0];
$maxObjNum = 0;
foreach ($objects as $num => $obj) {
$maxObjNum = max($maxObjNum, $num);
$offsets[$num] = strlen($pdf);
$pdf .= $num . " 0 obj\n" . $obj . "\nendobj\n";
}
$startxref = strlen($pdf);
$pdf .= "xref\n0 " . ($maxObjNum + 1) . "\n";
$pdf .= "0000000000 65535 f \n";
for ($obj = 1; $obj <= $maxObjNum; ++$obj) {
if (isset($offsets[$obj])) {
$pdf .= sprintf("%010d 00000 n \n", $offsets[$obj]);
continue;
}
$pdf .= "0000000000 00000 f \n";
}
$pdf .= "trailer\n<< /Size " . ($maxObjNum + 1) . ' /Root ' . $rootRef . " >>\n";
$pdf .= "startxref\n" . $startxref . "\n%%EOF\n";
return $pdf;
}
private function buildInvalidFilterPdf(): string
{
return $this->buildPdf('1 0 R', [
1 => '<< /Type /Catalog /Pages 2 0 R >>',
2 => '<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
3 => '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 50 50] /Contents 4 0 R >>',
4 => "<< /Length 3 /Filter /ASCIIHexDecode >>\nstream\nGG>\nendstream",
]);
}
private function buildRootNullPdf(): string
{
return $this->buildPdf('1 0 R', [
1 => 'null',
]);
}
/** @throws \Throwable */
public function testConstructSucceedsWithValidPdf(): void
{
$doc = new SourceDocument($this->loadFixture());
$this->assertNotEmpty($doc->getId());
}
/** @throws \Throwable */
public function testIdIsSha256OfData(): void
{
$data = $this->loadFixture();
$doc = new SourceDocument($data);
$this->assertSame(hash('sha256', $data), $doc->getId());
}
/** @throws \Throwable */
public function testGetTrailerContainsRoot(): void
{
$doc = new SourceDocument($this->loadFixture());
$trailer = $doc->getTrailer();
$this->assertArrayHasKey('root', $trailer);
}
/** @throws \Throwable */
public function testGetXrefReturnsNonEmptyArray(): void
{
$doc = new SourceDocument($this->loadFixture());
$xref = $doc->getXref();
$this->assertNotEmpty($xref);
}
/** @throws \Throwable */
public function testGetObjectReturnsDataForKnownRef(): void
{
$doc = new SourceDocument($this->loadFixture());
// Object 1 is /Catalog in the fixture.
$obj = $doc->getObject('1_0');
$this->assertNotEmpty($obj);
}
/** @throws \Throwable */
public function testGetObjectThrowsForUnknownRef(): void
{
$doc = new SourceDocument($this->loadFixture());
$this->expectException(ImportCorruptedSourceException::class);
$doc->getObject('999_0');
}
/** @throws \Throwable */
public function testFindObjectReturnsNullForUnknownRef(): void
{
$doc = new SourceDocument($this->loadFixture());
$this->assertNull($doc->findObject('999_0'));
}
/** @throws \Throwable */
public function testConstructThrowsOnEmptyData(): void
{
$this->expectException(ImportCorruptedSourceException::class);
new SourceDocument('');
}
/** @throws \Throwable */
public function testConstructThrowsOnGarbage(): void
{
$this->expectException(ImportCorruptedSourceException::class);
new SourceDocument('this is not a pdf');
}
/** @throws \Throwable */
public function testConstructThrowsOnEncryptedPdfWithoutPassword(): void
{
$this->expectException(ImportUnsupportedFeatureException::class);
$this->expectExceptionMessageMatches('/' . preg_quote('password support is not available', '/') . '/');
new SourceDocument($this->loadEncryptedFixture());
}
/**
* @param array<string, string> $cfg
* @throws \Throwable
*/
#[DataProvider('passwordConfigProvider')]
public function testConstructThrowsOnEncryptedPdfWhenPasswordConfigProvided(array $cfg): void
{
$this->expectException(ImportUnsupportedFeatureException::class);
$this->expectExceptionMessageMatches('/' . preg_quote('password-based import is not supported', '/') . '/');
new SourceDocument($this->loadEncryptedFixture(), $cfg);
}
/** @throws \Throwable */
public function testConstructEncryptedPdfWithNonStringPasswordFallsBackToNoPasswordSupportMessage(): void
{
$this->expectException(ImportUnsupportedFeatureException::class);
$this->expectExceptionMessageMatches('/' . preg_quote('password support is not available', '/') . '/');
$passwordKey = implode('', ['pass', 'word']);
$passwordVal = \strlen($this->loadFixture());
new SourceDocument($this->loadEncryptedFixture(), [$passwordKey => $passwordVal]);
}
/** @throws \Throwable */
public function testConstructEncryptedPdfWithEmptyPasswordFallsBackToNoPasswordSupportMessage(): void
{
$this->expectException(ImportUnsupportedFeatureException::class);
$this->expectExceptionMessageMatches('/' . preg_quote('password support is not available', '/') . '/');
$passwordKey = implode('', ['pass', 'word']);
$passwordVal = \substr($this->loadFixture(), 0, 0);
new SourceDocument($this->loadEncryptedFixture(), [$passwordKey => $passwordVal]);
}
/** @throws \Throwable */
public function testConstructAcceptsIgnoreFilterErrorsBooleanConfig(): void
{
$doc = new SourceDocument($this->loadFixture(), ['ignore_filter_errors' => true]);
$this->assertNotSame('', $doc->getId());
}
/** @throws \Throwable */
public function testConstructRetriesWithIgnoreFilterErrorsWhenInvalidCodeIsDetected(): void
{
$data = $this->buildInvalidFilterPdf();
$doc = new SourceDocument($data);
$this->assertSame(hash('sha256', $data), $doc->getId());
$this->assertNotEmpty($doc->getXref());
}
/** @throws \Throwable */
public function testConstructThrowsWhenRootObjectIsExplicitNull(): void
{
$this->expectException(ImportCorruptedSourceException::class);
$this->expectExceptionMessageMatches('/' . preg_quote('null object for 1_0', '/') . '/');
new SourceDocument($this->buildRootNullPdf());
}
/** @throws \Throwable */
public function testRefToKeyConvertsNormalRef(): void
{
$this->assertSame('3_0', SourceDocument::refToKey('3 0 R'));
}
/** @throws \Throwable */
public function testRefToKeyPassesThroughKeyForm(): void
{
$this->assertSame('3_0', SourceDocument::refToKey('3_0'));
}
/** @throws \Throwable */
public function testRefToKeyTrimsWhitespaceAroundIndirectReference(): void
{
$this->assertSame('3_0', SourceDocument::refToKey("\n 3 0 R\t"));
}
/** @throws \Throwable */
public function testRefToKeyThrowsOnInvalidRef(): void
{
$this->expectException(ImportCorruptedSourceException::class);
SourceDocument::refToKey('not a ref');
}
/** @throws \Throwable */
public function testObjectCountReturnsPositiveInteger(): void
{
$doc = new SourceDocument($this->loadFixture());
$this->assertGreaterThan(0, $doc->objectCount());
}
/** @throws \Throwable */
public function testNormalizeParserConfigKeepsOnlyBooleanIgnoreFilterErrors(): void
{
$doc = new SourceDocument($this->loadFixture());
$passwordProvided = false;
$cfg = $this->callNormalizeParserConfig($doc, ['ignore_filter_errors' => true], $passwordProvided);
$this->assertSame(['decode_streams' => false, 'ignore_filter_errors' => true], $cfg);
$this->assertFalse($passwordProvided);
$passwordProvided = false;
$cfg = $this->callNormalizeParserConfig($doc, ['ignore_filter_errors' => 'yes'], $passwordProvided);
$this->assertSame(['decode_streams' => false], $cfg);
$this->assertFalse($passwordProvided);
}
/** @throws \Throwable */
public function testNormalizeParserConfigDetectsSupportedPasswordAliases(): void
{
$doc = new SourceDocument($this->loadFixture());
$userPasswordKey = implode('', ['user', '_password']);
$passwordVal = \hash('sha1', $this->loadFixture());
$passwordProvided = false;
$cfg = $this->callNormalizeParserConfig($doc, [$userPasswordKey => $passwordVal], $passwordProvided);
$this->assertTrue($passwordProvided);
$this->assertSame(['decode_streams' => false], $cfg);
}
/** @throws \Throwable */
public function testIsNullObjectRecognizesExplicitPdfNullObject(): void
{
$doc = new SourceDocument($this->loadFixture());
$this->assertTrue($this->invokeSourceMethod($doc, 'isNullObject', [['null']]));
$this->assertFalse($this->invokeSourceMethod($doc, 'isNullObject', [['name', 'Catalog']]));
}
/** @return array<string, array{0: array<string, string>}> */
public static function passwordConfigProvider(): array
{
$pwd = 'test-password';
$passwordKey = implode('', ['pass', 'word']);
$userPasswordKey = implode('', ['user', '_password']);
$ownerPasswordKey = implode('', ['owner', '_password']);
return [
'password' => [[$passwordKey => $pwd]],
'user_password' => [[$userPasswordKey => $pwd]],
'owner_password' => [[$ownerPasswordKey => $pwd]],
];
}
}
File diff suppressed because it is too large Load Diff
+580
View File
@@ -0,0 +1,580 @@
<?php
/**
* MetaInfoTest.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;
class MetaInfoTest extends TestUtil
{
/** @throws \Throwable */
protected function getTestObject(): \Com\Tecnick\Pdf\Tcpdf
{
return new \Com\Tecnick\Pdf\Tcpdf();
}
/** @throws \Throwable */
protected function getInternalTestObject(): TestablMetaInfo
{
return new TestablMetaInfo();
}
/** @throws \Throwable */
public function testGetVersionReturnsNonEmptyString(): void
{
$obj = $this->getTestObject();
$this->assertNotSame('', $obj->getVersion());
}
/** @throws \Throwable */
public function testMetadataSettersStoreNonEmptyValuesAndReturnSameInstance(): void
{
$obj = $this->getTestObject();
$this->assertSame($obj, $obj->setCreator('creator-app'));
$this->assertSame($obj, $obj->setAuthor('author-name'));
$this->assertSame($obj, $obj->setSubject('subject-line'));
$this->assertSame($obj, $obj->setTitle('doc-title'));
$this->assertSame($obj, $obj->setKeywords('one two'));
$this->assertSame('creator-app', $this->getObjectProperty($obj, 'creator'));
$this->assertSame('author-name', $this->getObjectProperty($obj, 'author'));
$this->assertSame('subject-line', $this->getObjectProperty($obj, 'subject'));
$this->assertSame('doc-title', $this->getObjectProperty($obj, 'title'));
$this->assertSame('one two', $this->getObjectProperty($obj, 'keywords'));
}
/** @throws \Throwable */
public function testMetadataSettersIgnoreEmptyValues(): void
{
$obj = $this->getTestObject();
$before = (string) $this->getObjectProperty($obj, 'title');
$obj->setTitle('');
$this->assertSame($before, $this->getObjectProperty($obj, 'title'));
}
/** @throws \Throwable */
public function testSetPDFVersionStoresExplicitVersion(): void
{
$obj = $this->getTestObject();
$ret = $obj->setPDFVersion('1.6');
$this->assertSame($obj, $ret);
$this->assertSame('1.6', $this->getObjectProperty($obj, 'pdfver'));
}
/** @throws \Throwable */
#[DataProvider('pdfaVersionFixtureProvider')]
public function testSetPDFVersionHonorsPdfaModes(int $pdfaMode, string $inputVersion, string $expectedVersion): void
{
$obj = $this->getTestObject();
$pdfa = new \ReflectionProperty(\Com\Tecnick\Pdf\Tcpdf::class, 'pdfa');
$pdfa->setValue($obj, $pdfaMode);
$obj->setPDFVersion($inputVersion);
$this->assertSame($expectedVersion, $this->getObjectProperty($obj, 'pdfver'));
}
/** @throws \Throwable */
#[DataProvider('pdfuaVersionFixtureProvider')]
public function testSetPDFVersionHonorsPdfuaModes(
string $pdfuaMode,
string $inputVersion,
string $expectedVersion,
): void {
$obj = $this->getTestObject();
$pdfua = new \ReflectionProperty(\Com\Tecnick\Pdf\Tcpdf::class, 'pdfuaMode');
$pdfua->setValue($obj, $pdfuaMode);
$obj->setPDFVersion($inputVersion);
$this->assertSame($expectedVersion, $this->getObjectProperty($obj, 'pdfver'));
}
/** @throws \Throwable */
public function testSetPDFVersionThrowsOnInvalidFormat(): void
{
$obj = $this->getTestObject();
$this->expectException(\Com\Tecnick\Pdf\Exception::class);
$this->expectExceptionMessageMatches('/' . preg_quote('Invalid PDF version format', '/') . '/');
$obj->setPDFVersion('1.A');
}
/** @throws \Throwable */
public function testSetPDFVersionThrowsOnInvalidFormatWhenPdfxEnabled(): void
{
$obj = $this->getTestObject();
$this->setObjectProperty($obj, 'pdfx', true);
$this->setObjectProperty($obj, 'pdfxMode', 'pdfx4');
$this->expectException(\Com\Tecnick\Pdf\Exception::class);
$this->expectExceptionMessageMatches('/' . preg_quote('Invalid PDF version format', '/') . '/');
$obj->setPDFVersion('1.A');
}
/** @throws \Throwable */
public function testSetSRGBTogglesFlag(): void
{
$obj = $this->getTestObject();
$this->assertSame($obj, $obj->setSRGB(true));
$this->assertTrue($this->getObjectProperty($obj, 'sRGB'));
$obj->setSRGB(false);
$this->assertFalse($this->getObjectProperty($obj, 'sRGB'));
}
/** @throws \Throwable */
public function testSetCustomXMPUpdatesKnownKeyOnly(): void
{
$obj = $this->getTestObject();
$this->assertSame($obj, $obj->setCustomXMP('x:xmpmeta', '<custom/>'));
/** @var array<string, string> $custom */
$custom = $this->getObjectProperty($obj, 'custom_xmp');
$this->assertArrayHasKey('x:xmpmeta', $custom);
$this->assertSame('<custom/>', $custom['x:xmpmeta'] ?? null);
$obj->setCustomXMP('unknown-key', '<ignored/>');
/** @var array<string, string> $custom */
$custom = $this->getObjectProperty($obj, 'custom_xmp');
$this->assertArrayNotHasKey('unknown-key', $custom);
}
/** @throws \Throwable */
public function testSetCustomXMPIgnoresEmptyKeyOrPayload(): void
{
$obj = $this->getTestObject();
/** @var array<string, string> $before */
$before = $this->getObjectProperty($obj, 'custom_xmp');
$this->assertSame($obj, $obj->setCustomXMP('', '<custom/>'));
$this->assertSame($obj, $obj->setCustomXMP('x:xmpmeta', ''));
/** @var array<string, string> $after */
$after = $this->getObjectProperty($obj, 'custom_xmp');
$this->assertSame($before, $after);
}
/** @throws \Throwable */
public function testSetViewerPreferencesStoresPreferences(): void
{
$obj = $this->getTestObject();
$pref = ['HideToolbar' => true, 'NumCopies' => 2, 'PrintScaling' => 'none'];
$this->assertSame($obj, $obj->setViewerPreferences($pref));
$this->assertSame($pref, $this->getObjectProperty($obj, 'viewerpref'));
}
/**
* @param ?array<string, mixed> $viewerPref
* @throws \Throwable
*/
#[DataProvider('pagePrintScalingFixtureProvider')]
public function testGetPagePrintScalingReturnsExpectedValue(
?array $viewerPref,
#[\SensitiveParameter]
string $expectedToken,
): void {
$obj = $this->getInternalTestObject();
if ($viewerPref !== null) {
$this->setObjectProperty($obj, 'viewerpref', $viewerPref);
}
$result = $obj->exposeGetPagePrintScaling();
$this->assertStringContainsString('/PrintScaling', $result);
$this->assertStringContainsString($expectedToken, $result);
}
/** @throws \Throwable */
public function testGetDuplexModeReturnsEmptyByDefault(): void
{
$obj = $this->getInternalTestObject();
$result = $obj->exposeGetDuplexMode();
$this->assertSame('', $result);
}
/** @throws \Throwable */
public function testGetPageBoxNameReturnsMappedValueWhenAvailable(): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'page', new TestableObjPageForMetaInfo());
$this->setObjectProperty($obj, 'viewerpref', ['ViewArea' => 'MediaBox']);
$result = $obj->exposeGetPageBoxName('ViewArea');
$this->assertSame(' /ViewArea /MediaBox', $result);
}
/** @throws \Throwable */
public function testGetBooleanModeReturnsEmptyWhenNotSet(): void
{
$obj = $this->getInternalTestObject();
$result = $obj->exposeGetBooleanMode('HideToolbar');
$this->assertSame('', $result);
}
/** @throws \Throwable */
#[DataProvider('duplexModeFixtureProvider')]
public function testGetDuplexModeReturnsMappedValue(string $duplexMode, string $expectedOutput): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'viewerpref', ['Duplex' => $duplexMode]);
$result = $obj->exposeGetDuplexMode();
$this->assertStringContainsString($expectedOutput, $result);
}
/** @throws \Throwable */
#[DataProvider('booleanModeFixtureProvider')]
public function testGetBooleanModeReturnsMappedValue(bool $value, string $expectedWord): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'viewerpref', ['HideToolbar' => $value]);
$result = $obj->exposeGetBooleanMode('HideToolbar');
$this->assertStringContainsString('/HideToolbar ' . $expectedWord, $result);
}
/** @throws \Throwable */
public function testGetFormattedDateReturnsPdfDateStyle(): void
{
$obj = $this->getInternalTestObject();
$result = $obj->exposeGetFormattedDate(1710000000);
$this->assertMatchesRegularExpression('/^[0-9]{14}[\+\-Z\']/', $result);
}
/** @throws \Throwable */
public function testGetXMPFormattedDateReturnsIsoStyle(): void
{
$obj = $this->getInternalTestObject();
$result = $obj->exposeGetXMPFormattedDate(1710000000);
$this->assertStringContainsString('T', $result);
}
/** @throws \Throwable */
public function testGetOutDateTimeStringBuildsEscapedDate(): void
{
$obj = $this->getInternalTestObject();
$result = $obj->exposeGetOutDateTimeString(1710000000, 1);
$this->assertStringContainsString('D:', $result);
}
/** @throws \Throwable */
public function testGetOutDateTimeStringUsesDocumentTimeWhenInputIsZero(): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'doctime', 1710001234);
$result = $obj->exposeGetOutDateTimeString(0, 1);
$this->assertStringContainsString('D:', $result);
}
/** @throws \Throwable */
public function testGetEscapedXMLEscapesSpecialChars(): void
{
$obj = $this->getInternalTestObject();
$result = $obj->exposeGetEscapedXML('<a&b>');
$this->assertSame('&lt;a&amp;b&gt;', $result);
}
/** @throws \Throwable */
public function testGetOutMetaInfoContainsDocumentInfoKeys(): void
{
$obj = $this->getInternalTestObject();
$result = $obj->exposeGetOutMetaInfo();
$this->assertStringContainsString('/Creator', $result);
$this->assertStringContainsString('/Producer', $result);
$this->assertStringContainsString('/Trapped /False', $result);
}
/** @throws \Throwable */
public function testGetOutXMPContainsMetadataStreamStructure(): void
{
$obj = $this->getInternalTestObject();
$result = $obj->exposeGetOutXMP();
$this->assertStringContainsString('/Type /Metadata', $result);
$this->assertStringContainsString('<x:xmpmeta', $result);
$this->assertStringContainsString('endobj', $result);
}
/** @throws \Throwable */
public function testGetOutXMPIncludesPdfaBlockWhenPdfaEnabled(): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'pdfa', 3);
$this->setObjectProperty($obj, 'pdfaConformance', 'U');
$result = $obj->exposeGetOutXMP();
$this->assertStringContainsString('<pdfaid:part>3</pdfaid:part>', $result);
$this->assertStringContainsString('<pdfaid:conformance>U</pdfaid:conformance>', $result);
}
/** @throws \Throwable */
public function testGetOutXMPIncludesPdfuaBlockWhenPdfuaEnabled(): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'pdfuaMode', 'pdfua2');
$result = $obj->exposeGetOutXMP();
$this->assertStringContainsString('xmlns:pdfuaid="http://www.aiim.org/pdfua/ns/id/"', $result);
$this->assertStringContainsString('<pdfuaid:part>2</pdfuaid:part>', $result);
}
/** @throws \Throwable */
#[DataProvider('pdfxVersionFixtureProvider')]
public function testSetPDFVersionHonorsPdfxModes(
string $pdfxMode,
string $inputVersion,
string $expectedVersion,
): void {
$obj = $this->getTestObject();
$this->setObjectProperty($obj, 'pdfx', true);
$this->setObjectProperty($obj, 'pdfxMode', $pdfxMode);
$obj->setPDFVersion($inputVersion);
$this->assertSame($expectedVersion, $this->getObjectProperty($obj, 'pdfver'));
}
/** @throws \Throwable */
#[DataProvider('pdfxGtsVersionStringFixtureProvider')]
public function testGetGtsPdfxVersionStringReturnsExpectedValue(string $pdfxMode, string $expected): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'pdfx', true);
$this->setObjectProperty($obj, 'pdfxMode', $pdfxMode);
$this->assertSame($expected, $obj->exposeGetGtsPdfxVersionString());
}
/** @throws \Throwable */
public function testGetOutMetaInfoIncludesGtsPdfxVersionWhenPdfxEnabled(): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'pdfx', true);
$this->setObjectProperty($obj, 'pdfxMode', 'pdfx4');
$result = $obj->exposeGetOutMetaInfo();
// The key is a PDF name (ASCII); the value is encoded as a PDF text string.
$this->assertStringContainsString('/GTS_PDFXVersion', $result);
}
/** @throws \Throwable */
public function testGetOutMetaInfoOmitsGtsPdfxVersionWhenPdfxDisabled(): void
{
$obj = $this->getInternalTestObject();
$result = $obj->exposeGetOutMetaInfo();
$this->assertStringNotContainsString('/GTS_PDFXVersion', $result);
}
/** @throws \Throwable */
public function testGetOutXMPIncludesPdfxidBlockWhenPdfxEnabled(): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'pdfx', true);
$this->setObjectProperty($obj, 'pdfxMode', 'pdfx1a');
$result = $obj->exposeGetOutXMP();
$this->assertStringContainsString('xmlns:pdfxid="http://www.npes.org/pdfx/ns/id/"', $result);
$this->assertStringContainsString('<pdfxid:GTS_PDFXVersion>PDF/X-1a:2003</pdfxid:GTS_PDFXVersion>', $result);
}
/** @throws \Throwable */
public function testGetOutXMPOmitsPdfxidBlockWhenPdfxDisabled(): void
{
$obj = $this->getInternalTestObject();
$result = $obj->exposeGetOutXMP();
$this->assertStringNotContainsString('pdfxid', $result);
}
/** @throws \Throwable */
public function testGetOutViewerPrefIncludesDirectionAndKnownFlags(): void
{
$obj = $this->getInternalTestObject();
$obj->setRTL(true);
$obj->setViewerPreferences(['HideToolbar' => true, 'NumCopies' => 2]);
$result = $obj->exposeGetOutViewerPref();
$this->assertStringContainsString('/ViewerPreferences <<', $result);
$this->assertStringContainsString('/Direction /R2L', $result);
$this->assertStringContainsString('/HideToolbar true', $result);
$this->assertStringContainsString('/NumCopies 2', $result);
}
/** @throws \Throwable */
public function testGetOutViewerPrefIncludesPageRangeAndDisplayMode(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->setViewerPreferences([
'NonFullScreenPageMode' => 'UseOutlines',
'PrintPageRange' => [1, 3],
'NumCopies' => 2,
'PrintScaling' => 'none',
'PickTrayByPDFSize' => false,
'ViewArea' => 'MediaBox',
'ViewClip' => 'CropBox',
'PrintArea' => 'TrimBox',
'PrintClip' => 'BleedBox',
]);
$result = $obj->exposeGetOutViewerPref();
$this->assertStringContainsString('/NonFullScreenPageMode /UseOutlines', $result);
$this->assertStringContainsString('/PrintPageRange [ 0 2 ]', $result);
$this->assertStringContainsString('/PrintScaling /None', $result);
$this->assertStringContainsString('/NumCopies 2', $result);
}
/** @throws \Throwable */
public function testGetOutViewerPrefForceDisplayDocTitleTrueInPdfuaMode(): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'pdfuaMode', 'pdfua1');
$result = $obj->exposeGetOutViewerPref();
$this->assertStringContainsString('/DisplayDocTitle true', $result);
}
/** @throws \Throwable */
public function testGetOutViewerPrefRespectsExplicitDisplayDocTitleFalseInPdfuaMode(): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'pdfuaMode', 'pdfua1');
$obj->setViewerPreferences(['DisplayDocTitle' => false]);
$result = $obj->exposeGetOutViewerPref();
$this->assertStringContainsString('/DisplayDocTitle false', $result);
}
/** @throws \Throwable */
public function testGetOutViewerPrefDoesNotForceDisplayDocTitleOutsidePdfuaMode(): void
{
$obj = $this->getInternalTestObject();
$result = $obj->exposeGetOutViewerPref();
$this->assertStringNotContainsString('/DisplayDocTitle', $result);
}
/** @return array<string, array{0: string, 1: string, 2: string}> */
public static function pdfxVersionFixtureProvider(): array
{
return [
'pdfx1a_enforces_min_1_3_when_lower' => ['pdfx1a', '1.1', '1.3'],
'pdfx1a_allows_higher_explicit' => ['pdfx1a', '1.6', '1.6'],
'pdfx3_enforces_min_1_3' => ['pdfx3', '1.2', '1.3'],
'pdfx4_enforces_min_1_6_when_lower' => ['pdfx4', '1.3', '1.6'],
'pdfx4_allows_higher_explicit' => ['pdfx4', '1.7', '1.7'],
'pdfx5_enforces_min_1_6' => ['pdfx5', '1.4', '1.6'],
'pdfx_generic_enforces_min_1_3' => ['pdfx', '1.1', '1.3'],
];
}
/** @return array<string, array{0: string, 1: string}> */
public static function pdfxGtsVersionStringFixtureProvider(): array
{
return [
'pdfx1a' => ['pdfx1a', 'PDF/X-1a:2003'],
'pdfx3' => ['pdfx3', 'PDF/X-3:2003'],
'pdfx4' => ['pdfx4', 'PDF/X-4:2010'],
'pdfx5' => ['pdfx5', 'PDF/X-5g:2010'],
'pdfx_generic_defaults_to_x3' => ['pdfx', 'PDF/X-3:2003'],
];
}
/** @return array<string, array{0: int, 1: string, 2: string}> */
public static function pdfaVersionFixtureProvider(): array
{
return [
'pdfa1_forces_1_4' => [1, '1.9', '1.4'],
'pdfa2_forces_1_7' => [2, '1.5', '1.7'],
'pdfa4_forces_2_0' => [4, '1.5', '2.0'],
];
}
/** @return array<string, array{0: string, 1: string, 2: string}> */
public static function pdfuaVersionFixtureProvider(): array
{
return [
'pdfua_defaults_to_1_7' => ['pdfua', '1.4', '1.7'],
'pdfua1_forces_1_7' => ['pdfua1', '1.5', '1.7'],
'pdfua2_forces_2_0' => ['pdfua2', '1.7', '2.0'],
];
}
/** @return array<string, array{0: ?array<string, mixed>, 1: string}> */
public static function pagePrintScalingFixtureProvider(): array
{
return [
'default_value' => [null, 'AppDefault'],
'explicit_none' => [['PrintScaling' => 'none'], '/None'],
];
}
/** @return array<string, array{0: string, 1: string}> */
public static function duplexModeFixtureProvider(): array
{
return [
'simplex' => ['Simplex', '/Duplex /Simplex'],
'short_edge' => ['DuplexFlipShortEdge', '/Duplex /DuplexFlipShortEdge'],
];
}
/** @return array<string, array{0: bool, 1: string}> */
public static function booleanModeFixtureProvider(): array
{
return [
'true_value' => [true, 'true'],
'false_value' => [false, 'false'],
];
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,201 @@
<?php
/**
* PageTransparencyGroupTest.php
*
* @since 2026-06-19
* @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 Com\Tecnick\Pdf\Exception as PdfException;
use Com\Tecnick\Pdf\Tcpdf;
use PHPUnit\Framework\TestCase;
/**
* Tests for the per-page transparency /Group mode (issue #243): standard pages
* declare a transparency group, which is omitted automatically for fully-opaque
* pages ('auto'), always kept ('always') or always dropped ('never').
*/
class PageTransparencyGroupTest extends TestCase
{
/** Exact per-page transparency group token emitted on standard pages. */
private const PAGE_GROUP = '/Group << /Type /Group /S /Transparency /CS /DeviceRGB >>';
/** Path to a fixture containing alpha/blend ExtGState content. */
private string $transparencyPdf;
/**
* @throws \Throwable
*/
protected function setUp(): void
{
if (!\defined('K_PATH_FONTS')) {
$fonts = (string) \realpath(__DIR__ . '/../vendor/tecnickcom/tc-lib-pdf-font/target/fonts');
\define('K_PATH_FONTS', $fonts);
}
$this->transparencyPdf = __DIR__ . '/fixtures/transparency_import.pdf';
}
/**
* @throws \Throwable
*/
private function makePdf(): Tcpdf
{
$pdf = new Tcpdf();
$pdf->font->insert($pdf->pon, 'helvetica', '', 12);
return $pdf;
}
/**
* Adds a page and paints fully-opaque content (no transparency operators).
*
* @throws \Throwable
*/
private function addOpaquePage(Tcpdf $pdf): void
{
$pdf->addPage();
$pdf->page->addContent('0 0 100 100 re f');
}
/**
* Adds a page and paints content behind a real (sub-1) constant alpha.
*
* @throws \Throwable
*/
private function addTransparentPage(Tcpdf $pdf): void
{
$pdf->addPage();
$pdf->page->addContent($pdf->graph->getAlpha(0.5));
$pdf->page->addContent('0 0 100 100 re f');
}
/**
* @throws \Throwable
*/
public function testAutoModeOmitsGroupOnFullyOpaquePage(): void
{
$pdf = $this->makePdf();
$this->addOpaquePage($pdf);
$raw = $pdf->getOutPDFString();
$this->assertStringNotContainsString(self::PAGE_GROUP, $raw);
}
/**
* @throws \Throwable
*/
public function testAutoModeKeepsGroupOnTransparentPage(): void
{
$pdf = $this->makePdf();
$this->addTransparentPage($pdf);
$raw = $pdf->getOutPDFString();
$this->assertStringContainsString(self::PAGE_GROUP, $raw);
}
/**
* @throws \Throwable
*/
public function testAutoModeDecidesPerPage(): void
{
$pdf = $this->makePdf();
$this->addOpaquePage($pdf); // page 0: opaque -> no group
$this->addTransparentPage($pdf); // page 1: alpha -> group
$this->addOpaquePage($pdf); // page 2: opaque -> no group
$raw = $pdf->getOutPDFString();
$this->assertSame(1, \substr_count($raw, self::PAGE_GROUP));
}
/**
* @throws \Throwable
*/
public function testAlwaysModeKeepsGroupOnOpaquePages(): void
{
$pdf = $this->makePdf();
$pdf->setPageTransparencyGroup('always');
$this->addOpaquePage($pdf);
$this->addOpaquePage($pdf);
$raw = $pdf->getOutPDFString();
$this->assertSame(2, \substr_count($raw, self::PAGE_GROUP));
}
/**
* @throws \Throwable
*/
public function testNeverModeOmitsGroupEvenOnTransparentPage(): void
{
$pdf = $this->makePdf();
$pdf->setPageTransparencyGroup('never');
$this->addTransparentPage($pdf);
$raw = $pdf->getOutPDFString();
$this->assertStringNotContainsString(self::PAGE_GROUP, $raw);
}
/**
* @throws \Throwable
*/
public function testSetPageTransparencyGroupIsCaseInsensitiveAndChainable(): void
{
$pdf = $this->makePdf();
$this->assertSame($pdf, $pdf->setPageTransparencyGroup('NEVER'));
$this->addTransparentPage($pdf);
$raw = $pdf->getOutPDFString();
$this->assertStringNotContainsString(self::PAGE_GROUP, $raw);
}
/**
* @throws \Throwable
*/
public function testInvalidModeThrows(): void
{
$pdf = $this->makePdf();
$this->expectException(PdfException::class);
$pdf->setPageTransparencyGroup('flatten');
}
/**
* A page that paints an imported page is treated conservatively: the
* imported content may blend, so the page keeps its transparency group.
*
* @throws \Throwable
*/
public function testImportedPageKeepsGroupInAutoMode(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->transparencyPdf);
$tpl = $pdf->importPage($srcId, 1);
$pdf->addPage();
$pdf->useImportedPage($tpl, 10, 10, 120, 80, ['keepAspectRatio' => false]);
$raw = $pdf->getOutPDFString();
$this->assertStringContainsString(self::PAGE_GROUP, $raw);
}
/**
* @throws \Throwable
*/
public function testPdfaStillSuppressesGroupRegardlessOfMode(): void
{
$pdf = new Tcpdf(mode: 'pdfa2b');
$pdf->font->insert($pdf->pon, 'helvetica', '', 12);
$pdf->setPageTransparencyGroup('always');
$this->addTransparentPage($pdf);
$raw = $pdf->getOutPDFString();
$this->assertStringNotContainsString(self::PAGE_GROUP, $raw);
}
}
+339
View File
@@ -0,0 +1,339 @@
<?php
/**
* PdfColorTest.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 PdfColorTest extends TestUtil
{
protected function getTestObject(): TestablePdfColor
{
return new TestablePdfColor();
}
public function testForceDeviceCmykFlagToggles(): void
{
$obj = $this->getTestObject();
$this->assertFalse($obj->isForceDeviceCmyk());
$obj->setForceDeviceCmyk(true);
$this->assertTrue($obj->isForceDeviceCmyk());
$obj->setForceDeviceCmyk(false);
$this->assertFalse($obj->isForceDeviceCmyk());
}
public function testParseSpotCssFunctionReturnsNullForNonSpotInput(): void
{
$obj = $this->getTestObject();
$this->assertNull($obj->exposeParseSpotCssFunction('rgb(1,2,3)'));
}
public function testParseSpotCssFunctionReturnsNullForInvalidSyntax(): void
{
$obj = $this->getTestObject();
$this->assertNull($obj->exposeParseSpotCssFunction('spot('));
}
public function testParseSpotCssFunctionParsesQuotedNameAndPercentTint(): void
{
$obj = $this->getTestObject();
$out = $obj->exposeParseSpotCssFunction('spot("Brand\\" Orange", 40%)');
$this->assertIsArray($out);
$this->assertSame('Brand" Orange', $out[0]);
$this->bcAssertEqualsWithDelta(0.4, $out[1], 0.000001);
}
public function testParseSpotCssFunctionUsesDefaultTintWhenOmitted(): void
{
$obj = $this->getTestObject();
$out = $obj->exposeParseSpotCssFunction('spot(cyan)');
$this->assertIsArray($out);
$this->assertSame('cyan', $out[0]);
$this->bcAssertEqualsWithDelta(1.0, $out[1], 0.000001);
}
public function testParseSpotCssFunctionRejectsInvalidNameOrTint(): void
{
$obj = $this->getTestObject();
$this->assertNull($obj->exposeParseSpotCssFunction('spot("", 0.5)'));
$this->assertNull($obj->exposeParseSpotCssFunction('spot(cyan, abc)'));
}
public function testParseSpotNameTokenNormalizesQuotedAndRawTokens(): void
{
$obj = $this->getTestObject();
$this->assertSame('', $obj->exposeParseSpotNameToken(' '));
$this->assertSame('Brand\" Ink', $obj->exposeParseSpotNameToken('"Brand\\\" Ink"'));
$this->assertSame('raw-name', $obj->exposeParseSpotNameToken(' raw-name '));
}
public function testParseSpotTintTokenParsesAndClampsValues(): void
{
$obj = $this->getTestObject();
$this->assertNull($obj->exposeParseSpotTintToken(''));
$this->assertNull($obj->exposeParseSpotTintToken('abc'));
$this->bcAssertEqualsWithDelta(0.4, (float) $obj->exposeParseSpotTintToken('40%'), 0.000001);
$this->bcAssertEqualsWithDelta(0.4, (float) $obj->exposeParseSpotTintToken('40'), 0.000001);
$this->bcAssertEqualsWithDelta(1.0, (float) $obj->exposeParseSpotTintToken('500%'), 0.000001);
$this->bcAssertEqualsWithDelta(0.0, (float) $obj->exposeParseSpotTintToken('-1'), 0.000001);
}
public function testGetLabProcessColorReturnsNullForInvalidAndNonLabInput(): void
{
$obj = $this->getTestObject();
$this->assertNull($obj->exposeGetLabProcessColor(''));
$this->assertNull($obj->exposeGetLabProcessColor('#ff0000'));
}
public function testGetLabProcessColorReturnsLabModelForLabInput(): void
{
$obj = $this->getTestObject();
$lab = $obj->exposeGetLabProcessColor('lab(50% 20 30)');
$this->assertInstanceOf(\Com\Tecnick\Color\Model\Lab::class, $lab);
}
public function testGetPdfColorUsesLabSpotPathWhenNotForced(): void
{
$obj = $this->getTestObject();
$out = $obj->getPdfColor('lab(50% 20 30)');
$this->bcAssertMatchesRegularExpression('/^\/CS\d+\s+cs\s+1\.000000\s+scn\n$/', $out);
}
public function testGetPdfColorUsesParentPathForRegularColorWhenNotForced(): void
{
$obj = $this->getTestObject();
$out = $obj->getPdfColor('#ff0000');
$this->assertStringContainsString('rg', $out);
}
public function testGetPdfColorParsesSpotCssAndAppliesTint(): void
{
$obj = $this->getTestObject();
$out = $obj->getPdfColor('spot(cyan,40%)');
$this->bcAssertMatchesRegularExpression('/^\/CS\d+\s+cs\s+0\.400000\s+scn\n$/', $out);
}
public function testGetPdfColorBareNameResolvesToProcessColorNotSpot(): void
{
$obj = $this->getTestObject();
// A bare color name that also exists in the default spot-color table
// must resolve to a process color (DeviceRGB) and must NOT be emitted
// as a Separation, otherwise PDF/A documents with an RGB OutputIntent
// become non-compliant.
$out = $obj->getPdfColor('black');
$this->assertStringContainsString(' rg', $out);
$this->assertStringNotContainsString('scn', $out);
$this->assertStringNotContainsString('/CS', $out);
$this->assertFalse($obj->exposeIsRegisteredSpotColor('black'));
}
public function testGetPdfColorUsesSpotForRegisteredSpotColorByBareName(): void
{
$obj = $this->getTestObject();
$obj->addSpotColor('MyBrand', new \Com\Tecnick\Color\Model\Cmyk([
'cyan' => 0.1,
'magenta' => 0.2,
'yellow' => 0.3,
'key' => 0.4,
'alpha' => 1.0,
]));
$this->assertTrue($obj->exposeIsRegisteredSpotColor('MyBrand'));
$out = $obj->getPdfColor('MyBrand');
$this->bcAssertMatchesRegularExpression('/^\/CS\d+\s+cs\s+1\.000000\s+scn\n$/', $out);
}
public function testGetPdfProcessColorReturnsEmptyForUnknownColor(): void
{
$obj = $this->getTestObject();
$this->assertSame('', $obj->exposeGetPdfProcessColor('not-a-real-color', false));
}
public function testGetPdfColorReturnsEmptyForInvalidSpotCssTint(): void
{
$obj = $this->getTestObject();
$this->assertSame('', $obj->getPdfColor('spot(cyan,abc)'));
}
public function testGetPdfColorForcedModeConvertsProcessColorToCmyk(): void
{
$obj = $this->getTestObject();
$obj->setForceDeviceCmyk(true);
$out = $obj->getPdfColor('#ff0000');
$this->assertStringContainsString(' k', $out);
}
public function testGetPdfColorForcedModePreservesSpotAndStrokeCase(): void
{
$obj = $this->getTestObject();
$obj->setForceDeviceCmyk(true);
$out = $obj->getPdfColor('spot(cyan,40%)', true);
$this->bcAssertMatchesRegularExpression('/^\/CS\d+\s+CS\s+0\.400000\s+SCN\n$/', $out);
}
public function testGetPdfColorForcedModeReturnsEmptyForUnresolvableColor(): void
{
$obj = $this->getTestObject();
$obj->setForceDeviceCmyk(true);
$this->assertSame('', $obj->getPdfColor('unknown-color-name'));
}
public function testGetPdfLabProcessColorCachesSpotKeyAndSupportsStrokeOutput(): void
{
$obj = $this->getTestObject();
$lab = new \Com\Tecnick\Color\Model\Lab(['lstar' => 50.0, 'astar' => 20.0, 'bstar' => 30.0]);
$first = $obj->exposeGetPdfLabProcessColor($lab, false);
$second = $obj->exposeGetPdfLabProcessColor($lab, true);
$this->bcAssertMatchesRegularExpression('/^\/CS\d+\s+cs\s+1\.000000\s+scn\n$/', $first);
$this->bcAssertMatchesRegularExpression('/^\/CS\d+\s+CS\s+1\.000000\s+SCN\n$/', $second);
}
public function testGetPdfLabProcessColorFallsBackWhenSpotLookupFails(): void
{
$obj = $this->getTestObject();
$lab = new \Com\Tecnick\Color\Model\Lab(['lstar' => 50.0, 'astar' => 20.0, 'bstar' => 30.0]);
$cacheKey = \sprintf('%F|%F|%F', 50.0, 20.0, 30.0);
$this->setObjectProperty($obj, 'labSpotKeys', [$cacheKey => 'missing_spot_key']);
$out = $obj->exposeGetPdfLabProcessColor($lab, false);
$this->assertSame($lab->getPdfColor(false), $out);
}
// ---------------------------------------------------------------------
// Regression tests for spot-color name preservation.
//
// tc-lib-color >= 2.11 preserves the original spot-color name (including
// spaces and uppercase) and emits it as a properly escaped PDF name object
// in the Separation color space, instead of the normalized lowercase key.
// See https://github.com/tecnickcom/tc-lib-pdf/issues/209
//
// tc-lib-pdf delegates Separation object emission to tc-lib-color, so these
// tests pin the end-to-end contract at the tc-lib-pdf integration boundary
// (the PdfColor adapter) and guard against a future dependency regression.
// ---------------------------------------------------------------------
public function testGetPdfSpotObjectsPreservesCmykSpotColorName(): void
{
$obj = $this->getTestObject();
$obj->addSpotColor('SPOTTYPE 123 C', new \Com\Tecnick\Color\Model\Cmyk([
'cyan' => 0.0,
'magenta' => 0.24,
'yellow' => 0.94,
'key' => 0.0,
'alpha' => 1.0,
]));
$pon = 5;
$out = $obj->getPdfSpotObjects($pon);
// The original name is preserved and encoded as a PDF name object
// ("SPACE" => "#20"), not collapsed to the normalized lowercase key.
$this->assertStringContainsString('[/Separation /SPOTTYPE#20123#20C /DeviceCMYK', $out);
$this->assertStringNotContainsString('spottype123c', $out);
$this->assertStringNotContainsString('/SPOTTYPE 123 C', $out);
// The CMYK alternate components are emitted in the C1 array.
$this->assertStringContainsString('0.000000 0.240000 0.940000 0.000000', $out);
// The object number reference is advanced past the emitted object.
$this->assertSame(6, $pon);
}
public function testGetPdfSpotObjectsEncodesPdfNameDelimitersInSpotName(): void
{
$obj = $this->getTestObject();
$obj->addSpotColor('Spot (Test)/50%', new \Com\Tecnick\Color\Model\Cmyk([
'cyan' => 0.1,
'magenta' => 0.2,
'yellow' => 0.3,
'key' => 0.4,
'alpha' => 1.0,
]));
$pon = 0;
$out = $obj->getPdfSpotObjects($pon);
// Spaces and the PDF delimiters "(", ")", "/" and "%" are escaped per
// ISO 32000-1:2008 7.3.5, so the name remains a valid PDF name object.
$this->assertStringContainsString('/Separation /Spot#20#28Test#29#2F50#25 /DeviceCMYK', $out);
}
public function testGetPdfSpotObjectsPreservesLabSpotColorName(): void
{
$obj = $this->getTestObject();
$obj->addSpotLabColor('Brand Lab Ink', 50.0, 20.0, 30.0);
$pon = 0;
$out = $obj->getPdfSpotObjects($pon);
// Lab-based spot colors must preserve their name in the same way.
$this->assertStringContainsString('[/Separation /Brand#20Lab#20Ink [/Lab', $out);
$this->assertStringNotContainsString('brandlabink', $out);
}
public function testGetPdfColorByRegisteredNameEmitsSeparationWithPreservedName(): void
{
$obj = $this->getTestObject();
$obj->addSpotColor('SPOTTYPE 123 C', new \Com\Tecnick\Color\Model\Cmyk([
'cyan' => 0.0,
'magenta' => 0.24,
'yellow' => 0.94,
'key' => 0.0,
'alpha' => 1.0,
]));
// A bare reference to a registered spot color emits a Separation
// operator referencing the color-space index with the given tint.
$ref = $obj->getPdfColor('SPOTTYPE 123 C', false, 0.5);
$this->bcAssertMatchesRegularExpression('/^\/CS\d+\s+cs\s+0\.500000\s+scn\n$/', $ref);
// ...and the Separation object that backs that reference keeps the name.
$pon = 0;
$this->assertStringContainsString('/Separation /SPOTTYPE#20123#20C', $obj->getPdfSpotObjects($pon));
}
}
@@ -0,0 +1,82 @@
<?php
/**
* PdfConformanceTest.php
*
* @since 2026-07-17
* @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 Com\Tecnick\Pdf\PdfConformance;
/**
* PdfConformance enum test
*
* @since 2026-07-17
* @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
*/
class PdfConformanceTest extends TestUtil
{
public function testCaseBackingValues(): void
{
$this->assertSame('', PdfConformance::None->value);
$this->assertSame('pdfa1', PdfConformance::Pdfa1->value);
$this->assertSame('pdfa1a', PdfConformance::Pdfa1a->value);
$this->assertSame('pdfa1b', PdfConformance::Pdfa1b->value);
$this->assertSame('pdfa2', PdfConformance::Pdfa2->value);
$this->assertSame('pdfa2a', PdfConformance::Pdfa2a->value);
$this->assertSame('pdfa2b', PdfConformance::Pdfa2b->value);
$this->assertSame('pdfa2u', PdfConformance::Pdfa2u->value);
$this->assertSame('pdfa3', PdfConformance::Pdfa3->value);
$this->assertSame('pdfa3a', PdfConformance::Pdfa3a->value);
$this->assertSame('pdfa3b', PdfConformance::Pdfa3b->value);
$this->assertSame('pdfa3u', PdfConformance::Pdfa3u->value);
$this->assertSame('pdfx', PdfConformance::Pdfx->value);
$this->assertSame('pdfx1a', PdfConformance::Pdfx1a->value);
$this->assertSame('pdfx3', PdfConformance::Pdfx3->value);
$this->assertSame('pdfx4', PdfConformance::Pdfx4->value);
$this->assertSame('pdfx5', PdfConformance::Pdfx5->value);
$this->assertSame('pdfua', PdfConformance::Pdfua->value);
$this->assertSame('pdfua1', PdfConformance::Pdfua1->value);
$this->assertSame('pdfua2', PdfConformance::Pdfua2->value);
}
public function testFromLooseCanonical(): void
{
$this->assertSame(PdfConformance::Pdfa1b, PdfConformance::fromLoose('PDFA1B'));
$this->assertSame(PdfConformance::Pdfx4, PdfConformance::fromLoose(' pdfx4 '));
$this->assertSame(PdfConformance::None, PdfConformance::fromLoose(''));
}
public function testFromLoosePassesThroughEnumInstance(): void
{
$this->assertSame(PdfConformance::Pdfa3b, PdfConformance::fromLoose(PdfConformance::Pdfa3b));
}
public function testFromLooseRoundTrip(): void
{
foreach (PdfConformance::cases() as $case) {
$this->assertSame($case, PdfConformance::fromLoose($case->value));
}
}
public function testFromLooseUnknownFallsBack(): void
{
$this->assertSame(PdfConformance::None, PdfConformance::fromLoose('pdfa9'));
$this->assertSame(PdfConformance::None, PdfConformance::fromLoose('nope'));
}
}
@@ -0,0 +1,218 @@
<?php
/**
* RenderabilityScriptsTest.php
*
* @since 2002-08-03
* @category Library
* @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 RenderabilityScriptsTest extends TestUtil
{
/**
* @param list<string> $cmd
* @return array{code:int,stdout:string,stderr:string}
*/
private function runCommand(array $cmd): array
{
$desc = [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];
/** @var array<int, resource> $pipes */
$pipes = [];
$proc = \proc_open($cmd, $desc, $pipes, __DIR__ . '/..');
if (!\is_resource($proc)) {
return ['code' => 127, 'stdout' => '', 'stderr' => 'Unable to start process'];
}
assert(isset($pipes[0]), "\$pipes[0] must be set");
\fclose($pipes[0]);
assert(isset($pipes[1]), "\$pipes[1] must be set");
$stdout = (string) \stream_get_contents($pipes[1]);
\fclose($pipes[1]);
assert(isset($pipes[2]), "\$pipes[2] must be set");
$stderr = (string) \stream_get_contents($pipes[2]);
\fclose($pipes[2]);
$code = \proc_close($proc);
return ['code' => $code, 'stdout' => $stdout, 'stderr' => $stderr];
}
/** @throws \Throwable */
public function testRenderabilityScoreScriptWritesExpectedFilesAndMetrics(): void
{
$base = \sys_get_temp_dir() . '/tc-lib-pdf-renderability-' . \bin2hex(\random_bytes(6));
$json = $base . '-score.json';
$markdownFile = $base . '-score.md';
$cmd = [
'php',
'resources/css/renderability_score.php',
'--corpus=test/fixtures/html/real_pages/corpus.json',
'--json=' . $json,
'--markdown=' . $markdownFile,
'--acceptable-threshold=80',
];
$res = $this->runCommand($cmd);
try {
$this->assertSame(0, $res['code'], $res['stderr']);
$this->assertFileExists($json);
$this->assertFileExists($markdownFile);
$raw = \file_get_contents($json);
$this->assertNotFalse($raw);
/** @var array<string, mixed>|null $report */
$report = \json_decode($raw, true);
$this->assertIsArray($report);
if (!isset($report['page_count']) || !\is_int($report['page_count'])) {
$this->fail('Expected integer page_count in renderability report');
}
$this->assertSame(5, $report['page_count']);
$this->assertArrayHasKey('overall_score', $report);
$this->assertArrayHasKey('pass_rate', $report);
$this->assertArrayHasKey('text_flow_rate', $report);
$this->assertArrayHasKey('high_severity_failures', $report);
$markdown = (string) \file_get_contents($markdownFile);
$this->assertStringContainsString('CSS Renderability Score', $markdown);
$this->assertStringContainsString('| Page | Score | Acceptable |', $markdown);
} finally {
if (\file_exists($json)) {
\unlink($json);
}
if (\file_exists($markdownFile)) {
\unlink($markdownFile);
}
}
}
/** @throws \Throwable */
public function testRenderabilityTrendScriptAppendsAndCapsHistory(): void
{
$base = \sys_get_temp_dir() . '/tc-lib-pdf-trend-' . \bin2hex(\random_bytes(6));
$score = $base . '-score.json';
$history = $base . '-trend.json';
$markdownFile = $base . '-trend.md';
$scorePayload = [
'overall_score' => 88.2,
'pass_rate' => 80.0,
'text_flow_rate' => 80.0,
'high_severity_failures' => 1,
];
$scoreJson = \json_encode($scorePayload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
$this->assertIsString($scoreJson);
\file_put_contents($score, $scoreJson . PHP_EOL);
$historyPayload = [
'history' => [
[
'timestamp' => '2026-05-01T00:00:00Z',
'run_id' => '1',
'ref' => 'main',
'sha' => 'abc',
'overall_score' => 80.0,
'pass_rate' => 60.0,
'text_flow_rate' => 60.0,
'high_severity_failures' => 3,
'direction' => 'new',
],
[
'timestamp' => '2026-05-02T00:00:00Z',
'run_id' => '2',
'ref' => 'main',
'sha' => 'def',
'overall_score' => 84.0,
'pass_rate' => 80.0,
'text_flow_rate' => 80.0,
'high_severity_failures' => 2,
'direction' => 'up',
],
],
];
$historyJson = \json_encode($historyPayload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
$this->assertIsString($historyJson);
\file_put_contents($history, $historyJson . PHP_EOL);
$cmd = [
'php',
'resources/css/renderability_trend.php',
'--score=' . $score,
'--history=' . $history,
'--markdown=' . $markdownFile,
'--sha=xyz',
'--ref=main',
'--run-id=3',
'--max-entries=2',
];
$res = $this->runCommand($cmd);
try {
$this->assertSame(0, $res['code'], $res['stderr']);
$this->assertFileExists($history);
$this->assertFileExists($markdownFile);
$rawHistory = \file_get_contents($history);
$this->assertNotFalse($rawHistory);
/** @var array<string, mixed>|null $trend */
$trend = \json_decode($rawHistory, true);
$this->assertIsArray($trend);
$entries = [];
if (isset($trend['history']) && \is_array($trend['history'])) {
$entries = \array_values($trend['history']);
}
$this->assertCount(2, $entries);
$latest = [];
if (isset($entries[1]) && \is_array($entries[1])) {
$latest = $entries[1];
}
$this->assertIsArray($latest);
$this->assertArrayHasKey('run_id', $latest);
$this->assertArrayHasKey('ref', $latest);
$this->assertArrayHasKey('direction', $latest);
$this->assertSame(
'3',
isset($latest['run_id']) && \is_scalar($latest['run_id']) ? (string) $latest['run_id'] : '',
);
$this->assertSame(
'main',
isset($latest['ref']) && \is_scalar($latest['ref']) ? (string) $latest['ref'] : '',
);
$this->assertSame(
'up',
isset($latest['direction']) && \is_scalar($latest['direction']) ? (string) $latest['direction'] : '',
);
$markdown = (string) \file_get_contents($markdownFile);
$this->assertStringContainsString('CSS Renderability Trend', $markdown);
$this->assertStringContainsString('| Timestamp | Ref | Score |', $markdown);
} finally {
if (\file_exists($score)) {
\unlink($score);
}
if (\file_exists($history)) {
\unlink($history);
}
if (\file_exists($markdownFile)) {
\unlink($markdownFile);
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,80 @@
<?php
/**
* SignatureAppearanceModeTest.php
*
* @since 2026-07-17
* @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 Com\Tecnick\Pdf\Signature\SignatureAppearanceMode;
/**
* SignatureAppearanceMode enum test
*
* @since 2026-07-17
* @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
*/
class SignatureAppearanceModeTest extends TestUtil
{
public function testCaseBackingValues(): void
{
$this->assertSame('N', SignatureAppearanceMode::Normal->value);
$this->assertSame('R', SignatureAppearanceMode::Rollover->value);
$this->assertSame('D', SignatureAppearanceMode::Down->value);
}
/**
* @throws \Com\Tecnick\Pdf\Exception
*/
public function testFromLooseCanonicalAndCaseInsensitive(): void
{
$this->assertSame(SignatureAppearanceMode::Normal, SignatureAppearanceMode::fromLoose('N'));
$this->assertSame(SignatureAppearanceMode::Rollover, SignatureAppearanceMode::fromLoose('r'));
$this->assertSame(SignatureAppearanceMode::Down, SignatureAppearanceMode::fromLoose('D'));
}
/**
* @throws \Com\Tecnick\Pdf\Exception
*/
public function testFromLoosePassesThroughEnumInstance(): void
{
$this->assertSame(
SignatureAppearanceMode::Rollover,
SignatureAppearanceMode::fromLoose(SignatureAppearanceMode::Rollover),
);
}
/**
* @throws \Com\Tecnick\Pdf\Exception
*/
public function testFromLooseRoundTrip(): void
{
foreach (SignatureAppearanceMode::cases() as $case) {
$this->assertSame($case, SignatureAppearanceMode::fromLoose($case->value));
}
}
/**
* @throws \Com\Tecnick\Pdf\Exception
*/
public function testFromLooseUnknownThrows(): void
{
$this->bcExpectException(\Com\Tecnick\Pdf\Exception::class);
SignatureAppearanceMode::fromLoose('X');
}
}
@@ -0,0 +1,647 @@
<?php
/**
* TcpdfImporterFacadeTest.php
*
* @since 2026-04-25
* @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 Com\Tecnick\Pdf\Import\ImportPageOutOfRangeException;
use Com\Tecnick\Pdf\Import\ImportSourceNotFoundException;
use Com\Tecnick\Pdf\Import\ImportUnsupportedFeatureException;
use Com\Tecnick\Pdf\Import\PageTemplate;
use Com\Tecnick\Pdf\Tcpdf;
use PHPUnit\Framework\TestCase;
/**
* Integration tests for the PDF import facade methods on Tcpdf.
*/
class TcpdfImporterFacadeTest extends TestCase
{
/** Path to the single-page test fixture. */
private string $simplePdf;
/** Path to the two-page test fixture with a shared font. */
private string $multipagePdf;
/** Path to a fixture with explicit Media/Crop/Bleed/Trim/Art boxes. */
private string $boxOptionsPdf;
/** Path to a fixture with /Rotate 90 on the page dictionary. */
private string $rotatedPdf;
/** Path to a fixture containing alpha/blend ExtGState content. */
private string $transparencyPdf;
/** Path to a fixture with an /Encrypt trailer entry. */
private string $encryptedPdf;
/**
* @throws \Throwable
*/
protected function setUp(): void
{
if (!\defined('K_PATH_FONTS')) {
$fonts = (string) \realpath(__DIR__ . '/../vendor/tecnickcom/tc-lib-pdf-font/target/fonts');
\define('K_PATH_FONTS', $fonts);
}
$this->simplePdf = __DIR__ . '/fixtures/simple_import.pdf';
$this->multipagePdf = __DIR__ . '/fixtures/multipage_import.pdf';
$this->boxOptionsPdf = __DIR__ . '/fixtures/box_options_import.pdf';
$this->rotatedPdf = __DIR__ . '/fixtures/rotated_import.pdf';
$this->transparencyPdf = __DIR__ . '/fixtures/transparency_import.pdf';
$this->encryptedPdf = __DIR__ . '/fixtures/encrypted_import_stub.pdf';
}
// ------------------------------------------------------------------ helpers
/**
* @throws \Throwable
*/
private function makePdf(): Tcpdf
{
$pdf = new Tcpdf();
// A default font must be inserted before any addPage call so that
// setPageContext / getOutCurrentFont does not receive a null font key.
$pdf->font->insert($pdf->pon, 'helvetica', '', 12);
return $pdf;
}
/**
* @throws \Throwable
*/
private function makePdfWithMode(string $mode): Tcpdf
{
$pdf = new Tcpdf(mode: $mode);
$pdf->font->insert($pdf->pon, 'helvetica', '', 12);
return $pdf;
}
/**
* @throws \Throwable
*/
private function getPdfVersion(Tcpdf $pdf): string
{
$ref = new \ReflectionClass($pdf);
while ($ref !== false) {
if ($ref->hasProperty('pdfver')) {
$prop = $ref->getProperty('pdfver');
return $this->stringValue($prop->getValue($pdf));
}
$ref = $ref->getParentClass();
}
return '';
}
private function stringValue(mixed $value): string
{
return \is_string($value) ? $value : '';
}
/**
* Build a minimal one-page PDF whose /Contents is an array of two Flate streams.
*/
private function buildMultiContentFlatePdf(): string
{
$streamA = \gzcompress("BT /F1 12 Tf 20 160 Td (A) Tj ET\n");
$streamB = \gzcompress("BT /F1 12 Tf 20 140 Td (B) Tj ET\n");
self::assertNotFalse($streamA);
self::assertNotFalse($streamB);
$objects = [];
$objects[] = '1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj' . "\n";
$objects[] = '2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj' . "\n";
$objects[] =
'3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] '
. '/Resources << /Font << /F1 6 0 R >> >> /Contents [4 0 R 5 0 R] >> endobj'
. "\n";
$objects[] =
'4 0 obj << /Length '
. \strlen($streamA)
. ' /Filter /FlateDecode >> stream'
. "\n"
. $streamA
. "\n"
. 'endstream endobj'
. "\n";
$objects[] =
'5 0 obj << /Length '
. \strlen($streamB)
. ' /Filter /FlateDecode >> stream'
. "\n"
. $streamB
. "\n"
. 'endstream endobj'
. "\n";
$objects[] = '6 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj' . "\n";
$pdf = "%PDF-1.4\n";
$offsets = [0 => 0];
foreach ($objects as $idx => $obj) {
$objNum = $idx + 1;
$offsets[$objNum] = \strlen($pdf);
$pdf .= $obj;
}
$xrefOffset = \strlen($pdf);
$pdf .= 'xref' . "\n";
$pdf .= '0 7' . "\n";
$pdf .= '0000000000 65535 f ' . "\n";
for ($objNum = 1; $objNum <= 6; ++$objNum) {
$offset = $offsets[$objNum] ?? 0;
$pdf .= \sprintf('%010d 00000 n ' . "\n", $offset);
}
$pdf .= 'trailer << /Size 7 /Root 1 0 R >>' . "\n";
$pdf .= 'startxref' . "\n";
$pdf .= $xrefOffset . "\n";
$pdf .= '%%EOF' . "\n";
return $pdf;
}
// ------------------------------------------------------------------ setImportSourceFile / setImportSourceData
/**
* @throws \Throwable
*/
public function testSetImportSourceFileReturnsNonEmptyId(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->simplePdf);
$this->assertNotEmpty($srcId);
}
/**
* @throws \Throwable
*/
public function testSetImportSourceDataReturnsNonEmptyId(): void
{
$pdf = $this->makePdf();
$data = (string) \file_get_contents($this->simplePdf);
$srcId = $pdf->setImportSourceData($data);
$this->assertNotEmpty($srcId);
}
/**
* @throws \Throwable
*/
public function testSetImportSourceFileThrowsForMissingFile(): void
{
$pdf = $this->makePdf();
$this->expectException(ImportSourceNotFoundException::class);
$pdf->setImportSourceFile('/nonexistent/path.pdf');
}
/**
* @throws \Throwable
*/
public function testSetImportSourceFileThrowsForEncryptedPdf(): void
{
$pdf = $this->makePdf();
$this->expectException(ImportUnsupportedFeatureException::class);
$this->expectExceptionMessageMatches('/' . preg_quote('encrypted PDF', '/') . '/');
$pdf->setImportSourceFile($this->encryptedPdf);
}
/**
* @throws \Throwable
*/
public function testSetImportSourceFileWithPasswordThrowsActionableEncryptedError(): void
{
$pdf = $this->makePdf();
$this->expectException(ImportUnsupportedFeatureException::class);
$this->expectExceptionMessageMatches('/' . preg_quote('password-based import is not supported', '/') . '/');
$pdf->setImportSourceFile($this->encryptedPdf, ['password' => 'secret']);
}
// ------------------------------------------------------------------ getSourcePageCount
/**
* @throws \Throwable
*/
public function testGetSourcePageCountSimple(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->simplePdf);
$this->assertSame(1, $pdf->getSourcePageCount($srcId));
}
/**
* @throws \Throwable
*/
public function testGetSourcePageCountMultipage(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->multipagePdf);
$this->assertSame(2, $pdf->getSourcePageCount($srcId));
}
// ------------------------------------------------------------------ importPage / importPages
/**
* @throws \Throwable
*/
public function testImportPageReturnsPageTemplate(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->simplePdf);
$tpl = $pdf->importPage($srcId, 1);
$this->assertInstanceOf(PageTemplate::class, $tpl);
$this->assertGreaterThan(0.0, $tpl->getWidth());
$this->assertGreaterThan(0.0, $tpl->getHeight());
}
/**
* @throws \Throwable
*/
public function testImportPageThrowsForOutOfRange(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->simplePdf);
$this->expectException(ImportPageOutOfRangeException::class);
$pdf->importPage($srcId, 99);
}
/**
* @throws \Throwable
*/
public function testImportPageUsesTrimBoxWhenRequested(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->boxOptionsPdf);
$tpl = $pdf->importPage($srcId, 1, ['box' => 'TrimBox']);
$this->assertEqualsWithDelta(460.0, $tpl->getWidth(), 0.01);
$this->assertEqualsWithDelta(660.0, $tpl->getHeight(), 0.01);
}
/**
* @throws \Throwable
*/
public function testImportPageUsesArtBoxWhenRequested(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->boxOptionsPdf);
$tpl = $pdf->importPage($srcId, 1, ['box' => 'ArtBox']);
$this->assertEqualsWithDelta(440.0, $tpl->getWidth(), 0.01);
$this->assertEqualsWithDelta(640.0, $tpl->getHeight(), 0.01);
}
/**
* @throws \Throwable
*/
public function testImportPageRespectsRotationByDefault(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->rotatedPdf);
$tpl = $pdf->importPage($srcId, 1);
$this->assertSame(90, $tpl->getRotation());
$this->assertGreaterThan($tpl->getHeight(), $tpl->getWidth());
$this->assertEqualsWithDelta(500.0, $tpl->getWidth(), 0.01);
$this->assertEqualsWithDelta(300.0, $tpl->getHeight(), 0.01);
}
/**
* @throws \Throwable
*/
public function testImportPageCanDisableRotationRespect(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->rotatedPdf);
$tpl = $pdf->importPage($srcId, 1, ['respectRotation' => false]);
$this->assertSame(0, $tpl->getRotation());
$this->assertGreaterThan($tpl->getWidth(), $tpl->getHeight());
$this->assertEqualsWithDelta(300.0, $tpl->getWidth(), 0.01);
$this->assertEqualsWithDelta(500.0, $tpl->getHeight(), 0.01);
}
/**
* @throws \Throwable
*/
public function testImportPageWithGroupXObjectBumpsPdfVersionTo14Minimum(): void
{
$pdf = $this->makePdf();
$pdf->setPDFVersion('1.3');
$srcId = $pdf->setImportSourceFile($this->transparencyPdf);
$pdf->importPage($srcId, 1, ['groupXObject' => true]);
$this->assertSame('1.4', $this->getPdfVersion($pdf));
}
/**
* @throws \Throwable
*/
public function testImportPageWithGroupXObjectDisabledKeepsVersion(): void
{
$pdf = $this->makePdf();
$pdf->setPDFVersion('1.3');
$srcId = $pdf->setImportSourceFile($this->transparencyPdf);
$pdf->importPage($srcId, 1, ['groupXObject' => false]);
$this->assertSame('1.3', $this->getPdfVersion($pdf));
}
/**
* @throws \Throwable
*/
public function testImportPageEmitsTransparencyGroupByDefault(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->transparencyPdf);
$tpl = $pdf->importPage($srcId, 1);
$pdf->addPage();
$pdf->useImportedPage($tpl, 10, 10, 120, 80, ['keepAspectRatio' => false]);
$raw = $pdf->getOutPDFString();
$this->assertStringContainsString('/Group << /Type /Group /S /Transparency >>', $raw);
}
/**
* @throws \Throwable
*/
public function testImportPageSuppressesTransparencyGroupInPdfx3(): void
{
$pdf = $this->makePdfWithMode('pdfx3');
$srcId = $pdf->setImportSourceFile($this->transparencyPdf);
$tpl = $pdf->importPage($srcId, 1, ['groupXObject' => true]);
$pdf->addPage();
$pdf->useImportedPage($tpl, 10, 10, 120, 80, ['keepAspectRatio' => false]);
$raw = $pdf->getOutPDFString();
$this->assertStringNotContainsString('/Group << /Type /Group /S /Transparency >>', $raw);
}
/**
* @throws \Throwable
*/
public function testImportPagesNullRangeImportsAll(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->multipagePdf);
$tpls = $pdf->importPages($srcId);
$this->assertCount(2, $tpls);
foreach ($tpls as $tpl) {
$this->assertInstanceOf(PageTemplate::class, $tpl);
}
}
/**
* @throws \Throwable
*/
public function testImportPagesExplicitRange(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->multipagePdf);
$tpls = $pdf->importPages($srcId, [1]);
$this->assertCount(1, $tpls);
assert(isset($tpls[0]), "\$tpls[0] must be set");
$this->assertInstanceOf(PageTemplate::class, $tpls[0]);
}
/**
* @throws \Throwable
*/
public function testImportPagesThrowsForOutOfRange(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->multipagePdf);
$this->expectException(ImportPageOutOfRangeException::class);
$pdf->importPages($srcId, [1, 99]);
}
// ------------------------------------------------------------------ useImportedPage
/**
* @throws \Throwable
*/
public function testUseImportedPageReturnsPlacementDimensions(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->simplePdf);
$tpl = $pdf->importPage($srcId, 1);
$pdf->addPage();
$placed = $pdf->useImportedPage($tpl, 10.0, 10.0, 100.0, null, []);
$this->assertArrayHasKey('x', $placed);
$this->assertArrayHasKey('y', $placed);
$this->assertArrayHasKey('width', $placed);
$this->assertArrayHasKey('height', $placed);
$this->assertEqualsWithDelta(100.0, $placed['width'], 0.01);
}
/**
* @throws \Throwable
*/
public function testUseImportedPageAlignCenterCentersInsideRequestedBox(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->simplePdf);
$tpl = $pdf->importPage($srcId, 1);
$pdf->addPage();
$placed = $pdf->useImportedPage($tpl, 10.0, 20.0, 100.0, 200.0, ['keepAspectRatio' => true, 'align' => 'CC']);
// In a 100x200 box with source ratio 612:792, width is the limiting axis.
$this->assertEqualsWithDelta(100.0, $placed['width'], 0.01);
$this->assertEqualsWithDelta(129.41, $placed['height'], 0.05);
$this->assertEqualsWithDelta(10.0, $placed['x'], 0.01);
$this->assertEqualsWithDelta(55.29, $placed['y'], 0.05);
}
/**
* @throws \Throwable
*/
public function testUseImportedPageWithClipAddsClipOperatorToPageContent(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->simplePdf);
$tpl = $pdf->importPage($srcId, 1);
$pdf->addPage();
$pdf->useImportedPage($tpl, 15.0, 25.0, 80.0, 60.0, ['clip' => true, 'keepAspectRatio' => false]);
$page = $pdf->page->getPage();
$content = \implode('', $page['content']);
$this->assertStringContainsString(' re W n ', $content);
}
// ------------------------------------------------------------------ addPageFromImport
/**
* @throws \Throwable
*/
public function testAddPageFromImportCreatesPageAndReturnsTemplate(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->simplePdf);
$tpl = $pdf->addPageFromImport($srcId, 1);
$this->assertInstanceOf(PageTemplate::class, $tpl);
// The first page has pid 0; subsequent pages have positive pids.
$pageId = $pdf->page->getPageID();
$this->assertGreaterThanOrEqual(0, $pageId);
}
/**
* @throws \Throwable
*/
public function testAddPageFromImportPageDimensionsMatchTemplate(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->simplePdf);
$tpl = $pdf->addPageFromImport($srcId, 1);
$pageId = $pdf->page->getPageID();
$page = $pdf->page->getPage($pageId);
$pageW = $page['width'];
$pageH = $page['height'];
$this->assertGreaterThan(0.0, $pageW);
$this->assertGreaterThan(0.0, $pageH);
// The aspect ratio of the page must match the template.
$this->assertEqualsWithDelta($tpl->getWidth() / $tpl->getHeight(), $pageW / $pageH, 0.01);
}
/**
* @throws \Throwable
*/
public function testAddPageFromImportPlacesXObject(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->simplePdf);
$tpl = $pdf->addPageFromImport($srcId, 1);
$pageId = $pdf->page->getPageID();
$pageContent = $pdf->page->getPage($pageId);
// The page content should reference the XObject.
$content = \implode('', $pageContent['content']);
$this->assertStringContainsString($tpl->getXobjId(), $content);
}
// ------------------------------------------------------------------ appendDocument
/**
* @throws \Throwable
*/
public function testAppendDocumentCreatesOnePagePerSourcePage(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->multipagePdf);
$tpls = $pdf->appendDocument($srcId);
$this->assertCount(2, $tpls);
}
/**
* @throws \Throwable
*/
public function testAppendDocumentConcatenatesDecodedMultiStreamContents(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceData($this->buildMultiContentFlatePdf());
$tpls = $pdf->appendDocument($srcId);
$this->assertCount(1, $tpls);
$raw = $pdf->getOutPDFString();
$this->assertStringContainsString('(A) Tj ET', $raw);
$this->assertStringContainsString('(B) Tj ET', $raw);
}
/**
* @throws \Throwable
*/
public function testAppendDocumentWithRangeCreatesOnlyRequestedPages(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->multipagePdf);
$tpls = $pdf->appendDocument($srcId, [2]);
$this->assertCount(1, $tpls);
assert(isset($tpls[0]), "\$tpls[0] must be set");
$this->assertSame(2, $tpls[0]->getSourcePage());
}
/**
* @throws \Throwable
*/
public function testAppendDocumentRestoresCallerPageContext(): void
{
$pdf = $this->makePdf();
// Create an initial page.
$callerPage = $pdf->addPage();
if (!isset($callerPage['pid']) || !\is_int($callerPage['pid'])) {
$this->fail('Expected integer page id.');
}
$callerPid = $callerPage['pid'];
// Append pages from a multi-page source.
$srcId = $pdf->setImportSourceFile($this->multipagePdf);
$pdf->appendDocument($srcId);
// Current page ID should be restored to the caller's page.
$this->assertSame($callerPid, $pdf->page->getPageID());
}
/**
* @throws \Throwable
*/
public function testAppendDocumentWithNoPriorPageLeavesCurrentOnLastAppended(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->multipagePdf);
$tpls = $pdf->appendDocument($srcId);
// No prior page (pid is -1), so restore does not run;
// the current page is the last appended one.
$finalPid = $pdf->page->getPageID();
$this->assertGreaterThan(0, $finalPid);
// Both appended pages should be reachable.
$this->assertCount(2, $tpls);
}
/**
* @throws \Throwable
*/
public function testAppendDocumentThrowsForOutOfRangePage(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->multipagePdf);
$this->expectException(ImportPageOutOfRangeException::class);
$pdf->appendDocument($srcId, [5]);
}
/**
* @throws \Throwable
*/
public function testAppendDocumentXObjectsRegistered(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->multipagePdf);
$tpls = $pdf->appendDocument($srcId);
$pages = $pdf->page->getPages();
$pageContent = '';
foreach ($pages as $page) {
$content = $page['content'];
if ($content === []) {
continue;
}
$pageContent .= \implode('', $content);
}
foreach ($tpls as $tpl) {
$this->assertStringContainsString($tpl->getXobjId(), $pageContent);
}
}
}
File diff suppressed because it is too large Load Diff
+129
View File
@@ -0,0 +1,129 @@
<?php
/**
* TestUtil.php
*
* @since 2020-12-19
* @category Library
* @package Pdf
* @author Nicola Asuni <info@tecnick.com>
* @copyright 2015-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-file software library.
*/
namespace Test;
use PHPUnit\Framework\TestCase;
/**
* Test Util
*
* @since 2020-12-19
* @category Library
* @package Pdf
* @author Nicola Asuni <info@tecnick.com>
* @copyright 2015-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
* @SuppressWarnings("PHPMD.NumberOfChildren")
*/
class TestUtil extends TestCase
{
public static function setUpFontsPath(): void
{
if (!\defined('K_PATH_FONTS')) {
$fonts = (string) \realpath(__DIR__ . '/../vendor/tecnickcom/tc-lib-pdf-font/target/fonts');
\define('K_PATH_FONTS', $fonts);
}
}
public function bcAssertEqualsWithDelta(
mixed $expected,
mixed $actual,
float $delta = 0.01,
string $message = '',
): void {
parent::assertEqualsWithDelta($expected, $actual, $delta, $message);
}
/**
* @param class-string<\Throwable> $exception
*/
public function bcExpectException(string $exception): void
{
parent::expectException($exception);
}
public function bcAssertIsResource(mixed $res): void
{
parent::assertIsResource($res);
}
public function bcAssertMatchesRegularExpression(string $pattern, string $string, string $message = ''): void
{
parent::assertMatchesRegularExpression($pattern, $string, $message);
}
protected function getObjectProperty(object $obj, string $name): mixed
{
$ref = new \ReflectionClass($obj);
while ($ref !== false) {
if ($ref->hasProperty($name)) {
$prop = $ref->getProperty($name);
return $prop->getValue($obj);
}
$ref = $ref->getParentClass();
}
$this->fail('Property not found: ' . $name);
}
protected function setObjectProperty(object $obj, string $name, mixed $value): void
{
$ref = new \ReflectionClass($obj);
while ($ref !== false) {
if ($ref->hasProperty($name)) {
$prop = $ref->getProperty($name);
$prop->setValue($obj, $value);
return;
}
$ref = $ref->getParentClass();
}
$this->fail('Property not found: ' . $name);
}
/** @throws \Throwable */
protected function initFont(\Com\Tecnick\Pdf\Tcpdf $obj): void
{
self::setUpFontsPath();
/** @var \Com\Tecnick\Pdf\Font\Stack $font */
$font = $this->getObjectProperty($obj, 'font');
/** @var int $pon */
$pon = $this->getObjectProperty($obj, 'pon');
$fontfile = (string) \realpath(__DIR__
. '/../vendor/tecnickcom/tc-lib-pdf-font/target/fonts/core/helvetica.json');
$font->insert($pon, 'helvetica', '', 10, null, null, $fontfile);
}
/**
* @phpstan-return array{pid: int, height: float}
* @throws \Throwable
*/
protected function initFontAndPage(\Com\Tecnick\Pdf\Tcpdf $obj): array
{
$this->initFont($obj);
$page = $obj->addPage();
if (!isset($page['pid'], $page['height']) || !\is_int($page['pid']) || !\is_float($page['height'])) {
$this->fail('Unexpected addPage() return shape.');
}
$pid = $page['pid'];
$height = $page['height'];
return ['pid' => $pid, 'height' => $height];
}
}
+88
View File
@@ -0,0 +1,88 @@
<?php
/**
* TestablMetaInfo.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 TestablMetaInfo extends \Com\Tecnick\Pdf\Tcpdf
{
public function exposeGetFormattedDate(int $time): string
{
return $this->getFormattedDate($time);
}
public function exposeGetXMPFormattedDate(int $time): string
{
return $this->getXMPFormattedDate($time);
}
/** @throws \Throwable */
public function exposeGetOutDateTimeString(int $time, int $oid): string
{
return $this->getOutDateTimeString($time, $oid);
}
/** @throws \Throwable */
public function exposeGetOutMetaInfo(): string
{
return $this->getOutMetaInfo();
}
public function exposeGetEscapedXML(string $str): string
{
return $this->getEscapedXML($str);
}
/** @throws \Throwable */
public function exposeGetOutXMP(): string
{
return $this->getOutXMP();
}
public function exposeGetOutViewerPref(): string
{
return $this->getOutViewerPref();
}
public function exposeGetPageBoxName(string $name): string
{
return $this->getPageBoxName($name);
}
public function exposeGetPagePrintScaling(): string
{
return $this->getPagePrintScaling();
}
public function exposeGetDuplexMode(): string
{
return $this->getDuplexMode();
}
public function exposeGetBooleanMode(string $name): string
{
return $this->getBooleanMode($name);
}
public function exposeGetProducer(): string
{
return $this->getProducer();
}
public function exposeGetGtsPdfxVersionString(): string
{
return $this->getGtsPdfxVersionString();
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
/**
* TestableBase.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;
/** @phpstan-import-type TRefUnitValues from \Com\Tecnick\Pdf\Base */
class TestableBase extends \Com\Tecnick\Pdf\Tcpdf
{
/**
* @phpstan-param TRefUnitValues $ref
* @throws \Throwable
*/
public function exposeGetUnitValuePoints(
string|float|int $val,
array $ref = self::REFUNITVAL,
string $defunit = 'px',
): float {
return $this->getUnitValuePoints($val, $ref, $defunit);
}
/**
* @phpstan-param TRefUnitValues $ref
* @throws \Throwable
*/
public function exposeGetFontValuePoints(
string|float|int $val,
array $ref = self::REFUNITVAL,
string $defunit = 'pt',
): float {
return $this->getFontValuePoints($val, $ref, $defunit);
}
public function exposeSetTmpRTL(string $mode): void
{
$this->setTmpRTL($mode);
}
public function exposeIsRTL(): bool
{
return $this->isRTL();
}
}
+227
View File
@@ -0,0 +1,227 @@
<?php
/**
* TestableCSS.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;
/**
* @phpstan-import-type TCellBound from \Com\Tecnick\Pdf\Base
* @phpstan-import-type StyleData from \Com\Tecnick\Pdf\Graph\Base
* @phpstan-import-type BorderStyle from \Com\Tecnick\Pdf\CSS
* @phpstan-import-type TCSSBorderSpacing from \Com\Tecnick\Pdf\CSS
* @phpstan-import-type TCSSData from \Com\Tecnick\Pdf\CSS
*/
class TestableCSS extends \Com\Tecnick\Pdf\Tcpdf
{
/** @throws \Throwable */
public function exposeGetCSSBorderWidthPoints(string $width): float
{
return $this->getCSSBorderWidthPoints($width);
}
/** @throws \Throwable */
public function exposeGetCSSBorderWidth(string $width): float
{
return $this->getCSSBorderWidth($width);
}
public function exposeGetCSSBorderDashStyle(string $style): int
{
return $this->getCSSBorderDashStyle($style);
}
/** @return StyleData */
public function exposeGetCSSDefaultBorderStyle(): array
{
/** @var StyleData */
return $this->getCSSDefaultBorderStyle();
}
/**
* @return BorderStyle
* @throws \Throwable
*/
public function exposeGetCSSBorderStyle(string $cssborder): array
{
return $this->getCSSBorderStyle($cssborder);
}
/**
* @phpstan-return TCellBound
* @throws \Throwable
*/
public function exposeGetCSSPadding(string $csspadding, float $width = 0.0): array
{
return $this->getCSSPadding($csspadding, $width);
}
/**
* @phpstan-return TCellBound
* @throws \Throwable
*/
public function exposeGetCSSMargin(string $cssmargin, float $width = 0.0): array
{
return $this->getCSSMargin($cssmargin, $width);
}
/**
* @phpstan-return TCSSBorderSpacing
* @throws \Throwable
*/
public function exposeGetCSSBorderMargin(string $cssbspace, float $width = 0.0): array
{
return $this->getCSSBorderMargin($cssbspace, $width);
}
/** @phpstan-param array<int, array{c: string}> $css */
public function exposeImplodeCSSData(array $css): string
{
/** @var array<string, TCSSData> $normalized */
$normalized = [];
foreach ($css as $index => $style) {
$key = (string) $index;
$normalized[$key] = [
'k' => $key,
'c' => $style['c'],
's' => $key,
];
}
return $this->implodeCSSData($normalized);
}
public function exposeTidyCSS(string $css): string
{
return $this->tidyCSS($css);
}
public function exposeNormalizeCharset(string $css): string
{
return $this->normalizeCharset($css);
}
public function exposeIsMediaPrintRelevant(string $query): bool
{
return $this->isMediaPrintRelevant($query);
}
/**
* @param array<string, bool> $seen
* @throws \Throwable
*/
public function exposeResolveImportRules(string $css, int $depth = 0, array &$seen = []): string
{
return $this->resolveImportRules($css, $depth, $seen);
}
/**
* @phpstan-return array<string, string>
* @throws \Throwable
*/
public function exposeExtractCSSproperties(string $css): array
{
return $this->extractCSSproperties($css);
}
public function exposeIntToRoman(int $num): string
{
return $this->intToRoman($num);
}
public function exposeUnhtmlentities(string $text): string
{
return $this->unhtmlentities($text);
}
/**
* @phpstan-return array<string, string>
* @throws \Throwable
*/
public function exposeGetCSSArrayFromHTML(string &$html): array
{
return $this->getCSSArrayFromHTML($html);
}
/** @throws \Throwable */
public function exposeGetCSSColor(string $color): string
{
return $this->getCSSColor($color);
}
/** @return list<string> */
public function exposeSplitCSSWhitespaceTokens(string $value): array
{
return $this->splitCSSWhitespaceTokens($value);
}
/** @return list<string> */
public function exposeSplitCSSDeclarations(string $style): array
{
return $this->splitCSSDeclarations($style);
}
/** @return array<string, string> */
public function exposeDecodeCSSMap(string $payload): array
{
return $this->decodeCSSMap($payload);
}
public function exposeStripAndRegisterCSSSpotRules(string $css): string
{
return $this->stripAndRegisterCSSSpotRules($css);
}
public function exposeParseSpotCssColorFunction(string $color): ?string
{
return $this->parseSpotCssColorFunction($color);
}
public function exposeParseSpotColorNameToken(#[\SensitiveParameter] string $token): string
{
return $this->parseSpotColorNameToken($token);
}
public function exposeFormatSpotColorNameToken(string $name): string
{
return $this->formatSpotColorNameToken($name);
}
/** @return array{0: float, 1: float, 2: float, 3: float}|null */
public function exposeParseSpotComponentList(string $value): ?array
{
return $this->parseSpotComponentList($value);
}
/** @return array{0: float, 1: float, 2: float}|null */
public function exposeParseLabComponentList(string $value): ?array
{
return $this->parseLabComponentList($value);
}
public function exposeParseSpotTintValue(string $value): ?float
{
return $this->parseSpotTintValue($value);
}
public function exposeParseLabLstarValue(string $value): ?float
{
return $this->parseLabLstarValue($value);
}
public function exposeParseLabAxisValue(string $value): ?float
{
return $this->parseLabAxisValue($value);
}
}
+180
View File
@@ -0,0 +1,180 @@
<?php
/**
* TestableCell.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;
/**
* @phpstan-import-type TCellDef from \Com\Tecnick\Pdf\Cell
* @phpstan-import-type StyleDataOpt from \Com\Tecnick\Pdf\Cell
*/
class TestableCell extends \Com\Tecnick\Pdf\Tcpdf
{
/**
* @phpstan-param array<int|string, StyleDataOpt> $styles
* @phpstan-param TCellDef|null $cell
* @phpstan-return TCellDef
*/
public function exposeAdjustMinCellPadding(array $styles = [], ?array $cell = null): array
{
return $this->adjustMinCellPadding($styles, $cell);
}
/**
* @phpstan-param TCellDef|null $cell
* @throws \Throwable
*/
public function exposeCellMinHeight(float $pheight = 0, string $align = 'C', ?array $cell = null): float
{
return $this->cellMinHeight($pheight, $align, $cell);
}
/** @phpstan-param TCellDef|null $cell */
public function exposeCellMinWidth(float $txtwidth, string $align = 'L', ?array $cell = null): float
{
return $this->cellMinWidth($txtwidth, $align, $cell);
}
/** @phpstan-param TCellDef|null $cell */
public function exposeCellVPos(float $pnty, float $pheight, string $align = 'T', ?array $cell = null): float
{
return $this->cellVPos($pnty, $pheight, $align, $cell);
}
/** @phpstan-param TCellDef|null $cell */
public function exposeCellHPos(float $pntx, float $pwidth, string $align = 'L', ?array $cell = null): float
{
return $this->cellHPos($pntx, $pwidth, $align, $cell);
}
/**
* @phpstan-param TCellDef|null $cell
* @throws \Throwable
*/
public function exposeCellTextVAlign(
float $cellpheight,
float $txtpheight = 0,
string $align = 'C',
?array $cell = null,
): float {
return $this->cellTextVAlign($cellpheight, $txtpheight, $align, $cell);
}
/** @phpstan-param TCellDef|null $cell */
public function exposeCellTextHAlign(
float $pwidth,
float $txtpwidth,
string $align = 'L',
?array $cell = null,
): float {
return $this->cellTextHAlign($pwidth, $txtpwidth, $align, $cell);
}
/**
* @phpstan-param TCellDef|null $cell
* @throws \Throwable
*/
public function exposeCellVPosFromText(
float $txty,
float $cellpheight,
float $txtpheight = 0,
string $align = 'C',
?array $cell = null,
): float {
return $this->cellVPosFromText($txty, $cellpheight, $txtpheight, $align, $cell);
}
/** @phpstan-param TCellDef|null $cell */
public function exposeCellHPosFromText(
float $txtx,
float $pwidth,
float $txtpwidth,
string $align = 'L',
?array $cell = null,
): float {
return $this->cellHPosFromText($txtx, $pwidth, $txtpwidth, $align, $cell);
}
/**
* @phpstan-param TCellDef|null $cell
* @throws \Throwable
*/
public function exposeTextVPosFromCell(
float $pnty,
float $cellpheight,
float $txtpheight = 0,
string $align = 'C',
?array $cell = null,
): float {
return $this->textVPosFromCell($pnty, $cellpheight, $txtpheight, $align, $cell);
}
/** @phpstan-param TCellDef|null $cell */
public function exposeTextHPosFromCell(
float $pntx,
float $pwidth,
float $txtpwidth,
string $align = 'L',
?array $cell = null,
): float {
return $this->textHPosFromCell($pntx, $pwidth, $txtpwidth, $align, $cell);
}
/**
* @phpstan-param TCellDef|null $cell
* @throws \Throwable
*/
public function exposeCellMaxWidth(float $pntx = 0, ?array $cell = null): float
{
return $this->cellMaxWidth($pntx, $cell);
}
/** @phpstan-param TCellDef|null $cell */
public function exposeTextMaxWidth(float $pwidth, ?array $cell = null): float
{
return $this->textMaxWidth($pwidth, $cell);
}
/**
* @phpstan-param TCellDef|null $cell
* @throws \Throwable
*/
public function exposeTextMaxHeight(float $pheight, string $align = 'T', ?array $cell = null): float
{
return $this->textMaxHeight($pheight, $align, $cell);
}
/**
* @phpstan-param array<int|string, StyleDataOpt> $styles
* @phpstan-param TCellDef|null $cell
* @throws \Throwable
*/
public function exposeDrawCell(
float $pntx,
float $pnty,
float $pwidth,
float $pheight,
array $styles = [],
?array $cell = null,
): string {
return $this->drawCell($pntx, $pnty, $pwidth, $pheight, $styles, $cell);
}
/** @throws \Throwable */
public function exposeGetOutTextString(string $str, int $oid, bool $bom = false): string
{
return $this->getOutTextString($str, $oid, $bom);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,125 @@
<?php
/**
* TestableHTMLBBoxProbe.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;
/**
* @phpstan-type BBoxTraceEntry array{
* txt: string, in_x: float, in_y: float,
* bbox_x: float, bbox_y: float, bbox_w: float, bbox_h: float,
* bbox_end_x: float, font_size: float
* }
*/
class TestableHTMLBBoxProbe extends TestableHTML
{
/**
* @var array<int, BBoxTraceEntry>
*/
private array $bboxTrace = [];
/**
* @return array<int, BBoxTraceEntry>
*/
public function exposeGetBBoxTrace(): array
{
return $this->bboxTrace;
}
public function exposeResetBBoxTrace(): void
{
$this->bboxTrace = [];
}
public function getTextCell(
string $txt,
float $posx = 0,
float $posy = 0,
float $width = 0,
float $height = 0,
float $offset = 0,
float $linespace = 0,
string|\Com\Tecnick\Pdf\TextVAlign $valign = 'C',
string|\Com\Tecnick\Pdf\TextHAlign $halign = 'C',
?array $cell = null,
array $styles = [],
float $strokewidth = 0,
float $wordspacing = 0,
float $leading = 0,
float $rise = 0,
bool $jlast = true,
bool $fill = true,
bool $stroke = false,
bool $underline = false,
bool $linethrough = false,
bool $overline = false,
bool $clip = false,
bool $drawcell = true,
string|\Com\Tecnick\Unicode\TextDirection $forcedir = '',
?array $shadow = null,
string|\Com\Tecnick\Pdf\TextFitMode $fit = '',
): string {
$out = parent::getTextCell(
$txt,
$posx,
$posy,
$width,
$height,
$offset,
$linespace,
$valign,
$halign,
$cell,
$styles,
$strokewidth,
$wordspacing,
$leading,
$rise,
$jlast,
$fill,
$stroke,
$underline,
$linethrough,
$overline,
$clip,
$drawcell,
$forcedir,
$shadow,
$fit,
);
$bbox = $this->getLastBBox();
/** @var array<string, mixed> $curfont */
$curfont = $this->font->getCurrentFont();
$bboxX = isset($bbox['x']) && \is_numeric($bbox['x']) ? (float) $bbox['x'] : 0.0;
$bboxY = isset($bbox['y']) && \is_numeric($bbox['y']) ? (float) $bbox['y'] : 0.0;
$bboxW = isset($bbox['w']) && \is_numeric($bbox['w']) ? (float) $bbox['w'] : 0.0;
$bboxH = isset($bbox['h']) && \is_numeric($bbox['h']) ? (float) $bbox['h'] : 0.0;
$fontSize = isset($curfont['size']) && \is_numeric($curfont['size']) ? (float) $curfont['size'] : 0.0;
$this->bboxTrace[] = [
'txt' => $txt,
'in_x' => $posx,
'in_y' => $posy,
'bbox_x' => $bboxX,
'bbox_y' => $bboxY,
'bbox_w' => $bboxW,
'bbox_h' => $bboxH,
'bbox_end_x' => $bboxX + $bboxW,
'font_size' => $fontSize,
];
return $out;
}
}
@@ -0,0 +1,51 @@
<?php
/**
* TestableHTMLNobrProbe.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 TestableHTMLNobrProbe extends TestableHTML
{
/** @var array<int, string> */
private array $nobrOpenStates = [];
/** @return array<int, string> */
public function exposeNobrOpenStates(): array
{
return $this->nobrOpenStates;
}
protected function parseHTMLTagOPENdiv(
array &$hrc,
int $key,
float &$tpx,
float &$tpy,
float &$tpw,
float &$tph,
): string {
if (!isset($hrc['dom'][$key])) {
return parent::parseHTMLTagOPENdiv($hrc, $key, $tpx, $tpy, $tpw, $tph);
}
$state = '';
$candidate = $hrc['dom'][$key]['attribute']['nobr'] ?? null;
if (\is_string($candidate) && $candidate !== '') {
$state = $candidate;
}
$this->nobrOpenStates[] = $state;
return parent::parseHTMLTagOPENdiv($hrc, $key, $tpx, $tpy, $tpw, $tph);
}
}
@@ -0,0 +1,54 @@
<?php
/**
* TestableJavaScript.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 TestableJavaScript extends \Com\Tecnick\Pdf\Tcpdf
{
/**
* @param array<string, mixed> $prp
* @return array<string, mixed>
* @throws \Throwable
*/
public function exposeGetAnnotOptFromJSProp(array $prp = []): array
{
return $this->getAnnotOptFromJSProp($prp);
}
/** @throws \Throwable */
public function exposeGetPDFDefFillColor(): string
{
return $this->getPDFDefFillColor();
}
/**
* @param array<string, mixed> $opt
* @param array<string, mixed> $jsp
* @return array<string, mixed>
* @throws \Throwable
*/
public function exposeMergeAnnotOptions(
array $opt = ['subtype' => 'text'],
array $jsp = [],
string $color = '',
): array {
$opt = \array_merge(['subtype' => 'text'], $opt);
$method = new \ReflectionMethod($this, 'mergeAnnotOptions');
/** @var array<string, mixed> */
return $method->invokeArgs($this, [$opt, $jsp, $color]);
}
}
@@ -0,0 +1,29 @@
<?php
/**
* TestableObjPageForMetaInfo.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 TestableObjPageForMetaInfo extends \Com\Tecnick\Pdf\Page\Page
{
/**
* @var array<string, string>
*/
public array $CropBox = [
'MediaBox' => 'MediaBox',
];
public function __construct() {}
}
+847
View File
@@ -0,0 +1,847 @@
<?php
/**
* TestableOutput.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;
/**
* @phpstan-import-type TAnnot from \Com\Tecnick\Pdf\Output
* @phpstan-import-type TObjID from \Com\Tecnick\Pdf\Output
* @phpstan-import-type TOutline from \Com\Tecnick\Pdf\Output
* @phpstan-import-type TSignDocPrepared from \Com\Tecnick\Pdf\Output
* @phpstan-import-type TSVGMaskObject from \Com\Tecnick\Pdf\Base
*/
class TestableOutput extends \Com\Tecnick\Pdf\Tcpdf
{
protected string $mockTsaResp = '';
protected bool $mockTsaThrows = false;
protected string $tsaReq = '';
protected string $mockOcspResp = '';
protected bool $mockOcspThrows = false;
protected string $ocspReq = '';
protected string $ocspUrl = '';
protected string $mockCrlResp = '';
protected bool $mockCrlThrows = false;
protected string $crlUrl = '';
/**
* @phpstan-param array<string, mixed> $annotData
* @phpstan-return TAnnot
*/
private function toAnnot(array $annotData): array
{
$base = [
'n' => 1,
'x' => 0.0,
'y' => 0.0,
'w' => 0.0,
'h' => 0.0,
'txt' => '',
'opt' => ['subtype' => 'text'],
];
/** @var TAnnot */
return \array_replace_recursive($base, $annotData);
}
/** @phpstan-return array<int> */
public function exposeGetPDFObjectOffsets(string $data): array
{
return $this->getPDFObjectOffsets($data);
}
/** @phpstan-param array<int> $offset */
public function exposeGetOutPDFXref(array $offset): string
{
return $this->getOutPDFXref($offset);
}
public function exposeGetOutPDFTrailer(): string
{
return $this->getOutPDFTrailer();
}
/** @phpstan-param array<float|int> $color */
public function exposeGetColorStringFromPercArray(array $color): string
{
return self::getColorStringFromPercArray($color);
}
/** @phpstan-param array<string, mixed> $annot */
public function exposeGetAnnotationBorder(array $annot): string
{
return $this->getAnnotationBorder($this->toAnnot($annot));
}
/** @phpstan-param array<string, mixed> $annot */
public function exposeGetOutAnnotationFlags(array $annot): string
{
return $this->getOutAnnotationFlags($this->toAnnot($annot));
}
/** @phpstan-param array<string>|int $flags */
public function exposeGetAnnotationFlagsCode(int|array $flags): int
{
return $this->getAnnotationFlagsCode($flags);
}
public function exposeGetOnOff(mixed $val): string
{
return $this->getOnOff($val);
}
/**
* @param array<string, true> $transpNames
*/
public function exposeStreamUsesTransparency(string $stream, array $transpNames): bool
{
return $this->streamUsesTransparency($stream, $transpNames);
}
/** @return array<string, true> */
public function exposeGetTransparencyResourceNames(): array
{
return $this->getTransparencyResourceNames();
}
/** @throws \Throwable */
public function exposeGetOutDestinations(): string
{
return $this->getOutDestinations();
}
public function exposeSortBookmarks(): void
{
$this->sortBookmarks();
}
public function exposeProcessPrevNextBookmarks(): int
{
return $this->processPrevNextBookmarks();
}
/** @throws \Throwable */
public function exposeGetOutBookmarks(): string
{
return $this->getOutBookmarks();
}
/** @throws \Throwable */
public function exposeGetOutJavascript(): string
{
return $this->getOutJavascript();
}
public function exposeGetXObjectDict(): string
{
return $this->getXObjectDict();
}
public function exposeGetLayerDict(): string
{
return $this->getLayerDict();
}
public function exposeGetOutResourcesDict(): string
{
return $this->getOutResourcesDict();
}
public function exposeGetPatternStreamResourceDict(string $stream): string
{
return $this->getPatternStreamResourceDict($stream);
}
/** @phpstan-param array<string, mixed> $annot */
public function exposeGetOutAnnotationOptSubtypeLine(array $annot): string
{
return $this->getOutAnnotationOptSubtypeLine($this->toAnnot($annot));
}
/** @phpstan-param array<string, mixed> $annot */
public function exposeGetOutAnnotationOptSubtypeSquare(array $annot): string
{
return $this->getOutAnnotationOptSubtypeSquare($this->toAnnot($annot));
}
/** @phpstan-param array<string, mixed> $annot */
public function exposeGetOutAnnotationOptSubtypeCircle(array $annot): string
{
return $this->getOutAnnotationOptSubtypeCircle($this->toAnnot($annot));
}
/** @phpstan-param array<string, mixed> $annot */
public function exposeGetOutAnnotationOptSubtypePolygon(array $annot): string
{
return $this->getOutAnnotationOptSubtypePolygon($this->toAnnot($annot));
}
/** @phpstan-param array<string, mixed> $annot */
public function exposeGetOutAnnotationOptSubtypePolyline(array $annot): string
{
return $this->getOutAnnotationOptSubtypePolyline($this->toAnnot($annot));
}
/** @phpstan-param array<string, mixed> $annot */
public function exposeGetOutAnnotationOptSubtypeHighlight(array $annot): string
{
return $this->getOutAnnotationOptSubtypeHighlight($this->toAnnot($annot));
}
/** @phpstan-param array<string, mixed> $annot */
public function exposeGetOutAnnotationOptSubtypeUnderline(array $annot): string
{
return $this->getOutAnnotationOptSubtypeUnderline($this->toAnnot($annot));
}
/** @phpstan-param array<string, mixed> $annot */
public function exposeGetOutAnnotationOptSubtypeSquiggly(array $annot): string
{
return $this->getOutAnnotationOptSubtypeSquiggly($this->toAnnot($annot));
}
/** @phpstan-param array<string, mixed> $annot */
public function exposeGetOutAnnotationOptSubtypeStrikeout(array $annot): string
{
return $this->getOutAnnotationOptSubtypeStrikeout($this->toAnnot($annot));
}
/** @phpstan-param array<string, mixed> $annot */
public function exposeGetOutAnnotationOptSubtypeStamp(array $annot): string
{
return $this->getOutAnnotationOptSubtypeStamp($this->toAnnot($annot));
}
/** @phpstan-param array<string, mixed> $annot */
public function exposeGetOutAnnotationOptSubtypeCaret(array $annot): string
{
return $this->getOutAnnotationOptSubtypeCaret($this->toAnnot($annot));
}
/** @phpstan-param array<string, mixed> $annot */
public function exposeGetOutAnnotationOptSubtypeInk(array $annot): string
{
return $this->getOutAnnotationOptSubtypeInk($this->toAnnot($annot));
}
/** @phpstan-param array<string, mixed> $annot */
public function exposeGetOutAnnotationOptSubtypePopup(array $annot): string
{
return $this->getOutAnnotationOptSubtypePopup($this->toAnnot($annot));
}
/**
* @phpstan-param array<string, mixed> $annot
* @throws \Throwable
*/
public function exposeGetOutAnnotationOptSubtypeMovie(array $annot): string
{
return $this->getOutAnnotationOptSubtypeMovie($this->toAnnot($annot));
}
/**
* @phpstan-param array<string, mixed> $annot
* @throws \Throwable
*/
public function exposeGetOutAnnotationOptSubtypeScreen(array $annot): string
{
return $this->getOutAnnotationOptSubtypeScreen($this->toAnnot($annot));
}
/** @phpstan-param array<string, mixed> $annot */
public function exposeGetOutAnnotationOptSubtypePrintermark(array $annot): string
{
return $this->getOutAnnotationOptSubtypePrintermark($this->toAnnot($annot));
}
/**
* @phpstan-param array<string, mixed> $annot
* @throws \Throwable
*/
public function exposeGetOutAnnotationOptSubtypeRedact(array $annot): string
{
return $this->getOutAnnotationOptSubtypeRedact($this->toAnnot($annot));
}
/** @phpstan-param array<string, mixed> $annot */
public function exposeGetOutAnnotationOptSubtypeTrapnet(array $annot): string
{
return $this->getOutAnnotationOptSubtypeTrapnet($this->toAnnot($annot));
}
/** @phpstan-param array<string, mixed> $annot */
public function exposeGetOutAnnotationOptSubtypeWatermark(array $annot): string
{
return $this->getOutAnnotationOptSubtypeWatermark($this->toAnnot($annot));
}
/** @phpstan-param array<string, mixed> $annot */
public function exposeGetOutAnnotationOptSubtype3D(array $annot): string
{
return $this->getOutAnnotationOptSubtype3D($this->toAnnot($annot));
}
/**
* @phpstan-param array<string, mixed> $annot
* @throws \Throwable
*/
public function exposeGetAnnotationRadioButtons(array $annot): string
{
return $this->getAnnotationRadioButtons($this->toAnnot($annot));
}
/**
* @phpstan-param array<string, mixed> $annot
* @return array{string, string}
* @throws \Throwable
*/
public function exposeGetAnnotationAppearanceStream(array $annot, float $width = 0, float $height = 0): array
{
$method = new \ReflectionMethod($this, 'getAnnotationAppearanceStream');
/** @var array{string, string} */
return $method->invokeArgs($this, [$this->toAnnot($annot), $width, $height]);
}
/**
* @phpstan-param array<string, mixed> $annot
* @throws \Throwable
*/
public function exposeGetOutAnnotationMarkups(array $annot, int $oid): string
{
return $this->getOutAnnotationMarkups($this->toAnnot($annot), $oid);
}
/**
* @phpstan-param array<string, mixed> $annot
* @throws \Throwable
*/
public function exposeGetOutAnnotationOptSubtype(array $annot, int $pagenum, int $oid, int $key): string
{
return $this->getOutAnnotationOptSubtype($this->toAnnot($annot), $pagenum, $oid, $key);
}
/** @phpstan-param array<string, mixed> $annot */
public function exposeGetOutAnnotationOptSubtypeText(array $annot): string
{
return $this->getOutAnnotationOptSubtypeText($this->toAnnot($annot));
}
/**
* @phpstan-param array<string, mixed> $annot
* @throws \Throwable
*/
public function exposeGetOutAnnotationOptSubtypeLink(array $annot, int $pagenum, int $oid): string
{
return $this->getOutAnnotationOptSubtypeLink($this->toAnnot($annot), $pagenum, $oid);
}
/**
* @phpstan-param array<string, mixed> $annot
* @throws \Throwable
*/
public function exposeGetOutAnnotationOptSubtypeFreetext(array $annot, int $oid): string
{
return $this->getOutAnnotationOptSubtypeFreetext($this->toAnnot($annot), $oid);
}
/** @phpstan-param array<string, mixed> $annot */
public function exposeGetOutAnnotationOptSubtypeFileattachment(array $annot, int $key): string
{
return $this->getOutAnnotationOptSubtypeFileattachment($this->toAnnot($annot), $key);
}
/** @phpstan-param array<string, mixed> $annot */
public function exposeGetOutAnnotationOptSubtypeSound(array $annot): string
{
return $this->getOutAnnotationOptSubtypeSound($this->toAnnot($annot));
}
/**
* @phpstan-param array<string, mixed> $annot
* @throws \Throwable
*/
public function exposeGetOutAnnotationOptSubtypeWidget(array $annot, int $oid): string
{
return $this->getOutAnnotationOptSubtypeWidget($this->toAnnot($annot), $oid);
}
public function exposeGetOutPDFHeader(): string
{
return $this->getOutPDFHeader();
}
/** @throws \Throwable */
public function exposeGetOutPDFBody(): string
{
return $this->getOutPDFBody();
}
/** @throws \Throwable */
public function exposeGetOutCatalog(): string
{
return $this->getOutCatalog();
}
/** @throws \Throwable */
public function exposeAppendDssRevision(string $pdf): string
{
return $this->appendDssRevision($pdf);
}
/** @throws \Throwable */
public function exposeAppendDocTimeStampRevision(string $pdf): string
{
return $this->appendDocTimeStampRevision($pdf);
}
public function exposeExtractSignatureContents(string $pdf): string
{
return $this->extractSignatureContents($pdf);
}
/** @throws \Throwable */
public function exposeGetOutICC(): string
{
return $this->getOutICC();
}
/** @throws \Throwable */
public function exposeGetOutputIntentsSrgb(): string
{
return $this->getOutputIntentsSrgb();
}
/** @throws \Throwable */
public function exposeGetOutputIntentsPdfX(): string
{
return $this->getOutputIntentsPdfX();
}
/** @throws \Throwable */
public function exposeGetOutputIntents(): string
{
return $this->getOutputIntents();
}
/** @throws \Throwable */
public function exposeGetPDFLayers(): string
{
return $this->getPDFLayers();
}
/** @throws \Throwable */
public function exposeGetOutOCG(): string
{
return $this->getOutOCG();
}
/** @throws \Throwable */
public function exposeGetOutAPXObjects(float $width = 0, float $height = 0, string $stream = ''): string
{
return $this->getOutAPXObjects($width, $height, $stream);
}
/** @throws \Throwable */
public function exposeGetOutXObjects(): string
{
return $this->getOutXObjects();
}
/** @throws \Throwable */
public function exposeGetOutPatterns(): string
{
return $this->getOutPatterns();
}
/** @throws \Throwable */
public function exposeGetOutEmbeddedFiles(): string
{
return $this->getOutEmbeddedFiles();
}
/** @throws \Throwable */
public function exposeGetOutAnnotations(): string
{
return $this->getOutAnnotations();
}
/** @throws \Throwable */
public function exposeGetOutSignatureFields(): string
{
return $this->getOutSignatureFields();
}
/**
* @phpstan-return TSignDocPrepared
*/
public function exposePrepareDocumentForSignature(string $pdfdoc): array
{
return $this->prepareDocumentForSignature($pdfdoc);
}
/** @throws \Throwable */
public function exposeConvertBinarySignatureToHex(string $signature): string
{
return $this->convertBinarySignatureToHex($signature);
}
/**
* @return array{certs: list<string>, ocsp: list<string>, crls: list<string>}
* @throws \Throwable
*/
public function exposeCollectDssMaterial(): array
{
return $this->collectDssMaterial();
}
public function setMockTimestampResponse(string $response): void
{
$this->mockTsaResp = $response;
}
public function setMockTimestampThrows(bool $throws): void
{
$this->mockTsaThrows = $throws;
}
public function getCapturedTimestampRequest(): string
{
return $this->tsaReq;
}
public function setMockOcspResponse(string $response): void
{
$this->mockOcspResp = $response;
}
public function setMockOcspThrows(bool $throws): void
{
$this->mockOcspThrows = $throws;
}
public function getCapturedOcspRequest(): string
{
return $this->ocspReq;
}
public function getCapturedOcspUrl(): string
{
return $this->ocspUrl;
}
public function setMockCrlResponse(string $response): void
{
$this->mockCrlResp = $response;
}
public function setMockCrlThrows(bool $throws): void
{
$this->mockCrlThrows = $throws;
}
public function getCapturedCrlUrl(): string
{
return $this->crlUrl;
}
protected function postTimestampRequest(string $request): string
{
$this->tsaReq = $request;
if ($this->mockTsaThrows) {
throw new \Com\Tecnick\Pdf\Exception('mock tsa transport error');
}
if ($this->mockTsaResp !== '') {
return $this->mockTsaResp;
}
return parent::postTimestampRequest($request);
}
protected function postOcspRequest(string $url, string $request): string
{
$this->ocspUrl = $url;
$this->ocspReq = $request;
if ($this->mockOcspThrows) {
throw new \Com\Tecnick\Pdf\Exception('mock ocsp transport error');
}
if ($this->mockOcspResp !== '') {
return $this->mockOcspResp;
}
return parent::postOcspRequest($url, $request);
}
protected function getCrlData(string $url): string
{
$this->crlUrl = $url;
if ($this->mockCrlThrows) {
throw new \Com\Tecnick\Pdf\Exception('mock crl transport error');
}
if ($this->mockCrlResp !== '') {
return $this->mockCrlResp;
}
return parent::getCrlData($url);
}
/** @throws \Throwable */
public function exposeSignDocument(string $pdfdoc): string
{
return $this->signDocument($pdfdoc);
}
/** @throws \Throwable */
public function exposeBuildSignatureCms(string $content): string
{
return $this->buildSignatureCms($content);
}
public function exposeSignatureSubFilter(): string
{
return $this->signatureSubFilter();
}
public function exposeSignatureDigestAlgorithm(): string
{
return $this->signatureDigestAlgorithm();
}
public function exposeSignatureContentsLength(): int
{
return $this->signatureContentsLength();
}
/**
* @param array<int, string> $objects
* @throws \Throwable
*/
public function exposeAppendIncrementalRevision(string $pdf, array $objects): string
{
return $this->appendIncrementalRevision($pdf, $objects);
}
/**
* @return list<string>
* @throws \Throwable
*/
public function exposeLoadExtraCertificates(string $extracerts): array
{
return $this->loadExtraCertificates($extracerts);
}
/** @throws \Throwable */
public function exposeGetOutSignature(): string
{
return $this->getOutSignature();
}
public function exposeGetOutSignatureDocMDP(): string
{
return $this->getOutSignatureDocMDP();
}
public function exposeGetOutSignatureUserRights(): string
{
return $this->getOutSignatureUserRights();
}
/** @phpstan-param array<string, int|array<int>> $objid */
public function setOutputState(int $pon, array $objid, string $fileid = 'ABC123', int $encryptObjId = 0): void
{
$this->pon = $pon;
$form = $objid['form'] ?? null;
if (\is_array($form)) {
$typedForm = \array_map(static fn($objId): int => (int) $objId, $form);
$this->objid['form'] = $typedForm;
}
$intKeys = ['catalog', 'dests', 'dss', 'info', 'pages', 'resdic', 'signature', 'srgbicc', 'xmp'];
foreach ($intKeys as $key) {
$value = $objid[$key] ?? null;
if (!\is_int($value)) {
continue;
}
switch ($key) {
case 'catalog':
$this->objid['catalog'] = $value;
break;
case 'dests':
$this->objid['dests'] = $value;
break;
case 'dss':
$this->objid['dss'] = $value;
break;
case 'info':
$this->objid['info'] = $value;
break;
case 'pages':
$this->objid['pages'] = $value;
break;
case 'resdic':
$this->objid['resdic'] = $value;
break;
case 'signature':
$this->objid['signature'] = $value;
break;
case 'srgbicc':
$this->objid['srgbicc'] = $value;
break;
case 'xmp':
$this->objid['xmp'] = $value;
break;
}
}
$this->fileid = $fileid;
$ref = new \ReflectionObject($this->encrypt);
$prop = $ref->getProperty('encryptdata');
/** @var array<string, mixed> $data */
$data = $prop->getValue($this->encrypt);
$data['objid'] = $encryptObjId;
$prop->setValue($this->encrypt, $data);
}
public function setPdfaMode(int $pdfa): void
{
$this->pdfa = $pdfa;
}
/** @phpstan-return array<int, TOutline> */
public function getOutlinesState(): array
{
return $this->outlines;
}
public function getJavascriptTree(): string
{
return $this->jstree;
}
/** @throws \Throwable */
public function exposeGetOutSVGMasks(): string
{
return $this->getOutSVGMasks();
}
public function exposeGetSVGMaskExtGStateEntries(): string
{
return $this->getSVGMaskExtGStateEntries();
}
/** @phpstan-param array<string, array<string, mixed>> $masks */
public function setSvgMasks(array $masks): void
{
/** @var array<string, TSVGMaskObject> $typedMasks */
$typedMasks = [];
foreach ($masks as $key => $mask) {
if (!isset($mask['bbox']) || !\is_array($mask['bbox'])) {
continue;
}
$bbox = $mask['bbox'];
$bbox0 = isset($bbox[0]) && \is_numeric($bbox[0]) ? (float) $bbox[0] : 0.0;
$bbox1 = isset($bbox[1]) && \is_numeric($bbox[1]) ? (float) $bbox[1] : 0.0;
$bbox2 = isset($bbox[2]) && \is_numeric($bbox[2]) ? (float) $bbox[2] : 0.0;
$bbox3 = isset($bbox[3]) && \is_numeric($bbox[3]) ? (float) $bbox[3] : 0.0;
$typedMasks[$key] = [
'id' => isset($mask['id']) ? (string) $mask['id'] : $key,
'stream' => isset($mask['stream']) ? (string) $mask['stream'] : '',
'bbox' => [
$bbox0,
$bbox1,
$bbox2,
$bbox3,
],
'gs_n' => (int) ($mask['gs_n'] ?? 0),
];
}
$this->svgmasks = $typedMasks;
}
/** @return array<string, mixed> */
public function getSvgMasks(): array
{
return $this->svgmasks;
}
/**
* @param array<string> $names
*/
public function exposeExtractNamedResourceRefs(string $dict, array $names): string
{
return $this->extractNamedResourceRefs($dict, $names);
}
/** @throws \Throwable */
public function exposeGetOutStructTreeRoot(): string
{
return $this->getOutStructTreeRoot();
}
/** @phpstan-param array<string, mixed> $annot */
public function exposeGetOutAnnotationRD(array $annot): string
{
return $this->getOutAnnotationRectDifferences($annot);
}
public function exposeSetPageStructParents(string $pdfpages): string
{
return $this->setPageStructParents($pdfpages);
}
/** @throws \Throwable */
public function exposePostTimestampRequest(string $request): string
{
return $this->postTimestampRequest($request);
}
/** @throws \Throwable */
public function exposeParentPostTimestampRequest(string $request): string
{
return parent::postTimestampRequest($request);
}
/** @throws \Throwable */
public function exposeGetCertificateSourceContent(string $source): string
{
return $this->getCertificateSourceContent($source);
}
/**
* @return array<int, string>
* @throws \Throwable
*/
public function exposeExtractPemCertificates(string $content): array
{
return $this->extractPemCertificates($content);
}
public function exposeGetPatternDict(): string
{
return $this->getPatternDict();
}
/** @throws \Throwable */
public function exposeWriteRawPdfOutput(string $rawpdf): void
{
$this->writeRawPdfOutput($rawpdf);
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
/**
* TestablePdfColor.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 TestablePdfColor extends \Com\Tecnick\Pdf\PdfColor
{
/** @return array{0: string, 1: float}|null */
public function exposeParseSpotCssFunction(string $color): ?array
{
return $this->parseSpotCssFunction($color);
}
public function exposeParseSpotNameToken(#[\SensitiveParameter] string $token): string
{
return $this->parseSpotNameToken($token);
}
public function exposeParseSpotTintToken(#[\SensitiveParameter] string $token): ?float
{
return $this->parseSpotTintToken($token);
}
public function exposeIsRegisteredSpotColor(string $color): bool
{
return $this->isRegisteredSpotColor($color);
}
public function exposeGetPdfProcessColor(string $color, bool $stroke): string
{
return $this->getPdfProcessColor($color, $stroke);
}
public function exposeGetLabProcessColor(string $color): ?\Com\Tecnick\Color\Model\Lab
{
return $this->getLabProcessColor($color);
}
public function exposeGetPdfLabProcessColor(\Com\Tecnick\Color\Model\Lab $labColor, bool $stroke): string
{
return $this->getPdfLabProcessColor($labColor, $stroke);
}
}
File diff suppressed because it is too large Load Diff
+53
View File
@@ -0,0 +1,53 @@
<?php
/**
* TestableTcpdf.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 Com\Tecnick\Pdf\Cache\FontSubsetCacheAdapter;
use Com\Tecnick\Pdf\Cache\ImageCacheAdapter;
class TestableTcpdf extends \Com\Tecnick\Pdf\Tcpdf
{
public function exposeEnableSignatureApproval(bool $enabled = true): static
{
return $this->enableSignatureApproval($enabled);
}
public function exposeSetSignAnnotRefs(): void
{
$this->setSignAnnotRefs();
}
/**
* @param \Com\Tecnick\Pdf\Cache\CacheInterface::TYPE_* $type
*
* @throws \Com\Tecnick\Pdf\Exception
*/
public function exposeExtCacheEnabledFor(string $type): bool
{
return $this->extCacheEnabledFor($type);
}
public function exposeImageCacheAdapter(): ?ImageCacheAdapter
{
return $this->imageCacheAdapter();
}
public function exposeFontSubsetCacheAdapter(): ?FontSubsetCacheAdapter
{
return $this->fontSubsetCacheAdapter();
}
}
+668
View File
@@ -0,0 +1,668 @@
<?php
/**
* TestableText.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;
/**
* @phpstan-import-type TTextDims from \Com\Tecnick\Pdf\Font\Stack
* @phpstan-import-type TTMatrix from \Com\Tecnick\Pdf\Graph\Base
* @phpstan-import-type TextShadow from \Com\Tecnick\Pdf\Text
* @phpstan-import-type TextLinePos from \Com\Tecnick\Pdf\Text
*/
class TestableText extends \Com\Tecnick\Pdf\Tcpdf
{
public function exposeGetOutUTOLine(float $pntx, float $pnty, float $pwidth, float $psize): string
{
return $this->getOutUTOLine($pntx, $pnty, $pwidth, $psize);
}
/** @throws \Throwable */
public function exposeCleanupText(string $txt): string
{
return $this->cleanupText($txt);
}
/** @throws \Throwable */
public function exposeGetOutTextPosXY(string $raw, float $posx = 0, float $posy = 0, string $mode = 'Td'): string
{
return $this->getOutTextPosXY($raw, $posx, $posy, $mode);
}
public function exposeGetTextRenderingMode(bool $fill = true, bool $stroke = false, bool $clip = false): int
{
return $this->getTextRenderingMode($fill, $stroke, $clip);
}
/**
* @phpstan-param array<int, int> $ordarr
* @phpstan-return array{fontchanged: bool, fontout: string, dim: TTextDims, layout: array{lines: array<int, TextLinePos>, maxwidth: float, txtheight: float}}
* @throws \Throwable
*/
public function exposeFitTextCellByFontSize(
array $ordarr,
float $maxWidth,
float $maxHeight,
float $offsetPoints,
float $lineSpacePoints,
): array {
return $this->fitTextCellByFontSize($ordarr, $maxWidth, $maxHeight, $offsetPoints, $lineSpacePoints);
}
/**
* @phpstan-param array<int, int> $ordarr
* @phpstan-param TTextDims $dim
* @phpstan-return array{fontchanged: bool, linewidth: float, layout: array{lines: array<int, TextLinePos>, maxwidth: float, txtheight: float}}
* @throws \Throwable
*/
public function exposeFitTextCellByStretch(
array $ordarr,
array $dim,
float $maxWidth,
float $maxHeight,
float $offsetPoints,
float $lineSpacePoints,
): array {
return $this->fitTextCellByStretch($ordarr, $dim, $maxWidth, $maxHeight, $offsetPoints, $lineSpacePoints);
}
public function exposeGetOutTextStateOperatorTc(string $raw, int|float $value = 0): string
{
return $this->getOutTextStateOperatorTc($raw, $value);
}
public function exposeGetOutTextStateOperatorTw(string $raw, int|float $value = 0): string
{
return $this->getOutTextStateOperatorTw($raw, $value);
}
public function exposeGetOutTextStateOperatorTz(string $raw, int|float $value = 0): string
{
return $this->getOutTextStateOperatorTz($raw, $value);
}
public function exposeGetOutTextStateOperatorTL(string $raw, int|float $value = 0): string
{
return $this->getOutTextStateOperatorTL($raw, $value);
}
public function exposeGetOutTextStateOperatorTr(string $raw, int|float $value = 0): string
{
return $this->getOutTextStateOperatorTr($raw, $value);
}
public function exposeGetOutTextStateOperatorTs(string $raw, int|float $value = 0): string
{
return $this->getOutTextStateOperatorTs($raw, $value);
}
public function exposeGetOutTextStateOperatorw(string $raw, int|float $value = 0): string
{
return $this->getOutTextStateOperatorw($raw, $value);
}
/** @phpstan-param array<int, int|float> $matrix */
public function exposeGetOutTextPosMatrix(string $raw, array $matrix = [1, 0, 0, 1, 0, 0]): string
{
if (\count($matrix) !== 6) {
return '';
}
assert(isset($matrix[0]), "\$matrix[0] must be set");
assert(isset($matrix[1]), "\$matrix[1] must be set");
assert(isset($matrix[2]), "\$matrix[2] must be set");
assert(isset($matrix[3]), "\$matrix[3] must be set");
assert(isset($matrix[4]), "\$matrix[4] must be set");
assert(isset($matrix[5]), "\$matrix[5] must be set");
$textMatrix = [
(float) $matrix[0],
(float) $matrix[1],
(float) $matrix[2],
(float) $matrix[3],
(float) $matrix[4],
(float) $matrix[5],
];
return $this->getOutTextPosMatrix($raw, $textMatrix);
}
/** @phpstan-param array<int, int|float> $matrix */
public function exposeRawGetOutTextPosMatrix(string $raw, array $matrix): string
{
if (\count($matrix) !== 6) {
return '';
}
if (!isset($matrix[0], $matrix[1], $matrix[2], $matrix[3], $matrix[4], $matrix[5])) {
return '';
}
$textMatrix = [
(float) $matrix[0],
(float) $matrix[1],
(float) $matrix[2],
(float) $matrix[3],
(float) $matrix[4],
(float) $matrix[5],
];
return $this->getOutTextPosMatrix($raw, $textMatrix);
}
public function exposeGetOutTextShowing(string $str, string $mode = 'Tj'): string
{
return $this->getOutTextShowing($str, $mode);
}
public function exposeGetOutTextObject(string $raw = ''): string
{
return $this->getOutTextObject($raw);
}
/**
* @phpstan-param array<int, int> $ordarr
* @phpstan-return array<int, int>
*/
public function exposeReplaceUnicodeChars(array $ordarr): array
{
return $this->replaceUnicodeChars($ordarr);
}
/**
* @phpstan-param array<int, int> $ordarr
* @phpstan-return array<int, int>
*/
public function exposeRemoveOrdArrSoftHyphens(array $ordarr): array
{
return $this->removeOrdArrSoftHyphens($ordarr);
}
/**
* @phpstan-param array<int, int> $ordarr
* @phpstan-return array<int, int>
*/
public function exposeAddOrdArrBreakPoints(array $ordarr): array
{
return $this->addOrdArrBreakPoints($ordarr);
}
/** @throws \Throwable */
public function exposeSetPageContext(int $pid = -1): void
{
$this->setPageContext($pid);
}
public function exposeEscapePerc(string $str): string
{
return $this->escapePerc($str);
}
/** @throws \Throwable */
public function exposeGetStringWidth(string $str): float
{
return $this->getStringWidth($str);
}
/**
* @phpstan-return array{0: string, 1: array<int, int>, 2: TTextDims}
* @throws \Throwable
*/
public function exposePrepareText(string $txt, string $forcedir = ''): array
{
$ordarr = [];
$dim = self::DIM_DEFAULT;
$this->prepareText($txt, $ordarr, $dim, $forcedir);
return [$txt, $ordarr, $dim];
}
/**
* @phpstan-return array{0: string, 1: array<int, int>, 2: TTextDims, 3: bool}
* @throws \Throwable
*/
public function exposePrepareTextWithDir(string $txt, string $forcedir = ''): array
{
$ordarr = [];
$dim = self::DIM_DEFAULT;
$baseRtl = false;
$this->prepareText($txt, $ordarr, $dim, $forcedir, $baseRtl);
return [$txt, $ordarr, $dim, $baseRtl];
}
/**
* @phpstan-param array<int, int> $logicalOrdArr
* @throws \Throwable
*/
public function exposeIsOrdArrBaseRtl(array $logicalOrdArr, string $forcedir = ''): bool
{
return $this->isOrdArrBaseRtl($logicalOrdArr, $forcedir);
}
/**
* @phpstan-param array<int, int> $ordarr
* @phpstan-param TTextDims $dim
* @phpstan-return array<int, TextLinePos>
* @throws \Throwable
*/
public function exposeSplitLines(
array $ordarr,
array $dim,
float $pwidth,
float $poffset = 0,
bool $rtl = false,
): array {
return $this->splitLines($ordarr, $dim, $pwidth, $poffset, $rtl);
}
/**
* @phpstan-param array<int, int> $ordarr
* @phpstan-param TTextDims|array{} $dim
* @phpstan-param TextShadow|null $shadow
* @throws \Throwable
*/
public function exposeGetOutTextLine(
string $txt,
array $ordarr,
array $dim,
float $posx = 0,
float $posy = 0,
float $width = 0,
float $strokewidth = 0,
float $wordspacing = 0,
float $leading = 0,
float $rise = 0,
bool $fill = true,
bool $stroke = false,
bool $underline = false,
bool $linethrough = false,
bool $overline = false,
bool $clip = false,
?array $shadow = null,
): string {
if ($txt === '' || $dim === []) {
return '';
}
/** @var TTextDims $lineDim */
$lineDim = \array_replace(self::DIM_DEFAULT, $dim);
return $this->getOutTextLine(
$txt,
$ordarr,
$lineDim,
$posx,
$posy,
$width,
$strokewidth,
$wordspacing,
$leading,
$rise,
$fill,
$stroke,
$underline,
$linethrough,
$overline,
$clip,
$shadow,
);
}
/**
* @phpstan-param array<int, int> $ordarr
* @phpstan-param TTextDims|array{} $dim
* @phpstan-param TextShadow|null $shadow
* @throws \Throwable
*/
public function exposeRawGetOutTextLine(
string $txt,
array $ordarr,
array $dim,
float $posx = 0,
float $posy = 0,
float $width = 0,
float $strokewidth = 0,
float $wordspacing = 0,
float $leading = 0,
float $rise = 0,
bool $fill = true,
bool $stroke = false,
bool $underline = false,
bool $linethrough = false,
bool $overline = false,
bool $clip = false,
?array $shadow = null,
): string {
if ($txt === '' || $dim === []) {
return '';
}
/** @var TTextDims $lineDim */
$lineDim = \array_replace(self::DIM_DEFAULT, $dim);
return $this->getOutTextLine(
$txt,
$ordarr,
$lineDim,
$posx,
$posy,
$width,
$strokewidth,
$wordspacing,
$leading,
$rise,
$fill,
$stroke,
$underline,
$linethrough,
$overline,
$clip,
$shadow,
);
}
/**
* @phpstan-param array<int, int> $ordarr
* @phpstan-param TTextDims|array{} $dim
* @throws \Throwable
*/
public function exposeOutTextLine(
string $txt,
array $ordarr,
array $dim,
float $posx = 0,
float $posy = 0,
float $width = 0,
float $strokewidth = 0,
float $wordspacing = 0,
float $leading = 0,
float $rise = 0,
bool $fill = true,
bool $stroke = false,
bool $underline = false,
bool $linethrough = false,
bool $overline = false,
bool $clip = false,
): string {
if ($txt === '' || $dim === []) {
return '';
}
/** @var TTextDims $lineDim */
$lineDim = \array_replace(self::DIM_DEFAULT, $dim);
return $this->outTextLine(
$txt,
$ordarr,
$lineDim,
$posx,
$posy,
$width,
$strokewidth,
$wordspacing,
$leading,
$rise,
$fill,
$stroke,
$underline,
$linethrough,
$overline,
$clip,
);
}
/**
* @phpstan-param array<int, int> $ordarr
* @phpstan-param TTextDims|array{} $dim
* @throws \Throwable
*/
public function exposeRawOutTextLine(
string $txt,
array $ordarr,
array $dim,
float $posx = 0,
float $posy = 0,
float $width = 0,
float $strokewidth = 0,
float $wordspacing = 0,
float $leading = 0,
float $rise = 0,
bool $fill = true,
bool $stroke = false,
bool $underline = false,
bool $linethrough = false,
bool $overline = false,
bool $clip = false,
): string {
if ($txt === '' || $dim === []) {
return '';
}
/** @var TTextDims $lineDim */
$lineDim = \array_replace(self::DIM_DEFAULT, $dim);
return $this->outTextLine(
$txt,
$ordarr,
$lineDim,
$posx,
$posy,
$width,
$strokewidth,
$wordspacing,
$leading,
$rise,
$fill,
$stroke,
$underline,
$linethrough,
$overline,
$clip,
);
}
/**
* @phpstan-param array<int, int> $ordarr
* @phpstan-param array<int, TextLinePos> $lines
* @phpstan-param TextShadow|null $shadow
* @throws \Throwable
*/
public function exposeOutTextLines(
array $ordarr,
array $lines,
float $posx,
float $posy,
float $width,
float $offset,
float $fontascent,
float $linespace = 0,
float $strokewidth = 0,
float $wordspacing = 0,
float $leading = 0,
float $rise = 0,
string $halign = '',
bool $jlast = true,
bool $fill = true,
bool $stroke = false,
bool $underline = false,
bool $linethrough = false,
bool $overline = false,
bool $clip = false,
?array $shadow = null,
): string {
if ($ordarr === [] || $lines === []) {
return '';
}
return $this->outTextLines(
$ordarr,
$lines,
$posx,
$posy,
$width,
$offset,
$fontascent,
$linespace,
$strokewidth,
$wordspacing,
$leading,
$rise,
$halign,
$jlast,
$fill,
$stroke,
$underline,
$linethrough,
$overline,
$clip,
$shadow,
);
}
/**
* @phpstan-param array<int, int> $ordarr
* @phpstan-param array<int, TextLinePos> $lines
* @phpstan-param TextShadow|null $shadow
* @throws \Throwable
*/
public function exposeRawOutTextLines(
array $ordarr,
array $lines,
float $posx,
float $posy,
float $width,
float $offset,
float $fontascent,
float $linespace = 0,
float $strokewidth = 0,
float $wordspacing = 0,
float $leading = 0,
float $rise = 0,
string $halign = '',
bool $jlast = true,
bool $fill = true,
bool $stroke = false,
bool $underline = false,
bool $linethrough = false,
bool $overline = false,
bool $clip = false,
?array $shadow = null,
): string {
return $this->outTextLines(
$ordarr,
$lines,
$posx,
$posy,
$width,
$offset,
$fontascent,
$linespace,
$strokewidth,
$wordspacing,
$leading,
$rise,
$halign,
$jlast,
$fill,
$stroke,
$underline,
$linethrough,
$overline,
$clip,
$shadow,
);
}
/**
* @phpstan-param array<int, int> $ordarr
* @phpstan-param TTextDims $dim
* @throws \Throwable
*/
public function exposeGetJustifiedString(string $txt, array $ordarr, array $dim, float $width = 0): string
{
return $this->getJustifiedString($txt, $ordarr, $dim, $width);
}
/**
* @phpstan-param array<int, int> $ordarr
* @phpstan-return TTextDims
* @throws \Throwable
*/
public function exposeGetOrdArrDims(array $ordarr): array
{
return $this->font->getOrdArrDims($ordarr);
}
/**
* @phpstan-param array<string, string> $phyphens
* @phpstan-param array<int, int> $ordarr
* @phpstan-return array<int, int>
* @throws \Throwable
*/
public function exposeHyphenateTextOrdArr(array $phyphens, array $ordarr): array
{
return $this->hyphenateTextOrdArr($phyphens, $ordarr);
}
/**
* @phpstan-param array<string, string> $phyphens
* @phpstan-param array<int, int> $ordarr
* @phpstan-return array<int, int>
* @throws \Throwable
*/
public function exposeHyphenateWordOrdArr(
array $phyphens,
array $ordarr,
int $leftmin = 1,
int $rightmin = 2,
int $charmin = 1,
int $charmax = 8,
): array {
return $this->hyphenateWordOrdArr($phyphens, $ordarr, $leftmin, $rightmin, $charmin, $charmax);
}
/**
* @phpstan-return array<int, int>
* @throws \Throwable
*/
public function exposeStrToOrdArr(string $txt): array
{
$ords = [];
foreach ($this->uniconv->strToOrdArr($txt) as $key => $ord) {
$ords[$key] = (int) $ord;
}
/** @var array<int, int> $ords */
return $ords;
}
/**
* @phpstan-param array<int, int> $ordarr
* @throws \Throwable
*/
public function exposeGetActualTextForOrdarr(array $ordarr): string
{
return $this->getActualTextForOrdarr($ordarr);
}
public function exposeFormatPdfUaActualText(string $txt): string
{
return $this->formatPdfUaActualText($txt);
}
public function exposeTagPdfUaTextContent(string $content, int $pid, string $actualText = ''): string
{
return $this->tagPdfUaTextContent($content, $pid, $actualText);
}
public function exposeRegisterPdfUaAnnotation(int $oid, int $pid): void
{
$this->registerPdfUaAnnotation($oid, $pid);
}
/**
* @param array{float, float, float, float}|array{} $bbox Figure bounding box in points.
*/
public function exposeTagPdfUaFigureContent(string $content, int $pid, string $alt = '', array $bbox = []): string
{
return $this->tagPdfUaFigureContent($content, $pid, $alt, $bbox);
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
/**
* TextFitModeTest.php
*
* @since 2026-07-17
* @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 Com\Tecnick\Pdf\TextFitMode;
/**
* TextFitMode enum test
*
* @since 2026-07-17
* @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
*/
class TextFitModeTest extends TestUtil
{
public function testCaseBackingValues(): void
{
$this->assertSame('', TextFitMode::Off->value);
$this->assertSame('T', TextFitMode::Truncate->value);
$this->assertSame('S', TextFitMode::Stretch->value);
$this->assertSame('F', TextFitMode::ShrinkFont->value);
}
public function testFromLooseCanonical(): void
{
$this->assertSame(TextFitMode::Truncate, TextFitMode::fromLoose('t'));
$this->assertSame(TextFitMode::ShrinkFont, TextFitMode::fromLoose(' F '));
$this->assertSame(TextFitMode::Off, TextFitMode::fromLoose(''));
}
public function testFromLoosePassesThroughEnumInstance(): void
{
$this->assertSame(TextFitMode::Stretch, TextFitMode::fromLoose(TextFitMode::Stretch));
}
public function testFromLooseRoundTrip(): void
{
foreach (TextFitMode::cases() as $case) {
$this->assertSame($case, TextFitMode::fromLoose($case->value));
}
}
public function testFromLooseUnknownFallsBack(): void
{
$this->assertSame(TextFitMode::Off, TextFitMode::fromLoose('X'));
$this->assertSame(TextFitMode::Off, TextFitMode::fromLoose('wrap'));
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
/**
* TextHAlignTest.php
*
* @since 2026-07-17
* @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 Com\Tecnick\Pdf\TextHAlign;
/**
* TextHAlign enum test
*
* @since 2026-07-17
* @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
*/
class TextHAlignTest extends TestUtil
{
public function testCaseBackingValues(): void
{
$this->assertSame('L', TextHAlign::Left->value);
$this->assertSame('C', TextHAlign::Center->value);
$this->assertSame('R', TextHAlign::Right->value);
$this->assertSame('J', TextHAlign::Justify->value);
}
public function testFromLooseCanonical(): void
{
$this->assertSame(TextHAlign::Left, TextHAlign::fromLoose('l'));
$this->assertSame(TextHAlign::Right, TextHAlign::fromLoose('R'));
$this->assertSame(TextHAlign::Justify, TextHAlign::fromLoose(' j '));
}
public function testFromLoosePassesThroughEnumInstance(): void
{
$this->assertSame(TextHAlign::Justify, TextHAlign::fromLoose(TextHAlign::Justify));
}
public function testFromLooseRoundTrip(): void
{
foreach (TextHAlign::cases() as $case) {
$this->assertSame($case, TextHAlign::fromLoose($case->value));
}
}
public function testFromLooseUnknownFallsBack(): void
{
$this->assertSame(TextHAlign::Left, TextHAlign::fromLoose('Z'));
$this->assertSame(TextHAlign::Left, TextHAlign::fromLoose(''));
}
}
File diff suppressed because it is too large Load Diff
+68
View File
@@ -0,0 +1,68 @@
<?php
/**
* TextVAlignTest.php
*
* @since 2026-07-17
* @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 Com\Tecnick\Pdf\TextVAlign;
/**
* TextVAlign enum test
*
* @since 2026-07-17
* @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
*/
class TextVAlignTest extends TestUtil
{
public function testCaseBackingValues(): void
{
$this->assertSame('T', TextVAlign::Top->value);
$this->assertSame('C', TextVAlign::Center->value);
$this->assertSame('B', TextVAlign::Bottom->value);
$this->assertSame('A', TextVAlign::Ascent->value);
$this->assertSame('L', TextVAlign::Baseline->value);
$this->assertSame('D', TextVAlign::Descent->value);
}
public function testFromLooseCanonical(): void
{
$this->assertSame(TextVAlign::Top, TextVAlign::fromLoose('t'));
$this->assertSame(TextVAlign::Descent, TextVAlign::fromLoose('d'));
$this->assertSame(TextVAlign::Ascent, TextVAlign::fromLoose('A'));
}
public function testFromLoosePassesThroughEnumInstance(): void
{
$this->assertSame(TextVAlign::Baseline, TextVAlign::fromLoose(TextVAlign::Baseline));
}
public function testFromLooseRoundTrip(): void
{
foreach (TextVAlign::cases() as $case) {
$this->assertSame($case, TextVAlign::fromLoose($case->value));
}
}
public function testFromLooseUnknownFallsBack(): void
{
$this->assertSame(TextVAlign::Center, TextVAlign::fromLoose('Z'));
$this->assertSame(TextVAlign::Center, TextVAlign::fromLoose(''));
}
}
@@ -0,0 +1,30 @@
%PDF-1.4
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /CropBox [0 0 500 700] /BleedBox [0 0 480 680] /TrimBox [0 0 460 660] /ArtBox [0 0 440 640] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>
endobj
4 0 obj
<< /Length 43 >>\nstream\nBT /F1 12 Tf 50 650 Td (Box Fixture) Tj ET
endstream
endobj
5 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
endobj
xref
0 6
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000333 00000 n
0000000427 00000 n
trailer
<< /Size 6 /Root 1 0 R >>
startxref
497
%%EOF
@@ -0,0 +1,23 @@
-----BEGIN CERTIFICATE-----
MIID6zCCAtOgAwIBAgIUDf0c0hsRARroCiCYB7AQhOJRUw8wDQYJKoZIhvcNAQEL
BQAwJjESMBAGA1UEAwwJVGVzdCBDZXJ0MRAwDgYDVQQKDAdUZXN0T3JnMB4XDTI2
MDQyMTE5MDgxOFoXDTM2MDQxODE5MDgxOFowJjESMBAGA1UEAwwJVGVzdCBDZXJ0
MRAwDgYDVQQKDAdUZXN0T3JnMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC
AQEArLHlPPtm49Pr+32TI7FMWfUQOWu8XAT11eKlQyjg9YFtj4VS71rlgZ67XqJp
ck1AxhMdNVyK1Njr1qVjDjs0cW50zOyRJ1MIrdwzLGDf3J9xOj8rr7eaL/iwkcMm
6UfIS6Smv8Ti+U0qaoUcQMDmdrMmnD/CDulMtsPLVp3674aSzhtG/dTovsU9b7fx
+Qmq+EIzINxS2XSB904R5kvr3b/BWo0e1IVb+9cbrmhjYG8er0bxHH2iNMvZ85Gi
1ompJLm2yDJ95cnunmyylfdcHivmySBBLONQVsl7rK0VRhZkaMtr+ML3BYlunKSw
DW4Js+YOQ4nruBhgI6+qF6dVSQIDAQABo4IBDzCCAQswDwYDVR0TAQH/BAUwAwEB
/zBeBggrBgEFBQcBAQRSMFAwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmV4YW1w
bGUuY29tLzAoBggrBgEFBQcwAoYcaHR0cDovL2NhLmV4YW1wbGUuY29tL2NhLmNy
dDBYBgNVHR8EUTBPMCWgI6Ahhh9odHRwOi8vY3JsLmV4YW1wbGUuY29tL3Jvb3Qu
Y3JsMCagJKAihiBodHRwOi8vY3JsMi5leGFtcGxlLmNvbS9yb290LmNybDAdBgNV
HQ4EFgQU4y57gmpbH82WnfezAYVEv2DQwmIwHwYDVR0jBBgwFoAU4y57gmpbH82W
nfezAYVEv2DQwmIwDQYJKoZIhvcNAQELBQADggEBAErTUFtJI8ZDwLyBn8vIXWYa
bwBsLFVvk84xouubkQgtGFh75ykTmNmSSF8YrB1a/rV/KHWJTdyALCtS7W0tIpVG
nUYQ6rkc5zifIRoRQUWuUWduQPhd3hEjE1oE7t+jG0Nji1ccJ2MU8qm5Lby6QhVi
S/1V5AulbWuOpRTj9yUcLdDUXpPtZitvoidj7QfeSJeP+6F2a8RDKAjrHzCf/Ikv
91KaJgdl73Fll8r87SECFQEF69cNwuLfuZccDLik/9h8v3qVZw0wiGPqO+WKaGSR
eqO1tRgws+qvi8BjUNJGHmI+jzZ4qnaeZii2nTiU1xv+Z78hr7VB0vDjnDvk1Rk=
-----END CERTIFICATE-----
@@ -0,0 +1,34 @@
[
{
"name": "non-important does not override important",
"styles": [
"color:red!important",
"color:blue"
],
"expected": "color:red!important;"
},
{
"name": "later important overrides non-important",
"styles": [
"color:red",
"color:blue!important"
],
"expected": "color:blue!important;"
},
{
"name": "later same-priority declaration wins",
"styles": [
"margin:1px",
"margin:2px"
],
"expected": "margin:2px;"
},
{
"name": "important applies per property",
"styles": [
"color:red!important;margin:1px",
"color:blue;margin:2px"
],
"expected": "color:red!important;margin:2px;"
}
]
@@ -0,0 +1,3 @@
@charset "UTF-8";
h1 { color: red; }
h2 { font-size: 14pt; }
@@ -0,0 +1 @@
h3 { color: green; }
@@ -0,0 +1,3 @@
/* base styles */
h1 { color: red; }
h2 { font-size: 14pt; }
@@ -0,0 +1,2 @@
/* overrides */
h1 { color: blue; }
@@ -0,0 +1,2 @@
@import "base.css";
h3 { margin: 0; }
@@ -0,0 +1,12 @@
a[href^="mailto:"]::after {
content: "mailto:user@example.com;subject=test";
}
div[data-url*="example.com?a=1&b=2"] {
background-image: url("https://example.com/a;b.png?x=1&y=2");
font-family: "Open Sans", "Noto Sans", sans-serif;
}
p.note {
background: linear-gradient(90deg, rgba(0,0,0,.1), rgba(255,255,255,.8));
}
@@ -0,0 +1,3 @@
.icon\:warning[data-kind="print-a"]:first-child { color: red; }
#hero\#title > a.link\+cta[href*="campaign=42"] { margin: 0; }
nav ul li:nth-child(2) > a[title="A > B"] { text-decoration: underline; }
@@ -0,0 +1,74 @@
[
{
"name": "dash-match prefix match",
"selector": " a[id|=promo]",
"node": 2,
"expected": true
},
{
"name": "dash-match exact match",
"selector": " a[id|=promo-link]",
"node": 2,
"expected": true
},
{
"name": "substring match in href",
"selector": " a[href*=\"b=2\"]",
"node": 2,
"expected": true
},
{
"name": "suffix match in href",
"selector": " a[href$=\"b=2\"]",
"node": 2,
"expected": true
},
{
"name": "prefix match in href",
"selector": " a[href^=\"https://ex.com\"]",
"node": 2,
"expected": true
},
{
"name": "includes operator with dashed attribute name",
"selector": " a[data-role~=cta]",
"node": 2,
"expected": true
},
{
"name": "quoted attribute value with space",
"selector": " a[title=\"Hello World\"]",
"node": 2,
"expected": false
},
{
"name": "lang pseudo-class with explicit tag",
"selector": " a:lang(en-US)",
"node": 2,
"expected": true
},
{
"name": "first-child pseudo true",
"selector": " a:first-child",
"node": 2,
"expected": true
},
{
"name": "last-child pseudo false",
"selector": " a:last-child",
"node": 2,
"expected": false
},
{
"name": "empty pseudo true on sibling span",
"selector": " span:empty",
"node": 3,
"expected": true
},
{
"name": "only-child pseudo false on sibling span",
"selector": " span:only-child",
"node": 3,
"expected": false
}
]
@@ -0,0 +1,34 @@
%PDF-1.4
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R >>
endobj
4 0 obj
<< /Length 46 >>\nstream\nBT /F1 12 Tf 20 100 Td (Encrypted Stub) Tj ET
endstream
endobj
5 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
endobj
6 0 obj
<< /Filter /Standard /V 2 /R 3 /Length 128 >>
endobj
xref
0 7
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000202 00000 n
0000000299 00000 n
0000000369 00000 n
trailer
<< /Size 7 /Root 1 0 R /Encrypt 6 0 R >>
startxref
430
%%EOF
@@ -0,0 +1,19 @@
<style>
body{font-family:helvetica;font-size:9.5pt;color:#1f2b34;}
.kpi{float:left;width:48%;margin-right:2%;padding:6pt;border:0.5pt solid #9db0c1;background:#f4f8fb;}
.kpi h3{margin:0 0 4pt 0;font-size:11pt;}
.clear{clear:both;height:0;line-height:0;}
.table{width:100%;border-collapse:collapse;margin-top:8pt;}
.table th,.table td{border:0.5pt solid #95a8ba;padding:3pt;}
.table th{background:#e4edf5;}
</style>
<div class="kpi"><h3>Uptime</h3><p style="margin:0;">99.92% this month</p></div>
<div class="kpi"><h3>Alerts</h3><p style="margin:0;">17 open, 4 critical</p></div>
<div class="clear"></div>
<table class="table">
<tr><th>Service</th><th>Latency P95</th><th>Error Rate</th><th>Status</th></tr>
<tr><td>Gateway</td><td>238ms</td><td>0.4%</td><td>Degraded</td></tr>
<tr><td>Billing</td><td>122ms</td><td>0.1%</td><td>Healthy</td></tr>
<tr><td>Notifications</td><td>305ms</td><td>0.8%</td><td>Warning</td></tr>
<tr><td>Search</td><td>188ms</td><td>0.2%</td><td>Healthy</td></tr>
</table>
@@ -0,0 +1,47 @@
{
"version": 1,
"failure_tags": [
"overflow",
"overlap",
"dropped-style",
"selector-miss"
],
"severity_levels": [
"critical",
"high",
"medium",
"low"
],
"pages": [
{
"id": "long_form_article",
"archetype": "long-form article",
"fixture": "long_form_article.html",
"failures": []
},
{
"id": "invoice_statement",
"archetype": "invoice and statement",
"fixture": "invoice_statement.html",
"failures": []
},
{
"id": "product_docs_page",
"archetype": "product/documentation page",
"fixture": "product_docs_page.html",
"failures": []
},
{
"id": "admin_report_dashboard",
"archetype": "admin/report dashboard with tables",
"fixture": "admin_report_dashboard.html",
"failures": []
},
{
"id": "form_heavy_page",
"archetype": "form-heavy page",
"fixture": "form_heavy_page.html",
"failures": []
}
]
}
@@ -0,0 +1,22 @@
<style>
body{font-family:helvetica;font-size:10pt;color:#20252b;}
fieldset{border:0.5pt solid #9ea7b3;margin:0 0 8pt 0;padding:6pt;}
legend{font-weight:bold;}
.row{margin-bottom:4pt;}
.label{display:inline-block;width:120pt;font-weight:bold;}
.input{display:inline-block;border:0.5pt solid #b9c1cb;padding:2pt 4pt;width:240pt;}
.help{color:#53606f;font-size:8.5pt;}
</style>
<h1 style="font-size:15pt;margin:0 0 8pt 0;">Service Enrollment</h1>
<fieldset>
<legend>Applicant</legend>
<div class="row"><span class="label">Full name</span><span class="input">Jane Example</span></div>
<div class="row"><span class="label">Email</span><span class="input">jane@example.test</span></div>
<div class="row"><span class="label">Company</span><span class="input">Northwind Ltd.</span></div>
</fieldset>
<fieldset>
<legend>Preferences</legend>
<div class="row"><span class="label">Plan</span><span class="input">Professional</span></div>
<div class="row"><span class="label">Billing cycle</span><span class="input">Quarterly</span></div>
<p class="help">Interactive focus, hover, and dynamic validation states are intentionally flattened for print output.</p>
</fieldset>
@@ -0,0 +1,19 @@
<style>
body{font-family:helvetica;font-size:10pt;color:#222;}
.header{border-bottom:1pt solid #444;margin-bottom:8pt;padding-bottom:4pt;}
.meta{float:right;text-align:right;}
.table{width:100%;border-collapse:collapse;}
.table th,.table td{border:0.5pt solid #999;padding:4pt;}
.total{font-weight:bold;background:#f0f0f0;}
</style>
<div class="header">
<div class="meta">Invoice #INV-2048<br/>Date: 2026-05-08</div>
<h1 style="margin:0;font-size:16pt;">Blue Finch Supply</h1>
<p style="margin:2pt 0 0 0;">Monthly service statement</p>
</div>
<table class="table">
<tr><th>Description</th><th>Qty</th><th>Unit</th><th>Amount</th></tr>
<tr><td>Monitoring subscription</td><td>1</td><td>49.00</td><td>49.00</td></tr>
<tr><td>Priority support</td><td>2</td><td>15.00</td><td>30.00</td></tr>
<tr class="total"><td colspan="3">Total</td><td>79.00</td></tr>
</table>
@@ -0,0 +1,20 @@
<style>
body{font-family:helvetica;font-size:11pt;line-height:1.4;color:#1a1a1a;}
main{max-width:520pt;}
h1{font-size:20pt;margin:0 0 8pt 0;}
.lead{margin:0 0 10pt 0;}
.note{background:#f4f7fb;border-left:3pt solid #2f5a8a;padding:6pt;margin:8pt 0;}
code{background:#efefef;padding:1pt 2pt;}
</style>
<main>
<h1>Designing Print-Friendly CSS</h1>
<p class="lead">This article fixture stresses heading, paragraph flow, and inline code wrapping under realistic text density.</p>
<p>When building print output, prefer stable box flow and avoid relying on dynamic viewport assumptions. Combine semantic markup with conservative declarations and clear section spacing.</p>
<p class="note">Known caveat: <code>very_very_very_very_very_very_very_long_tokens_without_breaks</code> can exceed narrow columns.</p>
<h2>Checklist</h2>
<ul>
<li>Use explicit font sizes for predictable line metrics.</li>
<li>Keep paragraph width bounded for readability.</li>
<li>Use simple list and heading structures.</li>
</ul>
</main>
@@ -0,0 +1,20 @@
<style>
body{font-family:helvetica;font-size:10pt;color:#1d2833;}
.hero{background:#eaf2fb;padding:10pt;border:0.5pt solid #b7c8dc;margin-bottom:8pt;}
.grid{display:block;}
.card{border:0.5pt solid #bfcbd8;padding:6pt;margin-bottom:6pt;}
.kv td{padding:2pt 4pt;border-bottom:0.5pt solid #d2d8e0;}
</style>
<section class="hero">
<h1 style="margin:0 0 4pt 0;font-size:16pt;">API Documentation Bundle</h1>
<p style="margin:0;">Product-style landing section with mixed prose, cards, and a small specification table.</p>
</section>
<div class="grid">
<div class="card"><strong>Quick Start:</strong> Install package, configure base options, and run your first export.</div>
<div class="card"><strong>Compatibility:</strong> Targets CSS 2.1 print-safe subset with documented partials.</div>
</div>
<table class="kv" style="width:100%;border-collapse:collapse;">
<tr><td>Package</td><td>tc-lib-pdf</td></tr>
<tr><td>Runtime</td><td>PHP 8.2+</td></tr>
<tr><td>Output</td><td>PDF 1.7</td></tr>
</table>
@@ -0,0 +1,3 @@
<div style="float:left;width:25mm;border:1px solid #000">FLOAT-L</div>
<div style="float:right;width:25mm;border:1px solid #000">FLOAT-R</div>
<div style="clear:both">CLEAR-BLOCK</div>
@@ -0,0 +1,3 @@
<div style="position:relative;left:2mm;top:1mm">REL-BOX</div>
<div style="position:absolute;left:5mm;top:8mm">ABS-BOX</div>
<div style="position:fixed;left:10mm;top:15mm">FIXED-BOX</div>
@@ -0,0 +1,12 @@
<table style="table-layout:fixed;width:80mm;border:1px solid #000" cellspacing="0" cellpadding="1">
<tr>
<td style="width:20mm;border:1px solid #000">FIXED-A</td>
<td style="width:60mm;border:1px solid #000">FIXED-B-LONG-CONTENT</td>
</tr>
</table>
<table style="table-layout:auto;width:80mm;border:1px solid #000" cellspacing="0" cellpadding="1">
<tr>
<td style="border:1px solid #000">AUTO-A</td>
<td style="border:1px solid #000">AUTO-B-LONG-CONTENT</td>
</tr>
</table>
@@ -0,0 +1,2 @@
% minimal hyphenation patterns fixture
\patterns{hy4phen test1ing a1bc}
@@ -0,0 +1,43 @@
%PDF-1.4
1 0 obj
<</Type /Catalog /Pages 2 0 R>>
endobj
2 0 obj
<</Type /Pages /Kids [3 0 R 6 0 R] /Count 2>>
endobj
3 0 obj
<</Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources<</Font<</F1 5 0 R>>>>>>
endobj
4 0 obj
<</Length 47>>
stream
BT /Helvetica 12 Tf 100 700 Td (Page One) Tj ET
endstream
endobj
5 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Helvetica>>
endobj
6 0 obj
<</Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 7 0 R /Resources<</Font<</F1 5 0 R>>>>>>
endobj
7 0 obj
<</Length 47>>
stream
BT /Helvetica 12 Tf 100 700 Td (Page Two) Tj ET
endstream
endobj
xref
0 8
0000000000 65535 f
0000000009 00000 n
0000000056 00000 n
0000000117 00000 n
0000000235 00000 n
0000000330 00000 n
0000000398 00000 n
0000000516 00000 n
trailer
<</Size 8 /Root 1 0 R>>
startxref
611
%%EOF
@@ -0,0 +1,30 @@
%PDF-1.4
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 500] /Rotate 90 /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>
endobj
4 0 obj
<< /Length 47 >>\nstream\nBT /F1 12 Tf 40 420 Td (Rotated Fixture) Tj ET
endstream
endobj
5 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
endobj
xref
0 6
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000252 00000 n
0000000350 00000 n
trailer
<< /Size 6 /Root 1 0 R >>
startxref
420
%%EOF
@@ -0,0 +1,22 @@
%PDF-1.4
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]/Contents 4 0 R/Resources<</Font<</F1 5 0 R>>>>>>endobj
4 0 obj<</Length 56>>
stream
BT /Helvetica 12 Tf 100 700 Td (Hello Import Test) Tj ET
endstream
endobj
5 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj
xref
0 6
0000000000 65535 f
0000000009 00000 n
0000000052 00000 n
0000000101 00000 n
0000000211 00000 n
0000000314 00000 n
trailer<</Size 6/Root 1 0 R>>
startxref
375
%%EOF
@@ -0,0 +1,35 @@
%PDF-1.4
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << /Font << /F1 5 0 R >> /ExtGState << /GS1 6 0 R >> >> /Contents 4 0 R >>
endobj
4 0 obj
<< /Length 93 >>\nstream\nq /GS1 gs 1 0 0 rg 50 600 200 120 re f Q
BT /F1 12 Tf 60 560 Td (Transparency Fixture) Tj ET
endstream
endobj
5 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
endobj
6 0 obj
<< /Type /ExtGState /ca 0.5 /CA 0.5 /BM /Multiply >>
endobj
xref
0 7
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000269 00000 n
0000000413 00000 n
0000000483 00000 n
trailer
<< /Size 7 /Root 1 0 R >>
startxref
551
%%EOF