* @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 * @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 $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 $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 $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 $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 $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 */ 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}} $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> $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 $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 $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 $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, page: int}} $signature */ $signature = $this->getObjectProperty($obj, 'signature'); $this->assertSame([], $signature['appearance']['empty']); $this->assertSame($page['pid'], $signature['appearance']['page']); /** @var array{annotrefs: array} $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>, 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 $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 $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 $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 $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 $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} $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} $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 * }} $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]; } }