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

1537 lines
58 KiB
PHP
Raw Normal View History

2026-08-20 11:41:17 +00:00
<?php
/**
* TcpdfTest.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;
/**
* Tcpdf Pdf class test
*
* @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
*/
class TcpdfTest extends TestUtil
{
/** @throws \Throwable */
protected function getTestObject(): \Com\Tecnick\Pdf\Tcpdf
{
return new \Com\Tecnick\Pdf\Tcpdf();
}
/**
* @return array{pid: int}
* @throws \Throwable
*/
private function initFontAndAddRawPage(\Com\Tecnick\Pdf\Tcpdf $obj): array
{
/** @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);
/** @var \Com\Tecnick\Pdf\Page\Page $page */
$page = $this->getObjectProperty($obj, 'page');
/** @var array<string, mixed> $rawPage */
$rawPage = $page->add([]);
if (!isset($rawPage['pid']) || !\is_int($rawPage['pid'])) {
$this->fail('Unexpected page id type from add([]).');
}
$pid = $rawPage['pid'];
return ['pid' => $pid];
}
/** @throws \Throwable */
public function testSetPDFFilenameAcceptsValidPdfName(): void
{
$obj = $this->getTestObject();
$obj->setPDFFilename('my_test_file.pdf');
$this->assertSame('my_test_file.pdf', $this->getObjectProperty($obj, 'pdffilename'));
$this->assertSame('my_test_file.pdf', $this->getObjectProperty($obj, 'encpdffilename'));
}
/** @throws \Throwable */
public function testSetPDFFilenameAcceptsUnicodeName(): void
{
$obj = $this->getTestObject();
$obj->setPDFFilename('Resume 日本語.pdf');
$this->assertSame('Resume 日本語.pdf', $this->getObjectProperty($obj, 'pdffilename'));
$this->assertSame('Resume%20%E6%97%A5%E6%9C%AC%E8%AA%9E.pdf', $this->getObjectProperty($obj, 'encpdffilename'));
}
/** @throws \Throwable */
public function testSetPDFFilenameNormalizesUnicodeToNfcWhenAvailable(): void
{
$obj = $this->getTestObject();
$obj->setPDFFilename("Cafe\u{0301}.pdf");
$expectedName = \class_exists('\\Normalizer') ? 'Café.pdf' : "Cafe\u{0301}.pdf";
$expectedEncoded = \rawurlencode($expectedName);
$this->assertSame($expectedName, $this->getObjectProperty($obj, 'pdffilename'));
$this->assertSame($expectedEncoded, $this->getObjectProperty($obj, 'encpdffilename'));
}
/** @throws \Throwable */
public function testSetPDFFilenameRejectsInvalidExtension(): void
{
$obj = $this->getTestObject();
$before = (string) $this->getObjectProperty($obj, 'pdffilename');
$obj->setPDFFilename('bad-name.txt');
$this->assertSame($before, $this->getObjectProperty($obj, 'pdffilename'));
}
/** @throws \Throwable */
public function testSetPDFFilenameRejectsControlCharacters(): void
{
$obj = $this->getTestObject();
$before = (string) $this->getObjectProperty($obj, 'pdffilename');
$obj->setPDFFilename("bad\tname.pdf");
$this->assertSame($before, $this->getObjectProperty($obj, 'pdffilename'));
}
/** @throws \Throwable */
public function testSetPDFFilenameRejectsMarkOnlyStem(): void
{
$obj = $this->getTestObject();
$before = (string) $this->getObjectProperty($obj, 'pdffilename');
$obj->setPDFFilename("\u{0301}.pdf");
$this->assertSame($before, $this->getObjectProperty($obj, 'pdffilename'));
}
/** @throws \Throwable */
public function testSetPDFFilenameRejectsDetachedMarkAfterSeparator(): void
{
$obj = $this->getTestObject();
$before = (string) $this->getObjectProperty($obj, 'pdffilename');
$obj->setPDFFilename("a \u{0301}.pdf");
$this->assertSame($before, $this->getObjectProperty($obj, 'pdffilename'));
}
/** @throws \Throwable */
public function testSetPDFFilenameRejectsNamesLongerThan255Bytes(): void
{
$obj = $this->getTestObject();
$before = (string) $this->getObjectProperty($obj, 'pdffilename');
$obj->setPDFFilename(\str_repeat('a', 252) . '.pdf');
$this->assertSame($before, $this->getObjectProperty($obj, 'pdffilename'));
}
/** @throws \Throwable */
public function testSetPDFFilenameAcceptsNamesUpTo255Bytes(): void
{
$obj = $this->getTestObject();
$name = \str_repeat('a', 251) . '.pdf';
$obj->setPDFFilename($name);
$this->assertSame($name, $this->getObjectProperty($obj, 'pdffilename'));
$this->assertSame($name, $this->getObjectProperty($obj, 'encpdffilename'));
}
/** @throws \Throwable */
public function testSetSpaceRegexpParsesPatternAndModifiers(): void
{
$obj = $this->getTestObject();
$obj->setSpaceRegexp('/abc/i');
/** @var array{r: string, p: string, m: string} $regexp */
$regexp = $this->getObjectProperty($obj, 'spaceregexp');
$this->assertSame('/abc/i', $regexp['r']);
$this->assertSame('abc', $regexp['p']);
$this->assertSame('i', $regexp['m']);
}
/** @throws \Throwable */
public function testConstructorAlignsFileIdWithInjectedEncryptionObject(): void
{
$fileid = \md5('tcpdf-encryption-fileid');
$enc = new \Com\Tecnick\Pdf\Encrypt\Encrypt(true, $fileid, 2, ['modify', 'copy'], 'demo-user', 'demo-owner');
$obj = new \Com\Tecnick\Pdf\Tcpdf('mm', true, false, true, '', $enc);
$this->assertSame($fileid, $this->getObjectProperty($obj, 'fileid'));
}
/** @throws \Throwable */
public function testConstructorPassesFileOptionsToSharedFileHelper(): void
{
$defaultCurlOpts = [
CURLOPT_TIMEOUT => 12,
CURLOPT_USERAGENT => 'tc-lib-pdf-test',
];
$fixedCurlOpts = [
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_SSL_VERIFYPEER => true,
];
$allowedHosts = ['localhost', 'example.test'];
$obj = new \Com\Tecnick\Pdf\Tcpdf('mm', true, false, true, '', null, [
'defaultCurlOpts' => $defaultCurlOpts,
'fixedCurlOpts' => $fixedCurlOpts,
'allowedHosts' => $allowedHosts,
]);
/** @var \Com\Tecnick\File\File $file */
$file = $this->getObjectProperty($obj, 'file');
/** @var \Com\Tecnick\Pdf\Image\Import $image */
$image = $this->getObjectProperty($obj, 'image');
/** @var \Com\Tecnick\File\File $imageFile */
$imageFile = $this->getObjectProperty($image, 'fileHelper');
$this->assertSame($allowedHosts, $this->getObjectProperty($file, 'allowedHosts'));
$this->assertSame($defaultCurlOpts, $this->getObjectProperty($file, 'defaultCurlOpts'));
$this->assertSame($fixedCurlOpts, $this->getObjectProperty($file, 'fixedCurlOpts'));
$this->assertSame($allowedHosts, $this->getObjectProperty($imageFile, 'allowedHosts'));
$this->assertSame($defaultCurlOpts, $this->getObjectProperty($imageFile, 'defaultCurlOpts'));
$this->assertSame($fixedCurlOpts, $this->getObjectProperty($imageFile, 'fixedCurlOpts'));
/** @var array<string> $allowedPaths */
$allowedPaths = $this->getObjectProperty($file, 'allowedPaths');
$this->assertContains((string) \realpath(__DIR__ . '/..'), $allowedPaths);
}
/** @throws \Throwable */
public function testConstructorUsesCustomAllowedPathsWithoutMergingDefaults(): void
{
$customDir = (string) \realpath(__DIR__ . '/fixtures');
$obj = new \Com\Tecnick\Pdf\Tcpdf('mm', true, false, true, '', null, [
'allowedPaths' => [$customDir],
]);
/** @var \Com\Tecnick\File\File $file */
$file = $this->getObjectProperty($obj, 'file');
/** @var array<string> $allowedPaths */
$allowedPaths = $this->getObjectProperty($file, 'allowedPaths');
$this->assertNotContains((string) \realpath(__DIR__ . '/..'), $allowedPaths);
$this->assertContains($customDir, $allowedPaths);
}
/** @throws \Throwable */
public function testConstructorUsesMarkupAllowedPathsFallbackAndOverride(): void
{
$customDir = (string) \realpath(__DIR__ . '/fixtures');
$markupDir = (string) \realpath(__DIR__ . '/..');
$fallback = new \Com\Tecnick\Pdf\Tcpdf('mm', true, false, true, '', null, [
'allowedPaths' => [$customDir],
]);
/** @var \Com\Tecnick\File\File $fallbackMarkupFile */
$fallbackMarkupFile = $this->getObjectProperty($fallback, 'markupFile');
/** @var array<string> $fallbackMarkupPaths */
$fallbackMarkupPaths = $this->getObjectProperty($fallbackMarkupFile, 'allowedPaths');
$this->assertSame([$customDir], $fallbackMarkupPaths);
$override = new \Com\Tecnick\Pdf\Tcpdf('mm', true, false, true, '', null, [
'allowedPaths' => [$customDir],
'markupAllowedPaths' => [$markupDir],
]);
/** @var \Com\Tecnick\File\File $overrideMarkupFile */
$overrideMarkupFile = $this->getObjectProperty($override, 'markupFile');
/** @var array<string> $overrideMarkupPaths */
$overrideMarkupPaths = $this->getObjectProperty($overrideMarkupFile, 'allowedPaths');
$this->assertSame([$markupDir], $overrideMarkupPaths);
}
/** @throws \Throwable */
#[DataProvider('displayModeFixtureProvider')]
public function testSetDisplayModeStoresExpectedZoom(string|int $inputZoom, string|int $expectedZoom): void
{
$obj = $this->getTestObject();
$ret = $obj->setDisplayMode($inputZoom);
/** @var array{zoom: string|int|float} $display */
$display = $this->getObjectProperty($obj, 'display');
$this->assertSame($obj, $ret);
$this->assertSame($expectedZoom, $display['zoom']);
}
/** @throws \Throwable */
public function testSetUserRightsMergesValues(): void
{
$obj = $this->getTestObject();
$obj->setUserRights([
'annots' => '/All',
'document' => '/FullSave',
'ef' => '/All',
'enabled' => true,
'form' => '/All',
'formex' => '/All',
'signature' => '/All',
]);
/** @var array{enabled: bool, document: string} $rights */
$rights = $this->getObjectProperty($obj, 'userrights');
$this->assertTrue($rights['enabled']);
$this->assertSame('/FullSave', $rights['document']);
}
/** @return array<string, array{0: string|int, 1: string|int}> */
public static function displayModeFixtureProvider(): array
{
return [
'invalid_token_uses_default' => ['invalid-zoom-token', 'default'],
'numeric_zoom_is_preserved' => [125, 125],
];
}
/** @throws \Throwable */
public function testSetSignTimeStampThrowsWhenEnabledWithoutHost(): void
{
$obj = $this->getTestObject();
$this->bcExpectException(\Com\Tecnick\Pdf\Exception::class);
$obj->setSignTimeStamp([
'enabled' => true,
'host' => '',
'username' => '',
'password' => '',
'cert' => '',
'hash_algorithm' => 'sha256',
'policy_oid' => '',
'nonce_enabled' => true,
'timeout' => 5,
'verify_peer' => true,
]);
}
/** @throws \Throwable */
public function testSetSignTimeStampStoresDataWhenHostProvided(): void
{
$obj = $this->getTestObject();
$obj->setSignTimeStamp([
'enabled' => true,
'host' => 'https://tsa.example.test',
'username' => '',
'password' => '',
'cert' => '',
'hash_algorithm' => 'sha512',
'policy_oid' => '1.2.3.4',
'nonce_enabled' => false,
'timeout' => 9,
'verify_peer' => false,
]);
/** @var array{enabled: bool, host: string, hash_algorithm: string, timeout: int, verify_peer: bool} $timeStamp */
$timeStamp = $this->getObjectProperty($obj, 'sigtimestamp');
$this->assertTrue($timeStamp['enabled']);
$this->assertSame('https://tsa.example.test', $timeStamp['host']);
$this->assertSame('sha512', $timeStamp['hash_algorithm']);
$this->assertSame(9, $timeStamp['timeout']);
$this->assertFalse($timeStamp['verify_peer']);
}
/** @throws \Throwable */
public function testSetSignTimeStampThrowsOnInvalidHashAlgorithm(): void
{
$obj = $this->getTestObject();
$this->bcExpectException(\Com\Tecnick\Pdf\Exception::class);
$obj->setSignTimeStamp([
'enabled' => true,
'host' => 'https://tsa.example.test',
'username' => '',
'password' => '',
'cert' => '',
'hash_algorithm' => 'sha1',
'policy_oid' => '',
'nonce_enabled' => true,
'timeout' => 5,
'verify_peer' => true,
]);
}
/** @throws \Throwable */
public function testSetSignatureStoresLtvOptions(): void
{
$obj = $this->getTestObject();
$obj->setSignature([
'appearance' => ['empty' => [], 'name' => '', 'page' => 0, 'rect' => ''],
'approval' => '',
'cert_type' => 0,
'extracerts' => null,
'info' => ['ContactInfo' => '', 'Location' => '', 'Name' => '', 'Reason' => ''],
'password' => '',
'privkey' => '',
'signcert' => 'dummy-signcert',
'ltv' => [
'enabled' => true,
'embed_ocsp' => false,
'embed_crl' => false,
'embed_certs' => true,
'include_dss' => true,
'include_vri' => true,
],
]);
/** @var array{ltv: array{enabled: bool, embed_ocsp: bool, embed_crl: bool}} $signature */
$signature = $this->getObjectProperty($obj, 'signature');
$this->assertTrue($signature['ltv']['enabled']);
$this->assertFalse($signature['ltv']['embed_ocsp']);
$this->assertFalse($signature['ltv']['embed_crl']);
}
/** @throws \Throwable */
public function testSetSignatureThrowsOnMissingSigningCertificate(): void
{
$obj = $this->getTestObject();
$this->bcExpectException(\Com\Tecnick\Pdf\Exception::class);
$obj->setSignature([
'appearance' => ['empty' => [], 'name' => '', 'page' => 0, 'rect' => ''],
'approval' => '',
'cert_type' => 0,
'extracerts' => null,
'info' => ['ContactInfo' => '', 'Location' => '', 'Name' => '', 'Reason' => ''],
'password' => '',
'privkey' => '',
'signcert' => '',
]);
}
/** @throws \Throwable */
public function testGetBarcodeReturnsDrawingCommands(): void
{
$obj = $this->getTestObject();
$out = $obj->getBarcode('C39', 'ABC123');
$this->assertNotSame('', $out);
$this->bcAssertMatchesRegularExpression('/\bre\b/', $out);
}
/** @throws \Throwable */
public function testGetBarcodeThrowsOnInvalidType(): void
{
$obj = $this->getTestObject();
$this->bcExpectException(\Com\Tecnick\Barcode\Exception::class);
$obj->getBarcode('INVALID_TYPE', 'ABC123');
}
/** @throws \Throwable */
public function testSetSignatureAppearanceStoresPageNameAndRect(): void
{
$obj = $this->getTestObject();
$page = $this->initFontAndAddRawPage($obj);
$obj->setSignatureAppearance(10, 20, 30, 15, $page['pid'], 'MainSig');
/** @var array{appearance: array{page: int, name: string, rect: string}} $signature */
$signature = $this->getObjectProperty($obj, 'signature');
$appearance = $signature['appearance'];
$this->assertSame($page['pid'], $appearance['page']);
$this->assertSame('MainSig', $appearance['name']);
$this->bcAssertMatchesRegularExpression('/^[-0-9.]+\s+[-0-9.]+\s+[-0-9.]+\s+[-0-9.]+$/', $appearance['rect']);
}
/** @throws \Throwable */
public function testAddEmptySignatureAppearanceAddsEntry(): void
{
$obj = $this->getTestObject();
$page = $this->initFontAndAddRawPage($obj);
$obj->addEmptySignatureAppearance(5, 6, 20, 10, $page['pid'], 'EmptySig');
/** @var array{appearance: array{empty: array<int, array{name: string, page: int, objid: int}>}} $signature */
$signature = $this->getObjectProperty($obj, 'signature');
$this->assertNotEmpty($signature['appearance']['empty']);
assert(isset($signature['appearance']['empty'][0]), "\$signature['appearance']['empty'][0] must be set");
$entry = $signature['appearance']['empty'][0];
$this->assertSame('EmptySig', $entry['name']);
$this->assertSame($page['pid'], $entry['page']);
$this->assertIsInt($entry['objid']);
}
/** @throws \Throwable */
public function testAddTOCHandlesEmptyOutlineList(): void
{
$obj = $this->getTestObject();
$this->initFontAndAddRawPage($obj);
$obj->addTOC();
/** @var array<int, array<string, mixed>> $outlines */
$outlines = $this->getObjectProperty($obj, 'outlines');
$this->assertCount(0, $outlines);
}
/** @throws \Throwable */
public function testAddTOCProcessesBookmarks(): void
{
$obj = $this->getTestObject();
$page = $this->initFontAndAddRawPage($obj);
$obj->setBookmark('Section 1', '', 0, $page['pid']);
$obj->addTOC($page['pid']);
/** @var array<int, array{t: string, p: int}> $outlines */
$outlines = $this->getObjectProperty($obj, 'outlines');
$this->assertCount(1, $outlines);
assert(isset($outlines[0]), "\$outlines[0] must be set");
$this->assertSame('Section 1', $outlines[0]['t']);
$this->assertSame($page['pid'], $outlines[0]['p']);
}
/** @throws \Throwable */
public function testNewLayerReturnsBeginLayerOperatorAndStoresLayer(): void
{
$obj = $this->getTestObject();
$out = $obj->newLayer('Layer 1', ['view' => true], true, true, true);
/** @var array<int, array{name: string, intent: string}> $layers */
$layers = $this->getObjectProperty($obj, 'pdflayer');
$this->assertSame(" /OC /LYR001 BDC\n", $out);
$this->assertCount(1, $layers);
assert(isset($layers[0]), "\$layers[0] must be set");
$this->assertSame('Layer1', $layers[0]['name']);
$this->assertSame('/View', $layers[0]['intent']);
}
/** @throws \Throwable */
public function testCloseLayerReturnsEmcOperator(): void
{
$obj = $this->getTestObject();
$this->assertSame("EMC\n", $obj->closeLayer());
}
/** @throws \Throwable */
public function testNewLayerFallsBackToAutoNameAndAddsDesignIntent(): void
{
$obj = $this->getTestObject();
$out = $obj->newLayer('***', ['design' => true], true, true, true);
/** @var array<int, array{name: string, intent: string}> $layers */
$layers = $this->getObjectProperty($obj, 'pdflayer');
$this->assertSame(" /OC /LYR001 BDC\n", $out);
$this->assertCount(1, $layers);
assert(isset($layers[0]), "\$layers[0] must be set");
$this->assertSame('LYR001', $layers[0]['name']);
$this->assertSame('/Design', $layers[0]['intent']);
}
/** @throws \Throwable */
public function testConstructorPdfaModesSetExpectedFlags(): void
{
$pdfa1u = new \Com\Tecnick\Pdf\Tcpdf('mm', true, false, true, 'pdfa1u');
$this->assertSame(1, $this->getObjectProperty($pdfa1u, 'pdfa'));
$this->assertSame('B', $this->getObjectProperty($pdfa1u, 'pdfaConformance'));
$pdfa2u = new \Com\Tecnick\Pdf\Tcpdf('mm', true, false, true, 'pdfa2u');
$this->assertSame(2, $this->getObjectProperty($pdfa2u, 'pdfa'));
$this->assertSame('U', $this->getObjectProperty($pdfa2u, 'pdfaConformance'));
foreach (['pdfx', 'pdfx1a', 'pdfx3', 'pdfx4', 'pdfx5'] as $mode) {
$pdfx = new \Com\Tecnick\Pdf\Tcpdf('mm', true, false, true, $mode);
$this->assertTrue($this->getObjectProperty($pdfx, 'pdfx'));
$this->assertSame($mode, $this->getObjectProperty($pdfx, 'pdfxMode'));
$this->assertSame('', $this->getObjectProperty($pdfx, 'pdfuaMode'));
}
foreach (['pdfua', 'pdfua1', 'pdfua2'] as $mode) {
$pdfua = new \Com\Tecnick\Pdf\Tcpdf('mm', true, false, true, $mode);
$this->assertSame($mode, $this->getObjectProperty($pdfua, 'pdfuaMode'));
$this->assertFalse($this->getObjectProperty($pdfua, 'pdfx'));
$this->assertSame('', $this->getObjectProperty($pdfua, 'pdfxMode'));
}
$unknown = new \Com\Tecnick\Pdf\Tcpdf('mm', true, false, true, 'pdfunknown');
$this->assertFalse($this->getObjectProperty($unknown, 'pdfx'));
$this->assertSame('', $this->getObjectProperty($unknown, 'pdfxMode'));
$this->assertSame('', $this->getObjectProperty($unknown, 'pdfuaMode'));
$this->assertSame(0, $this->getObjectProperty($unknown, 'pdfa'));
}
/** @throws \Throwable */
public function testConstructorWithUnicodeDisabledSetsAsciiWhitespacePattern(): void
{
$obj = new \Com\Tecnick\Pdf\Tcpdf('mm', false, false, true);
/** @var array{r: string} $regexp */
$regexp = $this->getObjectProperty($obj, 'spaceregexp');
$this->assertSame('/[^\S\xa0]/', $regexp['r']);
}
/** @throws \Throwable */
public function testPdfxRestrictiveModesForceDeviceCmykProcessColors(): void
{
foreach (['pdfx', 'pdfx1a', 'pdfx3'] as $mode) {
$obj = new \Com\Tecnick\Pdf\Tcpdf('mm', true, false, true, $mode);
$fill = $obj->color->getPdfColor('#336699');
$stroke = $obj->color->getPdfColor('#336699', true);
$this->assertStringContainsString(" k\n", $fill);
$this->assertStringNotContainsString(" rg\n", $fill);
$this->assertStringContainsString(" K\n", $stroke);
$this->assertStringNotContainsString(" RG\n", $stroke);
// Spot colors remain spot operators in restrictive PDF/X modes.
$spot = $obj->color->getPdfColor('cyan');
$this->assertStringContainsString('scn', $spot);
$this->assertStringContainsString('/CS', $spot);
}
}
/** @throws \Throwable */
public function testDefaultAndPdfx4KeepRgbProcessColors(): void
{
$default = new \Com\Tecnick\Pdf\Tcpdf();
$pdfx4 = new \Com\Tecnick\Pdf\Tcpdf('mm', true, false, true, 'pdfx4');
$this->assertStringContainsString(" rg\n", $default->color->getPdfColor('#336699'));
$this->assertStringContainsString(" rg\n", $pdfx4->color->getPdfColor('#336699'));
}
/** @throws \Throwable */
public function testCssColorModelsEmitExpectedPdfOperators(): void
{
$obj = new \Com\Tecnick\Pdf\Tcpdf();
$cases = [
'named navy' => ['navy', " rg\n"],
'gray g()' => ['g(50%)', " g\n"],
'hex short' => ['#0f0', " rg\n"],
'hex long' => ['#1e90ff', " rg\n"],
'rgb' => ['rgb(255, 99, 71)', " rg\n"],
'rgba' => ['rgba(30, 144, 255, 0.25)', " rg\n"],
'hsl' => ['hsl(120, 100%, 25%)', " rg\n"],
'hsla' => ['hsla(300, 100%, 50%, 0.20)', " rg\n"],
'lab' => ['lab(54.29% -19.04 38.25)', " scn\n"],
'lab alpha' => ['lab(54.29% -19.04 38.25 / 0.35)', " scn\n"],
'cmyk' => ['cmyk(67, 33, 0, 25)', " k\n"],
'spot function' => ['spot(cyan, 35%)', " scn\n"],
];
foreach ($cases as $label => [$color, $operator]) {
$pdfColor = $obj->color->getPdfColor($color);
$this->assertTrue($pdfColor !== '', 'Expected non-empty PDF color output for case: ' . $label);
$this->assertStringContainsString($operator, $pdfColor, 'Unexpected PDF operator for case: ' . $label);
if ($label === 'lab' || $label === 'lab alpha' || $label === 'spot function') {
$this->assertStringContainsString('/CS', $pdfColor);
}
}
}
/** @throws \Throwable */
public function testSetSignatureSetsDefaultPrivkeyAndSignFlag(): void
{
$obj = $this->getTestObject();
$page = $this->initFontAndAddRawPage($obj);
$obj->setSignature([
'appearance' => [
'empty' => [
[
'objid' => 321,
'name' => 'EmptySig',
'page' => $page['pid'],
'rect' => '0 0 1 1',
],
],
'name' => 'MainSig',
'page' => $page['pid'],
'rect' => '0 0 10 5',
],
'approval' => '',
'cert_type' => 2,
'extracerts' => '',
'info' => ['ContactInfo' => '', 'Location' => '', 'Name' => '', 'Reason' => ''],
'password' => '',
'privkey' => '',
'signcert' => 'dummy-signcert',
]);
/** @var array{privkey: string, signcert: string} $signature */
$signature = $this->getObjectProperty($obj, 'signature');
$this->assertSame('dummy-signcert', $signature['privkey']);
$this->assertTrue($this->getObjectProperty($obj, 'sign'));
}
/** @throws \Throwable */
public function testSetSignatureForExternalSigningUsesPlaceholderCertificate(): void
{
$obj = $this->getTestObject();
$page = $this->initFontAndAddRawPage($obj);
$obj->setSignatureForExternalSigning([
'appearance' => [
'empty' => [],
'name' => 'MainSig',
'page' => $page['pid'],
'rect' => '0 0 10 5',
],
'approval' => '',
'cert_type' => 2,
'extracerts' => null,
'info' => ['ContactInfo' => '', 'Location' => '', 'Name' => '', 'Reason' => ''],
'password' => '',
'privkey' => '',
'signcert' => '',
]);
/** @var array{signcert: string, privkey: string} $signature */
$signature = $this->getObjectProperty($obj, 'signature');
$this->assertSame('__external_signing__', $signature['signcert']);
$this->assertSame('__external_signing__', $signature['privkey']);
$this->assertTrue($this->getObjectProperty($obj, 'sign'));
}
/** @throws \Throwable */
public function testGetExternalSignaturePreparationReturnsHashAndPreparedPdf(): void
{
$obj = $this->getTestObject();
$page = $this->initFontAndAddRawPage($obj);
$obj->setSignatureForExternalSigning([
'appearance' => [
'empty' => [],
'name' => 'MainSig',
'page' => $page['pid'],
'rect' => '0 0 10 5',
],
'approval' => '',
'cert_type' => 2,
'extracerts' => null,
'info' => ['ContactInfo' => '', 'Location' => '', 'Name' => '', 'Reason' => ''],
'password' => '',
'privkey' => '',
'signcert' => '',
]);
$prepared = $obj->getExternalSignaturePreparation('sha256');
$this->assertSame('sha256', $prepared['algorithm']);
$this->assertCount(4, $prepared['byte_range']);
$this->assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $prepared['hash_hex']);
$this->assertNotSame('', $prepared['prepared_pdf']);
$this->assertStringContainsString('/ByteRange[0 ', $prepared['prepared_pdf']);
$this->assertStringNotContainsString('**********', $prepared['prepared_pdf']);
}
/** @throws \Throwable */
public function testApplyExternalSignatureInjectsProvidedCmsData(): void
{
$obj = $this->getTestObject();
$page = $this->initFontAndAddRawPage($obj);
$obj->setSignatureForExternalSigning([
'appearance' => [
'empty' => [],
'name' => 'MainSig',
'page' => $page['pid'],
'rect' => '0 0 10 5',
],
'approval' => '',
'cert_type' => 2,
'extracerts' => null,
'info' => ['ContactInfo' => '', 'Location' => '', 'Name' => '', 'Reason' => ''],
'password' => '',
'privkey' => '',
'signcert' => '',
]);
$prepared = $obj->getExternalSignaturePreparation('sha256');
$signedPdf = $obj->applyExternalSignature($prepared['prepared_pdf'], $prepared['byte_range'], 'ABC', 'binary');
$this->assertStringContainsString('/Contents<414243', $signedPdf);
$this->assertMatchesRegularExpression('#/ByteRange\[0 \d+ \d+ \d+\]#', $signedPdf);
}
/** @throws \Throwable */
public function testSetSignAnnotRefsReturnsWhenNoEmptySignaturesAreDefined(): void
{
$obj = new TestableTcpdf();
$page = $this->initFontAndAddRawPage($obj);
$this->setObjectProperty($obj, 'objid', ['signature' => 123]);
$this->setObjectProperty($obj, 'signature', [
'appearance' => [
'empty' => [],
'name' => 'MainSig',
'page' => $page['pid'],
'rect' => '0 0 10 10',
],
'approval' => false,
'cert_type' => 0,
'extracerts' => '',
'info' => ['ContactInfo' => '', 'Location' => '', 'Name' => '', 'Reason' => ''],
'password' => '',
'privkey' => '',
'signcert' => '',
]);
$obj->exposeSetSignAnnotRefs();
/** @var array{appearance: array{empty: array<int, mixed>, page: int}} $signature */
$signature = $this->getObjectProperty($obj, 'signature');
$this->assertSame([], $signature['appearance']['empty']);
$this->assertSame($page['pid'], $signature['appearance']['page']);
/** @var array{annotrefs: array<int>} $pageData */
$pageData = $obj->page->getPage($page['pid']);
$this->assertContains(123, $pageData['annotrefs']);
}
/** @throws \Throwable */
public function testSetSignatureAppearanceAddsMainSignatureAnnotationReference(): void
{
$obj = new TestableTcpdf();
$this->initFontAndAddRawPage($obj);
$page = $this->initFontAndAddRawPage($obj);
$this->setObjectProperty($obj, 'objid', ['signature' => 321]);
$obj->setSignatureAppearance(1, 2, 3, 4, $page['pid'], 'MainSig');
/** @var array{appearance: array{page: int, name: string}} $signature */
$signature = $this->getObjectProperty($obj, 'signature');
$this->assertSame($page['pid'], $signature['appearance']['page']);
$this->assertSame('MainSig', $signature['appearance']['name']);
}
/** @throws \Throwable */
public function testGetSignatureObjectIDReturnsReservedObjectId(): void
{
$obj = $this->getTestObject();
$page = $this->initFontAndAddRawPage($obj);
$certPath = __DIR__ . '/../examples/data/cert/tcpdf.crt';
$certPem = (string) \file_get_contents($certPath);
$obj->setSignature([
'appearance' => [
'empty' => [],
'name' => 'MainSig',
'page' => $page['pid'],
'rect' => '0 0 10 5',
],
'approval' => '',
'cert_type' => 2,
'extracerts' => null,
'info' => ['ContactInfo' => '', 'Location' => '', 'Name' => '', 'Reason' => ''],
'password' => '',
'privkey' => $certPem,
'signcert' => $certPem,
]);
$this->assertGreaterThan(0, $obj->getSignatureObjectID());
}
/** @throws \Throwable */
public function testSetSignatureAppearanceStreamStoresModeAndState(): void
{
$obj = $this->getTestObject();
$obj->setSignatureAppearanceStream('q 0 g Q', 'N', 'On');
/** @var array{appearance: array{ap: array<string, array<string, string>>, as: string}} $signature */
$signature = $this->getObjectProperty($obj, 'signature');
$appearance = $signature['appearance'];
$ap = $appearance['ap'];
$normal = $ap['n'] ?? [];
$this->assertSame('q 0 g Q', $normal['On'] ?? null);
$this->assertSame('On', $signature['appearance']['as']);
}
/** @throws \Throwable */
public function testSetSignatureAppearanceXObjectStoresXObjectId(): void
{
$obj = $this->getTestObject();
$obj->setSignatureAppearanceXObject('IMP1');
/** @var array{appearance: array{xobj: string}} $signature */
$signature = $this->getObjectProperty($obj, 'signature');
$this->assertSame('IMP1', $signature['appearance']['xobj']);
}
/** @throws \Throwable */
public function testEnableSignatureApprovalTogglesFlag(): void
{
$obj = new TestableTcpdf();
$ret = $obj->exposeEnableSignatureApproval(true);
$this->assertSame($obj, $ret);
$this->assertTrue($this->getObjectProperty($obj, 'sigapp'));
$obj->exposeEnableSignatureApproval(false);
$this->assertFalse($this->getObjectProperty($obj, 'sigapp'));
}
/** @throws \Throwable */
public function testAddTOCSupportsRtlPositioningAndBookmarkColor(): void
{
$obj = $this->getTestObject();
$page = $this->initFontAndAddRawPage($obj);
$obj->setBookmark('RTL entry', '', 0, $page['pid'], 0, 0, '', 'red');
$obj->addTOC($page['pid'], 0, 0, -1, true);
/** @var array<int, array{t: string}> $outlines */
$outlines = $this->getObjectProperty($obj, 'outlines');
$this->assertCount(1, $outlines);
assert(isset($outlines[0]), "\$outlines[0] must be set");
$this->assertSame('RTL entry', $outlines[0]['t']);
}
/** @throws \Throwable */
public function testAddTOCHandlesRegionOverflowAndPageTransitions(): void
{
$obj = $this->getTestObject();
$page = $this->initFontAndAddRawPage($obj);
$obj->setBookmark(\str_repeat('Long TOC line ', 250), '', 0, $page['pid']);
$obj->addTOC($page['pid'], 0, 10000, 20, false);
/** @var array<int, array{t: string}> $outlines */
$outlines = $this->getObjectProperty($obj, 'outlines');
$this->assertCount(1, $outlines);
assert(isset($outlines[0]), "\$outlines[0] must be set");
$this->assertStringStartsWith('Long TOC line', $outlines[0]['t']);
}
/** @throws \Throwable */
public function testLanguageSettersUpdateLanguageMetadata(): void
{
$obj = $this->getTestObject();
$obj->setLanguageArray(['a_meta_language' => 'it-IT', 'custom' => 'x']);
/** @var array<string, string> $lang */
$lang = $this->getObjectProperty($obj, 'lang');
$this->assertSame('it-IT', $lang['a_meta_language'] ?? null);
$this->assertSame('x', $lang['custom'] ?? null);
$obj->setLanguage('en-US');
/** @var array<string, string> $updated */
$updated = $this->getObjectProperty($obj, 'lang');
$this->assertSame('en-US', $updated['a_meta_language'] ?? null);
}
/** @throws \Throwable */
public function testSetSignatureAddsDefaultLtvOptionsWhenMissing(): void
{
$obj = $this->getTestObject();
/** @var array<string, mixed> $signatureState */
$signatureState = $this->getObjectProperty($obj, 'signature');
unset($signatureState['ltv']);
$this->setObjectProperty($obj, 'signature', $signatureState);
$obj->setSignature([
'appearance' => ['empty' => [], 'name' => '', 'page' => 0, 'rect' => ''],
'approval' => '',
'cert_type' => 0,
'extracerts' => null,
'info' => ['ContactInfo' => '', 'Location' => '', 'Name' => '', 'Reason' => ''],
'password' => '',
'privkey' => '',
'signcert' => 'dummy-signcert',
]);
/** @var array{ltv: array{enabled: bool, embed_ocsp: bool, embed_crl: bool, embed_certs: bool, include_dss: bool, include_vri: bool}} $signature */
$signature = $this->getObjectProperty($obj, 'signature');
$this->assertFalse($signature['ltv']['enabled']);
$this->assertTrue($signature['ltv']['embed_ocsp']);
$this->assertTrue($signature['ltv']['embed_crl']);
$this->assertTrue($signature['ltv']['embed_certs']);
$this->assertTrue($signature['ltv']['include_dss']);
$this->assertTrue($signature['ltv']['include_vri']);
}
/** @throws \Throwable */
public function testSetSignatureThrowsWhenLtvIsNotArray(): void
{
$obj = $this->getTestObject();
$this->bcExpectException(\Com\Tecnick\Pdf\Exception::class);
$data = [
'appearance' => ['empty' => [], 'name' => '', 'page' => 0, 'rect' => ''],
'approval' => '',
'cert_type' => 0,
'extracerts' => null,
'info' => ['ContactInfo' => '', 'Location' => '', 'Name' => '', 'Reason' => ''],
'password' => '',
'privkey' => '',
'signcert' => 'dummy-signcert',
'ltv' => 'invalid',
];
$setSignatureObj = new \ReflectionMethod($obj, 'setSignature');
$setSignatureObj->invoke($obj, $data);
}
/** @throws \Throwable */
public function testSetSignatureThrowsWhenLtvKeyIsInvalidType(): void
{
$obj = $this->getTestObject();
$this->bcExpectException(\Com\Tecnick\Pdf\Exception::class);
$data = [
'appearance' => ['empty' => [], 'name' => '', 'page' => 0, 'rect' => ''],
'approval' => '',
'cert_type' => 0,
'extracerts' => null,
'info' => ['ContactInfo' => '', 'Location' => '', 'Name' => '', 'Reason' => ''],
'password' => '',
'privkey' => '',
'signcert' => 'dummy-signcert',
'ltv' => [
'enabled' => true,
'embed_ocsp' => true,
'embed_crl' => true,
'embed_certs' => true,
'include_dss' => true,
'include_vri' => 'yes',
],
];
$setSignatureObj = new \ReflectionMethod($obj, 'setSignature');
$setSignatureObj->invoke($obj, $data);
}
/** @throws \Throwable */
public function testSetSignTimeStampThrowsOnInvalidPolicyOid(): void
{
$obj = $this->getTestObject();
$this->bcExpectException(\Com\Tecnick\Pdf\Exception::class);
$obj->setSignTimeStamp([
'enabled' => true,
'host' => 'https://tsa.example.test',
'username' => '',
'password' => '',
'cert' => '',
'hash_algorithm' => 'sha256',
'policy_oid' => 'invalid-oid',
'nonce_enabled' => true,
'timeout' => 5,
'verify_peer' => true,
]);
}
/** @throws \Throwable */
public function testSetSignTimeStampThrowsOnInvalidNonceType(): void
{
$obj = $this->getTestObject();
$this->bcExpectException(\Com\Tecnick\Pdf\Exception::class);
$data = [
'enabled' => true,
'host' => 'https://tsa.example.test',
'username' => '',
'password' => '',
'cert' => '',
'hash_algorithm' => 'sha256',
'policy_oid' => '',
'nonce_enabled' => 1,
'timeout' => 5,
'verify_peer' => true,
];
$setSignTimeStampObj = new \ReflectionMethod($obj, 'setSignTimeStamp');
$setSignTimeStampObj->invoke($obj, $data);
}
/** @throws \Throwable */
public function testSetSignTimeStampThrowsOnInvalidTimeout(): void
{
$obj = $this->getTestObject();
$this->bcExpectException(\Com\Tecnick\Pdf\Exception::class);
$obj->setSignTimeStamp([
'enabled' => true,
'host' => 'https://tsa.example.test',
'username' => '',
'password' => '',
'cert' => '',
'hash_algorithm' => 'sha256',
'policy_oid' => '',
'nonce_enabled' => true,
'timeout' => 0,
'verify_peer' => true,
]);
}
/** @throws \Throwable */
public function testSetSignTimeStampThrowsOnInvalidVerifyPeerType(): void
{
$obj = $this->getTestObject();
$this->bcExpectException(\Com\Tecnick\Pdf\Exception::class);
$data = [
'enabled' => true,
'host' => 'https://tsa.example.test',
'username' => '',
'password' => '',
'cert' => '',
'hash_algorithm' => 'sha256',
'policy_oid' => '',
'nonce_enabled' => true,
'timeout' => 5,
'verify_peer' => 1,
];
$setSignTimeStampObj = new \ReflectionMethod($obj, 'setSignTimeStamp');
$setSignTimeStampObj->invoke($obj, $data);
}
/** @throws \Throwable */
public function testAddPagePartialMarginsKeepBottomFooterMarginsWhenCtCbMissing(): void
{
$obj = $this->getTestObject();
$margin = [
'PT' => 7,
'PR' => 7,
'PB' => 7,
'PL' => 7,
'HB' => 15,
'FT' => 15,
];
$landscape = $obj->addPage([
'orientation' => 'L',
'format' => 'A4',
'margin' => $margin,
]);
/** @var array{margin: array{CB: float, CT: float, FT: float, HB: float, PB: float}, FooterHeight: float, region: array<int, array{RB: float}>} $landscape */
$this->bcAssertEqualsWithDelta(15.0, $landscape['margin']['CB']);
$this->bcAssertEqualsWithDelta(15.0, $landscape['margin']['CT']);
$this->bcAssertEqualsWithDelta(15.0, $landscape['margin']['FT']);
$this->bcAssertEqualsWithDelta(15.0, $landscape['margin']['HB']);
$this->bcAssertEqualsWithDelta(7.0, $landscape['margin']['PB']);
$this->bcAssertEqualsWithDelta(8.0, $landscape['FooterHeight']);
$landscapeRegion0 = $landscape['region'][0] ?? null;
if (!\is_array($landscapeRegion0)) {
$this->fail('Unexpected addPage() region payload for landscape page.');
}
$this->bcAssertEqualsWithDelta(15.0, $landscapeRegion0['RB']);
$portrait = $obj->addPage([
'orientation' => 'P',
'format' => 'A4',
'margin' => $margin,
]);
/** @var array{margin: array{CB: float, CT: float, FT: float, HB: float, PB: float}, FooterHeight: float, region: array<int, array{RB: float}>} $portrait */
$this->bcAssertEqualsWithDelta(15.0, $portrait['margin']['CB']);
$this->bcAssertEqualsWithDelta(15.0, $portrait['margin']['CT']);
$this->bcAssertEqualsWithDelta(15.0, $portrait['margin']['FT']);
$this->bcAssertEqualsWithDelta(15.0, $portrait['margin']['HB']);
$this->bcAssertEqualsWithDelta(7.0, $portrait['margin']['PB']);
$this->bcAssertEqualsWithDelta(8.0, $portrait['FooterHeight']);
$portraitRegion0 = $portrait['region'][0] ?? null;
if (!\is_array($portraitRegion0)) {
$this->fail('Unexpected addPage() region payload for portrait page.');
}
$this->bcAssertEqualsWithDelta(15.0, $portraitRegion0['RB']);
}
/** @throws \Throwable */
public function testPdfColorGetterAndInvalidColorFallback(): void
{
$default = $this->getTestObject();
/** @var \Com\Tecnick\Pdf\PdfColor $defaultColor */
$defaultColor = $this->getObjectProperty($default, 'color');
$this->assertFalse($defaultColor->isForceDeviceCmyk());
$pdfx = new \Com\Tecnick\Pdf\Tcpdf('mm', true, false, true, 'pdfx');
/** @var \Com\Tecnick\Pdf\PdfColor $pdfxColor */
$pdfxColor = $this->getObjectProperty($pdfx, 'color');
$this->assertTrue($pdfxColor->isForceDeviceCmyk());
$this->assertSame('', $pdfxColor->getPdfColor('not-a-color-value'));
}
/**
* End-to-end: signing a document embeds a native detached CAdES CMS that
* cryptographically verifies over the ByteRange bytes, while the legacy
* profile keeps the /adbe.pkcs7.detached SubFilter.
*
* @throws \Throwable
*/
public function testSignDocumentEmbedsVerifiableNativeCms(): void
{
$cred = $this->makeSigningCredentials();
$obj = $this->getTestObject();
$page = $this->initFontAndAddRawPage($obj);
$obj->setSignature([
'appearance' => ['empty' => [], 'name' => 'MainSig', 'page' => $page['pid'], 'rect' => '0 0 10 5'],
'approval' => '',
'cert_type' => 2,
'extracerts' => '',
'info' => ['ContactInfo' => '', 'Location' => '', 'Name' => '', 'Reason' => ''],
'password' => '',
'privkey' => $cred['key_pem'],
'signcert' => $cred['cert_pem'],
]);
$pdf = $obj->getOutPDFString();
// Legacy profile keeps the ISO 32000-1 SubFilter.
$this->assertStringContainsString('/SubFilter /adbe.pkcs7.detached', $pdf);
[$cmsDer, $signedBytes] = $this->extractSignatureCmsAndContent($pdf);
// The embedded blob is a CMS SignedData ContentInfo.
$asn1 = new \Com\Tecnick\Pdf\Sign\Cms\Asn1();
$offset = 0;
$root = $asn1->readTlv($cmsDer, $offset);
$this->assertSame(0x30, $root['tag']);
$this->assertStringContainsString($asn1->encodeObjectIdentifier('1.2.840.113549.1.7.2'), $cmsDer);
// The detached signature covers exactly the ByteRange bytes: the
// message-digest signed attribute equals SHA-256 of that content.
$this->assertStringContainsString(\hash('sha256', $signedBytes, true), $cmsDer);
// The SignerInfo signature verifies over the DER SET OF signed attributes.
$this->assertTrue($this->cmsSignatureVerifies($cmsDer, $cred['cert_pem']));
}
/**
* PAdES B-LT: the Document Security Store is emitted in a post-signing
* incremental revision whose VRI key is the SHA-1 of the final signature
* /Contents, and the document catalog is re-emitted there with the /DSS
* reference. The originally signed revision is left byte-for-byte intact.
*
* @throws \Throwable
*/
public function testGetOutPDFStringAppendsBltDssRevision(): void
{
$cred = $this->makeSigningCredentials();
$obj = $this->getTestObject();
$page = $this->initFontAndAddRawPage($obj);
$obj->setSignature([
'appearance' => ['empty' => [], 'name' => 'MainSig', 'page' => $page['pid'], 'rect' => '0 0 10 5'],
'approval' => '',
'cert_type' => 2,
'extracerts' => '',
'info' => ['ContactInfo' => '', 'Location' => '', 'Name' => '', 'Reason' => ''],
'password' => '',
'privkey' => $cred['key_pem'],
'signcert' => $cred['cert_pem'],
'ltv' => [
'enabled' => true,
'embed_ocsp' => false,
'embed_crl' => false,
'embed_certs' => true,
'include_dss' => true,
'include_vri' => true,
],
]);
$pdf = $obj->getOutPDFString();
// The signed revision is untouched and a second (DSS) revision is appended.
$this->assertSame(2, \substr_count($pdf, '%%EOF'));
$this->assertSame(2, \substr_count($pdf, "startxref\n"));
$firstEof = \strpos($pdf, '%%EOF');
$this->assertIsInt($firstEof);
$revision = \substr($pdf, $firstEof);
// The DSS objects and the re-emitted catalog live only in the appended revision.
$this->assertStringContainsString('/Type /DSS', $revision);
$this->assertStringContainsString('/Certs [', $revision);
$this->assertStringContainsString('/Type /VRI', $revision);
$this->assertStringContainsString('/Type /Catalog', $revision);
$this->assertStringContainsString('/Prev ', $revision);
// The VRI key is the uppercase SHA-1 of the signature /Contents bytes.
$cstart = \strpos($pdf, '/Contents<');
$this->assertIsInt($cstart);
$cstart += \strlen('/Contents<');
$cend = \strpos($pdf, '>', $cstart);
$this->assertIsInt($cend);
$contents = (string) \hex2bin(\substr($pdf, $cstart, $cend - $cstart));
$vriKey = \strtoupper(\sha1($contents));
$this->assertStringContainsString('/VRI << /' . $vriKey . ' ', $revision);
$this->assertStringContainsString('/DSS ', $revision);
// It is no longer the legacy cert-hash VRI key.
$certDer = (string) \base64_decode(
(string) \preg_replace('/-----[^-]+-----|\s+/', '', $cred['cert_pem']),
true,
);
$this->assertStringNotContainsString(\strtoupper(\sha1($certDer)), $revision);
}
/** @throws \Throwable */
public function testSignatureFacadeIsLazyAndStable(): void
{
$obj = $this->getTestObject();
$facade = $obj->signature();
$this->assertInstanceOf(\Com\Tecnick\Pdf\Signature\Facade::class, $facade);
$this->assertSame($facade, $obj->signature());
}
/** @throws \Throwable */
public function testSignatureFacadeConfigureSetsSignState(): void
{
$obj = $this->getTestObject();
$page = $this->initFontAndAddRawPage($obj);
$result = $obj->signature()->configure([
'appearance' => ['empty' => [], 'name' => 'MainSig', 'page' => $page['pid'], 'rect' => '0 0 10 5'],
'approval' => '',
'cert_type' => 2,
'extracerts' => '',
'info' => ['ContactInfo' => '', 'Location' => '', 'Name' => '', 'Reason' => ''],
'password' => '',
'privkey' => 'dummy-key',
'signcert' => 'dummy-cert',
]);
$this->assertInstanceOf(\Com\Tecnick\Pdf\Signature\Facade::class, $result);
$this->assertTrue($this->getObjectProperty($obj, 'sign'));
$this->assertGreaterThan(0, $obj->signature()->widgetObjectId());
}
/** @throws \Throwable */
public function testSignatureFacadeAppearanceAndEmptyFieldDelegate(): void
{
$obj = $this->getTestObject();
$page = $this->initFontAndAddRawPage($obj);
$appearance = $obj->signature()->appearance();
$chained = $appearance->place(10, 20, 30, 15, $page['pid'], 'MainSig')->xobject('IMP1');
$appearance->stream('q 0 g Q', 'N', 'On');
$obj->signature()->emptyField(5, 6, 20, 10, $page['pid'], 'EmptySig');
$this->assertInstanceOf(\Com\Tecnick\Pdf\Signature\Appearance::class, $chained);
/**
* @var array{appearance: array{
* name: string, xobj: string, as: string, empty: array<int, array{name: string}>
* }} $signature
*/
$signature = $this->getObjectProperty($obj, 'signature');
$this->assertSame('MainSig', $signature['appearance']['name']);
$this->assertSame('IMP1', $signature['appearance']['xobj']);
$this->assertSame('On', $signature['appearance']['as']);
$this->assertNotEmpty($signature['appearance']['empty']);
}
/** @throws \Throwable */
public function testSignatureFacadeTimestampAndUserRightsDelegate(): void
{
$obj = $this->getTestObject();
$obj
->signature()
->timestamp([
'enabled' => true,
'host' => 'https://tsa.example.org',
'username' => '',
'password' => '',
'cert' => '',
'hash_algorithm' => 'sha256',
'policy_oid' => '',
'nonce_enabled' => true,
'timeout' => 5,
'verify_peer' => true,
])
->userRights([
'annots' => '',
'document' => '/FullSave',
'ef' => '',
'enabled' => true,
'form' => '',
'formex' => '',
'signature' => '',
]);
/** @var array{enabled: bool, host: string} $ts */
$ts = $this->getObjectProperty($obj, 'sigtimestamp');
$this->assertTrue($ts['enabled']);
$this->assertSame('https://tsa.example.org', $ts['host']);
/** @var array{document: string} $rights */
$rights = $this->getObjectProperty($obj, 'userrights');
$this->assertSame('/FullSave', $rights['document']);
}
/** @throws \Throwable */
public function testSignatureFacadeUpgradeToLtaSetsProfileAndDss(): void
{
$obj = $this->getTestObject();
$result = $obj->signature()->upgradeToLta();
$this->assertInstanceOf(\Com\Tecnick\Pdf\Signature\Facade::class, $result);
/** @var array{profile: string, ltv: array{enabled: bool, include_dss: bool}} $signature */
$signature = $this->getObjectProperty($obj, 'signature');
$this->assertSame('pades-b-lta', $signature['profile']);
$this->assertTrue($signature['ltv']['enabled']);
$this->assertTrue($signature['ltv']['include_dss']);
}
/** @throws \Throwable */
public function testSignatureFacadeExternalConfigureAndPrepare(): void
{
$obj = $this->getTestObject();
$page = $this->initFontAndAddRawPage($obj);
$obj
->signature()
->external()
->configure([
'appearance' => ['empty' => [], 'name' => 'ExtSig', 'page' => $page['pid'], 'rect' => '0 0 10 5'],
'approval' => '',
'cert_type' => 2,
'extracerts' => '',
'info' => ['ContactInfo' => '', 'Location' => '', 'Name' => '', 'Reason' => ''],
'password' => '',
'privkey' => '',
'signcert' => '',
]);
$prep = $obj->signature()->external()->prepare('sha256');
$this->assertSame('sha256', $prep['algorithm']);
$this->assertNotSame('', $prep['prepared_pdf']);
$this->assertSame(\hash('sha256', $prep['prepared_pdf'], true), $prep['hash_raw']);
}
/**
* Extract the embedded CMS (DER, trimmed of placeholder padding) and the
* ByteRange-covered content from a signed PDF.
*
* @return array{string, string} [CMS DER, signed content bytes]
* @throws \Throwable
*/
private function extractSignatureCmsAndContent(string $pdf): array
{
$m = [];
if (\preg_match('/\\/ByteRange\\[0 (\\d+) (\\d+) (\\d+)\\]/', $pdf, $m) !== 1) {
$this->fail('ByteRange not found in signed output');
}
$end1 = (int) ($m[1] ?? '0');
$start2 = (int) ($m[2] ?? '0');
$len2 = (int) ($m[3] ?? '0');
$signedBytes = \substr($pdf, 0, $end1) . \substr($pdf, $start2, $len2);
$contents = \substr($pdf, $end1, $start2 - $end1); // '<' hex '>'
$hex = \trim($contents, '<>');
$binary = (string) \hex2bin($hex);
// Trim the trailing zero padding to the exact DER length.
$asn1 = new \Com\Tecnick\Pdf\Sign\Cms\Asn1();
$offset = 0;
$cms = $asn1->readTlv($binary, $offset);
return [$cms['raw'], $signedBytes];
}
/**
* Cryptographically verify the CMS SignerInfo signature over its signed
* attributes using the certificate public key.
*
* @throws \Throwable
*/
private function cmsSignatureVerifies(string $cmsDer, string $certPem): bool
{
$asn1 = new \Com\Tecnick\Pdf\Sign\Cms\Asn1();
$offset = 0;
$contentInfo = $asn1->readTlv($cmsDer, $offset);
$ci = 0;
$asn1->readTlv($contentInfo['value'], $ci); // contentType OID
$explicit = $asn1->readTlv($contentInfo['value'], $ci); // [0] EXPLICIT
$sd = 0;
$signedData = $asn1->readTlv($explicit['value'], $sd);
$inner = 0;
$asn1->readTlv($signedData['value'], $inner); // version
$asn1->readTlv($signedData['value'], $inner); // digestAlgorithms
$asn1->readTlv($signedData['value'], $inner); // encapContentInfo
$asn1->readTlv($signedData['value'], $inner); // certificates [0]
$signerInfos = $asn1->readTlv($signedData['value'], $inner); // signerInfos SET
$si = 0;
$signerInfo = $asn1->readTlv($signerInfos['value'], $si);
$f = 0;
$asn1->readTlv($signerInfo['value'], $f); // version
$asn1->readTlv($signerInfo['value'], $f); // sid
$asn1->readTlv($signerInfo['value'], $f); // digestAlgorithm
$signedAttrs = $asn1->readTlv($signerInfo['value'], $f); // [0] IMPLICIT
$asn1->readTlv($signerInfo['value'], $f); // signatureAlgorithm
$signature = $asn1->readTlv($signerInfo['value'], $f); // signature OCTET STRING
$publicKey = \openssl_pkey_get_public($certPem);
if ($publicKey === false) {
$this->fail('Unable to load certificate public key');
}
$signedAttrsSet = $asn1->encodeSet($signedAttrs['value']);
return \openssl_verify($signedAttrsSet, $signature['value'], $publicKey, OPENSSL_ALGO_SHA256) === 1;
}
/**
* Generate an RSA signing key and a matching self-signed certificate (PEM).
*
* @return array{cert_pem: string, key_pem: string}
*/
private function makeSigningCredentials(): array
{
$config = ['digest_alg' => 'sha256', 'private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA];
$key = \openssl_pkey_new($config);
if (!$key instanceof \OpenSSLAsymmetricKey) {
$this->markTestSkipped('RSA key generation is not available');
}
$csr = \openssl_csr_new(['commonName' => 'tc-lib-pdf signer'], $key, $config);
if (!$csr instanceof \OpenSSLCertificateSigningRequest) {
$this->markTestSkipped('CSR generation failed');
}
$cert = \openssl_csr_sign($csr, null, $key, 365, $config);
if (!$cert instanceof \OpenSSLCertificate) {
$this->markTestSkipped('Certificate signing failed');
}
$certPem = '';
\openssl_x509_export($cert, $certPem);
$keyPem = '';
\openssl_pkey_export($key, $keyPem);
return ['cert_pem' => $certPem, 'key_pem' => $keyPem];
}
}