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

6402 lines
221 KiB
PHP

<?php
/**
* OutputTest.php
*
* @since 2002-08-03
* @category Library
* @package Pdf
* @author Nicola Asuni <info@tecnick.com>
* @copyright 2002-2026 Nicola Asuni - Tecnick.com LTD
* @license https://www.gnu.org/copyleft/lesser.html GNU-LGPL v3 (see LICENSE)
* @link https://github.com/tecnickcom/tc-lib-pdf
*
* This file is part of tc-lib-pdf software library.
*/
namespace Test;
use PHPUnit\Framework\Attributes\DataProvider;
class OutputTest extends TestUtil
{
/** @param array<string, mixed> $updates */
private function setEncryptData(\Com\Tecnick\Pdf\Encrypt\Encrypt $encrypt, array $updates): void
{
$ref = new \ReflectionObject($encrypt);
$prop = $ref->getProperty('encryptdata');
/** @var array<string, mixed> $data */
$data = $prop->getValue($encrypt);
$prop->setValue($encrypt, \array_replace($data, $updates));
}
public static function setUpBeforeClass(): void
{
self::setUpFontsPath();
}
/**
* @throws \Throwable
*/
protected function getTestObject(): \Com\Tecnick\Pdf\Tcpdf
{
return new \Com\Tecnick\Pdf\Tcpdf();
}
/**
* @throws \Throwable
*/
protected function getInternalTestObject(): TestableOutput
{
return new TestableOutput();
}
/**
* @throws \Throwable
*/
protected function getInternalUncompressedTestObject(): TestableOutput
{
return new TestableOutput('mm', true, false, false);
}
/**
* @return array{pid:int,n:int,content:array<int,string>}
* @throws \Throwable
*/
protected function addRawPageWithObjectNumber(\Com\Tecnick\Pdf\Tcpdf $obj, int $objectNumber): array
{
/** @var \Com\Tecnick\Pdf\Page\Page $page */
$page = $this->getObjectProperty($obj, 'page');
/** @var array{pid:int} $data */
$data = $page->add([]);
/** @var array<int, array<string, mixed>> $pages */
$pages = $this->getObjectProperty($page, 'page');
$pages[$data['pid']]['n'] = $objectNumber;
$this->setObjectProperty($page, 'page', $pages);
/** @var array<string, mixed> $pageData */
$pageData = $page->getPage($data['pid']);
if (
!isset($pageData['pid'], $pageData['n'], $pageData['content'])
|| !\is_int($pageData['pid'])
|| !\is_int($pageData['n'])
|| !\is_array($pageData['content'])
) {
$this->fail('Unexpected page data shape.');
}
$pid = $pageData['pid'];
$n = $pageData['n'];
$rawContent = $pageData['content'];
/** @var array<int, string> $content */
$content = \array_values(\array_filter($rawContent, 'is_string'));
return ['pid' => $pid, 'n' => $n, 'content' => $content];
}
/** @param list<string> $fragments */
private function assertContainsAllFragments(string $output, array $fragments): void
{
foreach ($fragments as $fragment) {
$this->assertStringContainsString($fragment, $output);
}
}
/**
* @param list<string> $cmd
* @return array{code:int,stdout:string,stderr:string}
*/
private function runExternalCommand(array $cmd, string $cwd): array
{
$desc = [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];
if ($cwd === '') {
$this->fail('runExternalCommand requires a non-empty cwd');
}
/** @var array<int, resource> $pipes */
$pipes = [];
$proc = \proc_open($cmd, $desc, $pipes, $cwd);
if (!\is_resource($proc)) {
return ['code' => 127, 'stdout' => '', 'stderr' => 'Unable to start process'];
}
assert(isset($pipes[0]), "\$pipes[0] must be set");
\fclose($pipes[0]);
assert(isset($pipes[1]), "\$pipes[1] must be set");
$stdout = (string) \stream_get_contents($pipes[1]);
\fclose($pipes[1]);
assert(isset($pipes[2]), "\$pipes[2] must be set");
$stderr = (string) \stream_get_contents($pipes[2]);
\fclose($pipes[2]);
$code = \proc_close($proc);
return ['code' => $code, 'stdout' => $stdout, 'stderr' => $stderr];
}
/**
* @throws \Throwable
*/
private function isCommandAvailable(string $name): bool
{
$res = $this->runExternalCommand(['sh', '-lc', 'command -v ' . \escapeshellarg($name)], __DIR__ . '/..');
return $res['code'] === 0 && \trim($res['stdout']) !== '';
}
/**
* @throws \Throwable
*/
public function testSubsetFontPdfHasReaderCompatibleFontObjects(): void
{
$obj = new \Com\Tecnick\Pdf\Tcpdf('mm', true, true, true);
$font = $obj->font->insert($obj->pon, 'dejavusans', '', 12);
$obj->addPage();
$obj->page->addContent($font['out']);
$obj->addHTMLCell(
'<h1>Subset Font Regression</h1><p><b>Bold</b> THE QUICK BROWN FOX · π ≈ 3.14159 © ® ™</p>',
15,
20,
180,
);
$raw = $obj->getOutPDFString();
// Ensure CID metadata is explicit and no empty CID registry/ordering is emitted.
$this->assertStringContainsString('/Subtype /CIDFontType2', $raw);
$this->assertStringNotContainsString('/Registry () /Ordering ()', $raw);
$this->assertMatchesRegularExpression('/\\/Registry \(Adobe\) \\/Ordering \(Identity\) \\/Supplement 0/', $raw);
// Embedded subset streams should be non-trivial font payloads, not tiny/broken stubs.
$matches = [];
\preg_match_all('/\\/Length1\\s+(\\d+)/', $raw, $matches);
assert(isset($matches[1]), "\$matches[1] must be set");
$lengths = \array_map('intval', $matches[1]);
if ($lengths === []) {
$this->fail('Expected at least one Length1 entry in generated PDF.');
}
$this->assertGreaterThan(1000, \max($lengths));
$tmpBase = \sys_get_temp_dir() . '/tc-lib-pdf-subset-' . \bin2hex(\random_bytes(6));
$pdfPath = $tmpBase . '.pdf';
\file_put_contents($pdfPath, $raw);
try {
if ($this->isCommandAvailable('pdftoppm')) {
$popplerOut = $this->runExternalCommand([
'pdftoppm',
'-f',
'1',
'-singlefile',
'-png',
$pdfPath,
$tmpBase . '-poppler',
], __DIR__ . '/..');
$this->assertSame(0, $popplerOut['code'], $popplerOut['stderr']);
$this->assertStringNotContainsString("Couldn't create a font", $popplerOut['stderr']);
if (\file_exists($tmpBase . '-poppler.png')) {
\unlink($tmpBase . '-poppler.png');
}
}
if ($this->isCommandAvailable('mutool')) {
$mupdfPng = $tmpBase . '-mupdf.png';
$mupdfOut = $this->runExternalCommand([
'mutool',
'draw',
'-o',
$mupdfPng,
$pdfPath,
'1',
], __DIR__ . '/..');
$this->assertSame(0, $mupdfOut['code'], $mupdfOut['stderr']);
$this->assertStringNotContainsString('FT_New_Memory_Face', $mupdfOut['stderr']);
$this->assertStringNotContainsString('locations (loca) table missing', $mupdfOut['stderr']);
$this->assertStringNotContainsString('broken table', $mupdfOut['stderr']);
if (\file_exists($mupdfPng)) {
\unlink($mupdfPng);
}
}
} finally {
if (\file_exists($pdfPath)) {
\unlink($pdfPath);
}
}
}
/**
* @throws \Throwable
*/
public function testGetOutPDFStringReturnsRawPdfDocument(): void
{
$obj = $this->getTestObject();
$this->initFontAndPage($obj);
$raw = $obj->getOutPDFString();
$this->assertStringStartsWith('%PDF-', $raw);
$this->assertStringContainsString('/Type /Catalog', $raw);
$this->assertStringContainsString("startxref\n", $raw);
$this->assertStringEndsWith("%%EOF\n", $raw);
}
/**
* @throws \Throwable
*/
public function testRenderPDFInCliEchoesRawString(): void
{
$obj = $this->getTestObject();
\ob_start();
$obj->renderPDF('raw-pdf-data');
$out = (string) \ob_get_clean();
$this->assertSame('raw-pdf-data', $out);
}
/**
* @throws \Throwable
*/
public function testRenderPDFInCliPreservesBinaryPdfBytes(): void
{
$script = \sys_get_temp_dir() . '/tc-lib-pdf-render-' . \bin2hex(\random_bytes(6)) . '.php';
$autoload = \var_export(__DIR__ . '/../vendor/autoload.php', true);
$fonts = \var_export((string) \realpath(__DIR__ . '/../vendor/tecnickcom/tc-lib-pdf-font/target/fonts'), true);
$code = <<<'PHP'
<?php
require AUTOLOAD_PATH;
define('K_PATH_FONTS', FONTS_PATH);
$pdf = new \Com\Tecnick\Pdf\Tcpdf();
$font = $pdf->font->insert($pdf->pon, 'dejavusans', '', 12);
$pdf->addPage();
$pdf->page->addContent($font['out']);
$pdf->page->addContent(
$pdf->getTextCell('The quick brown fox jumps over the lazy dog', 15, 15, 150, valign: 'T', halign: 'L')
);
$raw = $pdf->getOutPDFString();
fwrite(STDERR, md5($raw));
$pdf->renderPDF($raw);
PHP;
$code = \str_replace(['AUTOLOAD_PATH', 'FONTS_PATH'], [$autoload, $fonts], $code);
\file_put_contents($script, $code);
$cmd = [PHP_BINARY, $script];
$desc = [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];
/** @var array<int, resource> $pipes */
$pipes = [];
$proc = \proc_open($cmd, $desc, $pipes, __DIR__ . '/..');
if (!\is_resource($proc)) {
\unlink($script);
$this->fail('Unable to start PHP subprocess for renderPDF regression test.');
}
assert(isset($pipes[0]), "\$pipes[0] must be set");
\fclose($pipes[0]);
assert(isset($pipes[1]), "\$pipes[1] must be set");
$stdout = (string) \stream_get_contents($pipes[1]);
\fclose($pipes[1]);
assert(isset($pipes[2]), "\$pipes[2] must be set");
$stderr = (string) \stream_get_contents($pipes[2]);
\fclose($pipes[2]);
$exitCode = \proc_close($proc);
\unlink($script);
$this->assertSame(0, $exitCode, $stderr);
$this->assertSame($stderr, \md5($stdout));
}
/**
* @throws \Throwable
*/
public function testDownloadPDFRejectsExistingOutputBufferContent(): void
{
$obj = $this->getTestObject();
$level = \ob_get_level();
\ob_start();
echo 'already-sent';
try {
$obj->downloadPDF('raw-pdf-data');
$this->fail('Expected downloadPDF to throw when output buffer already contains content.');
} catch (\Com\Tecnick\Pdf\Exception $e) {
$this->assertStringContainsString('cannot be sent', $e->getMessage());
} finally {
while (\ob_get_level() > $level) {
\ob_end_clean();
}
}
}
/**
* @throws \Throwable
*/
public function testDownloadPDFOutputsRawDataWhenBufferIsClean(): void
{
$obj = $this->getTestObject();
\ob_start();
$obj->downloadPDF('raw-pdf-data');
$out = (string) \ob_get_clean();
$this->assertSame('raw-pdf-data', $out);
}
/**
* @throws \Throwable
*/
public function testSavePDFWritesRawPdfToDisk(): void
{
$obj = $this->getTestObject();
$obj->setPDFFilename('saved-output.pdf');
$dir = \sys_get_temp_dir() . '/tc-lib-pdf-output-' . \bin2hex(\random_bytes(4));
$this->assertTrue(\mkdir($dir, 0o777, true));
try {
$obj->savePDF($dir, 'raw-pdf-data');
$filepath = $dir . '/saved-output.pdf';
$this->assertFileExists($filepath);
$this->assertSame('raw-pdf-data', (string) \file_get_contents($filepath));
} finally {
if (\file_exists($dir . '/saved-output.pdf')) {
\unlink($dir . '/saved-output.pdf');
}
if (\is_dir($dir)) {
\rmdir($dir);
}
}
}
/**
* @throws \Throwable
*/
public function testGetMIMEAttachmentPDFReturnsBase64Attachment(): void
{
$obj = $this->getTestObject();
$obj->setPDFFilename('mail-output.pdf');
$mime = $obj->getMIMEAttachmentPDF('raw-pdf-data');
$this->assertStringContainsString('Content-Type: application/pdf;', $mime);
$this->assertStringContainsString('name="mail-output.pdf"', $mime);
$this->assertStringContainsString('Content-Transfer-Encoding: base64', $mime);
$this->assertStringContainsString(\chunk_split(\base64_encode('raw-pdf-data'), 76, "\r\n"), $mime);
}
/**
* @throws \Throwable
*/
public function testGetPDFObjectOffsetsReturnsSortedPositions(): void
{
$obj = $this->getInternalTestObject();
$data = "3 0 obj\nthird\nendobj\n1 0 obj\nfirst\nendobj\n10 0 obj\ntenth\nendobj\n";
$offsets = $obj->exposeGetPDFObjectOffsets($data);
$this->assertSame(
[
1 => \strpos($data, "1 0 obj\n"),
3 => \strpos($data, "3 0 obj\n"),
10 => \strpos($data, "10 0 obj\n"),
],
$offsets,
);
}
/**
* @throws \Throwable
*/
public function testGetOutPDFXrefFormatsPresentAndMissingObjects(): void
{
$obj = $this->getInternalTestObject();
$obj->setOutputState(4, ['catalog' => 7, 'info' => 8]);
$xref = $obj->exposeGetOutPDFXref([
1 => 9,
3 => 30,
4 => 50,
]);
$expected =
"xref\n"
. "0 5\n"
. "0000000000 65535 f \n"
. "0000000009 00000 n \n"
. "0000000000 00006 f \n"
. "0000000030 00000 n \n"
. "0000000050 00000 n \n";
$this->assertSame($expected, $xref);
}
/**
* @throws \Throwable
*/
public function testGetOutPDFTrailerIncludesCatalogInfoAndFileId(): void
{
$obj = $this->getInternalTestObject();
$obj->setOutputState(4, ['catalog' => 7, 'info' => 8], 'FEEDBEEF');
$trailer = $obj->exposeGetOutPDFTrailer();
$this->assertStringContainsString("trailer\n", $trailer);
$this->assertStringContainsString(' /Size 5 /Root 7 0 R /Info 8 0 R', $trailer);
$this->assertStringContainsString(' /ID [ <FEEDBEEF> <FEEDBEEF> ]', $trailer);
$this->assertStringNotContainsString(' /Encrypt ', $trailer);
}
/**
* @throws \Throwable
*/
public function testGetOutPDFTrailerIncludesEncryptReferenceWhenPresent(): void
{
$obj = $this->getInternalTestObject();
$obj->setOutputState(4, ['catalog' => 7, 'info' => 8], 'FEEDBEEF', 12);
$trailer = $obj->exposeGetOutPDFTrailer();
$this->assertStringContainsString(' /Encrypt 12 0 R', $trailer);
}
/**
* @throws \Throwable
*/
public function testGetColorStringFromPercArrayFormatsSupportedColorSpaces(): void
{
$obj = $this->getInternalTestObject();
$this->assertSame('[0.500000]', $obj->exposeGetColorStringFromPercArray([0.5]));
$this->assertSame('[0.100000 0.200000 0.300000]', $obj->exposeGetColorStringFromPercArray([0.1, 0.2, 0.3]));
$this->assertSame('[0.100000 0.200000 0.300000 0.400000]', $obj->exposeGetColorStringFromPercArray([
0.1,
0.2,
0.3,
0.4,
]));
$this->assertSame('[]', $obj->exposeGetColorStringFromPercArray([]));
}
/**
* @throws \Throwable
*/
public function testGetAnnotationFlagsCodeAndOutputHandlePdfa(): void
{
$obj = $this->getInternalTestObject();
$this->assertSame(580, $obj->exposeGetAnnotationFlagsCode(['print', 'readonly', 'lockedcontents']));
$this->assertSame(' /F 128', $obj->exposeGetOutAnnotationFlags(['opt' => ['f' => ['locked']]]));
$obj->setPdfaMode(1);
$this->assertSame(' /F 6', $obj->exposeGetOutAnnotationFlags(['opt' => ['f' => ['hidden']]]));
}
/**
* @throws \Throwable
*/
public function testGetAnnotationBorderFormatsBorderStyleAndEffect(): void
{
$obj = $this->getInternalTestObject();
$annot = [
'opt' => [
'bs' => ['w' => 2, 's' => 'D', 'd' => [3, 1]],
'be' => ['s' => 'C', 'i' => 1.5],
],
];
$border = $obj->exposeGetAnnotationBorder($annot);
$this->assertStringContainsString(' /BS << /Type /Border /W 2 /S /D /D [ 3 1] >>', $border);
$this->assertStringContainsString(' /BE << /S /C /I 1.500000>>', $border);
}
/**
* @throws \Throwable
*/
#[DataProvider('onOffProvider')]
public function testGetOnOffMapsTruthyAndFalsyValues(mixed $input, string $expected): void
{
$obj = $this->getInternalTestObject();
$this->assertSame($expected, $obj->exposeGetOnOff($input));
}
/**
* @throws \Throwable
*/
public function testGetOutDestinationsSerializesNamedDestinationCoordinates(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$page = $this->addRawPageWithObjectNumber($obj, 3);
$dest = $obj->setNamedDestination('Section One', $page['pid'], 10.0, 20.0);
$out = $obj->exposeGetOutDestinations();
$this->assertStringStartsWith("1 0 obj\n<< ", $out);
$this->assertStringContainsString(' /Section#20One [3 0 R /XYZ 28.346457 785.197087 null]', $out);
$this->assertStringEndsWith(" >>\nendobj\n", $out);
$this->assertSame('#Section#20One', $dest);
}
/**
* @throws \Throwable
*/
public function testSortBookmarksOrdersByPageThenOriginalIndex(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$page1 = $this->addRawPageWithObjectNumber($obj, 3);
$page2 = $this->addRawPageWithObjectNumber($obj, 4);
$obj->setBookmark('Later on page 2', '', 0, $page2['pid']);
$obj->setBookmark('Earlier on page 1', '', 0, $page1['pid']);
$obj->setBookmark('Also on page 2', '', 0, $page2['pid']);
$obj->exposeSortBookmarks();
$outlines = $obj->getOutlinesState();
assert(isset($outlines[0]), "\$outlines[0] must be set");
$this->assertSame('Earlier on page 1', $outlines[0]['t']);
assert(isset($outlines[1]), "\$outlines[1] must be set");
$this->assertSame('Later on page 2', $outlines[1]['t']);
assert(isset($outlines[2]), "\$outlines[2] must be set");
$this->assertSame('Also on page 2', $outlines[2]['t']);
}
/**
* @throws \Throwable
*/
public function testProcessPrevNextBookmarksAssignsHierarchyLinks(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$page = $this->addRawPageWithObjectNumber($obj, 3);
$obj->setBookmark('Root A', '', 0, $page['pid']);
$obj->setBookmark('Child A1', '', 1, $page['pid']);
$obj->setBookmark('Root B', '', 0, $page['pid']);
$root = $obj->exposeProcessPrevNextBookmarks();
$outlines = $obj->getOutlinesState();
$this->assertSame(2, $root);
assert(isset($outlines[0]), "\$outlines[0] must be set");
$this->assertSame(3, $outlines[0]['parent']);
$this->assertSame(1, $outlines[0]['first']);
$this->assertSame(1, $outlines[0]['last']);
assert(isset($outlines[1]), "\$outlines[1] must be set");
$this->assertSame(0, $outlines[1]['parent']);
$this->assertSame(2, $outlines[0]['next']);
assert(isset($outlines[2]), "\$outlines[2] must be set");
$this->assertSame(0, $outlines[2]['prev']);
$this->assertSame(3, $outlines[2]['parent']);
}
/**
* @throws \Throwable
*/
public function testGetOutBookmarksSerializesOutlineObjectsAndRoot(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$page1 = $this->addRawPageWithObjectNumber($obj, 3);
$page2 = $this->addRawPageWithObjectNumber($obj, 4);
$obj->setBookmark('First <b>Section</b>', '', 0, $page1['pid'], 12.0, 34.0, 'B', 'red');
$obj->setBookmark('Second Section', '#target', 0, $page2['pid'], 1.0, 2.0, 'I');
$out = $obj->exposeGetOutBookmarks();
$this->assertContainsAllFragments($out, [
'/Title (',
"\x00F\x00i\x00r\x00s\x00t\x00 \x00S\x00e\x00c\x00t\x00i\x00o\x00n",
'/Dest [3 0 R /XYZ 34.015748 745.512047 null]',
'/F 2 /C [ 1.000000 0.000000 0.000000 ]',
'/Dest /target',
'/F 1 /C [0.0 0.0 0.0]',
'/Type /Outlines',
]);
}
/**
* @throws \Throwable
*/
public function testGetOutJavascriptBuildsObjectsAndNameTree(): void
{
$obj = $this->getInternalTestObject();
$obj->appendRawJavaScript('app.alert("hi");');
$obj->addRawJavaScriptObj('console.println("loaded");', true);
$obj->addRawJavaScriptObj('console.println("skip");', false);
$out = $obj->exposeGetOutJavascript();
$this->assertStringContainsString('/S /JavaScript /JS ', $out);
$this->assertStringContainsString("\x00a\x00p\x00p", $out);
$this->assertStringContainsString("\x00l\x00o\x00a\x00d\x00e\x00d", $out);
$this->assertStringContainsString("\x00s\x00k\x00i\x00p", $out);
$this->assertSame('<< /Names [ (EmbeddedJS) 3 0 R (JS0) 1 0 R ] >>', $obj->getJavascriptTree());
}
/**
* @throws \Throwable
*/
public function testGetXObjectDictEmptyWhenNoImagesOrXObjects(): void
{
$obj = $this->getInternalTestObject();
$out = $obj->exposeGetXObjectDict();
$this->assertSame(' /XObject << >>', $out);
}
/**
* @throws \Throwable
*/
public function testGetLayerDictEmptyWhenNoLayers(): void
{
$obj = $this->getInternalTestObject();
$out = $obj->exposeGetLayerDict();
$this->assertSame('', $out);
}
/**
* @throws \Throwable
*/
public function testGetXObjectDictIncludesCustomObjectEntries(): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'xobjects', [
'XO1' => ['n' => 21],
'XO2' => ['n' => 22],
]);
$out = $obj->exposeGetXObjectDict();
$this->assertStringContainsString('/XO1 21 0 R', $out);
$this->assertStringContainsString('/XO2 22 0 R', $out);
}
/**
* @throws \Throwable
*/
public function testGetLayerDictIncludesLayerReferences(): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'pdflayer', [
['layer' => 'L1', 'objid' => 31],
['layer' => 'L2', 'objid' => 32],
]);
$out = $obj->exposeGetLayerDict();
$this->assertStringContainsString('/L1 31 0 R', $out);
$this->assertStringContainsString('/L2 32 0 R', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutResourcesDictIncludesProcSetAndEmptyFontDict(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
// Initialize outfont property (normally done in getOutPDFBody)
/** @var \Com\Tecnick\Pdf\Font\Stack $font */
$font = $this->getObjectProperty($obj, 'font');
/** @var \Com\Tecnick\Pdf\Encrypt\Encrypt $encrypt */
$encrypt = $this->getObjectProperty($obj, 'encrypt');
/** @var int $pon */
$pon = $this->getObjectProperty($obj, 'pon');
$outfont = new \Com\Tecnick\Pdf\Font\Output($font->getFonts(), $pon, $encrypt);
$this->setObjectProperty($obj, 'outfont', $outfont);
$out = $obj->exposeGetOutResourcesDict();
$this->assertStringContainsString('1 0 obj', $out);
$this->assertStringContainsString(' /ProcSet [/PDF /Text /ImageB /ImageC /ImageI]', $out);
$this->assertStringContainsString(' /XObject << >>', $out);
$this->assertStringEndsWith(" >>\nendobj\n", $out);
}
/**
* @throws \Throwable
*/
public function testGetOutResourcesDictAddsSvgMaskExtGStateWhenGraphHasNoExtGState(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
/** @var \Com\Tecnick\Pdf\Font\Stack $font */
$font = $this->getObjectProperty($obj, 'font');
/** @var \Com\Tecnick\Pdf\Encrypt\Encrypt $encrypt */
$encrypt = $this->getObjectProperty($obj, 'encrypt');
/** @var int $pon */
$pon = $this->getObjectProperty($obj, 'pon');
$outfont = new \Com\Tecnick\Pdf\Font\Output($font->getFonts(), $pon, $encrypt);
$this->setObjectProperty($obj, 'outfont', $outfont);
$obj->setSvgMasks([
'MSK_ONLY' => [
'id' => 'MSK_ONLY',
'stream' => 'q Q',
'bbox' => [0.0, 0.0, 10.0, 10.0],
'gs_n' => 42,
],
]);
$out = $obj->exposeGetOutResourcesDict();
$this->assertStringContainsString('/ExtGState <<', $out);
$this->assertStringContainsString('/MSK_ONLY 42 0 R', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutResourcesDictMergesSvgMaskEntriesWithExistingExtGState(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
/** @var \Com\Tecnick\Pdf\Font\Stack $font */
$font = $this->getObjectProperty($obj, 'font');
/** @var \Com\Tecnick\Pdf\Encrypt\Encrypt $encrypt */
$encrypt = $this->getObjectProperty($obj, 'encrypt');
/** @var \Com\Tecnick\Pdf\Graph\Draw $graph */
$graph = $this->getObjectProperty($obj, 'graph');
/** @var int $pon */
$pon = $this->getObjectProperty($obj, 'pon');
$outfont = new \Com\Tecnick\Pdf\Font\Output($font->getFonts(), $pon, $encrypt);
$this->setObjectProperty($obj, 'outfont', $outfont);
// Ensure graph resources already contain at least one GS entry.
$graph->getAlpha(0.6);
$obj->setSvgMasks([
'MSK_MERGE' => [
'id' => 'MSK_MERGE',
'stream' => 'q Q',
'bbox' => [0.0, 0.0, 10.0, 10.0],
'gs_n' => 43,
],
]);
$out = $obj->exposeGetOutResourcesDict();
$this->assertStringContainsString('/ExtGState <<', $out);
$this->assertStringContainsString('/GS1', $out);
$this->assertStringContainsString('/MSK_MERGE 43 0 R', $out);
}
/**
* @throws \Throwable
*/
public function testPatternStreamResourcesKeepOnlyReferencedFontAndSpotAliases(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
/** @var \Com\Tecnick\Pdf\Font\Stack $font */
$font = $this->getObjectProperty($obj, 'font');
/** @var int $pon */
$pon = $this->getObjectProperty($obj, 'pon');
/** @var \Com\Tecnick\Pdf\Encrypt\Encrypt $encrypt */
$encrypt = $this->getObjectProperty($obj, 'encrypt');
$timesfile = (string) \realpath(__DIR__ . '/../vendor/tecnickcom/tc-lib-pdf-font/target/fonts/core/times.json');
$font->insert($pon, 'times', '', 10, null, null, $timesfile);
$outfont = new \Com\Tecnick\Pdf\Font\Output($font->getFonts(), $pon, $encrypt);
$this->setObjectProperty($obj, 'outfont', $outfont);
/** @var \Com\Tecnick\Color\Pdf $color */
$color = $this->getObjectProperty($obj, 'color');
$this->setObjectProperty($color, 'spot_colors', [
'spotA' => ['i' => 1, 'n' => 111],
'spotB' => ['i' => 2, 'n' => 222],
]);
$stream = '/F1 12 Tf /CS1 cs';
$out = $obj->exposeGetPatternStreamResourceDict($stream);
$this->assertStringContainsString(' /Font <<', $out);
$this->assertStringContainsString(' /F1 ', $out);
$this->assertStringNotContainsString(' /F2 ', $out);
$this->assertStringContainsString(' /ColorSpace <<', $out);
$this->assertStringContainsString(' /CS1 ', $out);
$this->assertStringNotContainsString(' /CS2 ', $out);
}
/**
* @throws \Throwable
*/
public function testPatternStreamResourcesHandleEmptyFontDictionary(): void
{
$obj = $this->getInternalTestObject();
/** @var \Com\Tecnick\Pdf\Encrypt\Encrypt $encrypt */
$encrypt = $this->getObjectProperty($obj, 'encrypt');
$outfont = new \Com\Tecnick\Pdf\Font\Output([], 0, $encrypt);
$this->setObjectProperty($obj, 'outfont', $outfont);
$out = $obj->exposeGetPatternStreamResourceDict('/F99 10 Tf');
$this->assertSame('', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutPatternsSkipsEntriesWithEmptyStreamData(): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'patterns', [
1 => [
'n' => 0,
'outdata' => '',
'bbox' => [0.0, 0.0, 10.0, 10.0],
'xstep' => 10.0,
'ystep' => 10.0,
'matrix' => [1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
],
]);
$out = $obj->exposeGetOutPatterns();
$this->assertSame('', $out);
}
/**
* @throws \Throwable
*/
public function testPatternStreamResourcesFilterMixedCategoriesByStreamUsage(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
/** @var \Com\Tecnick\Pdf\Font\Stack $font */
$font = $this->getObjectProperty($obj, 'font');
/** @var int $pon */
$pon = $this->getObjectProperty($obj, 'pon');
/** @var \Com\Tecnick\Pdf\Encrypt\Encrypt $encrypt */
$encrypt = $this->getObjectProperty($obj, 'encrypt');
/** @var \Com\Tecnick\Color\Pdf $color */
$color = $this->getObjectProperty($obj, 'color');
/** @var \Com\Tecnick\Pdf\Graph\Draw $graph */
$graph = $this->getObjectProperty($obj, 'graph');
$timesfile = (string) \realpath(__DIR__ . '/../vendor/tecnickcom/tc-lib-pdf-font/target/fonts/core/times.json');
$font->insert($pon, 'times', '', 10, null, null, $timesfile);
$outfont = new \Com\Tecnick\Pdf\Font\Output($font->getFonts(), $pon, $encrypt);
$this->setObjectProperty($obj, 'outfont', $outfont);
$this->setObjectProperty($color, 'spot_colors', [
'spotA' => ['i' => 1, 'n' => 111],
'spotB' => ['i' => 2, 'n' => 222],
]);
// Register at least two ExtGState entries so we can verify selective inclusion.
$graph->getAlpha(0.9);
$graph->getAlpha(0.5);
$this->setObjectProperty($obj, 'xobjects', [
'XO1' => ['n' => 201],
'XO2' => ['n' => 202],
]);
$stream = '/F1 10 Tf /CS1 cs /GS2 gs /XO2 Do';
$out = $obj->exposeGetPatternStreamResourceDict($stream);
$this->assertStringContainsString(' /Font <<', $out);
$this->assertStringContainsString(' /F1 ', $out);
$this->assertStringNotContainsString(' /F2 ', $out);
$this->assertStringContainsString(' /ColorSpace <<', $out);
$this->assertStringContainsString(' /CS1 ', $out);
$this->assertStringNotContainsString(' /CS2 ', $out);
$this->assertStringContainsString(' /ExtGState <<', $out);
$this->assertStringContainsString(' /GS2 ', $out);
$this->assertStringNotContainsString(' /GS1 ', $out);
$this->assertStringContainsString(' /XObject <<', $out);
$this->assertStringContainsString(' /XO2 202 0 R', $out);
$this->assertStringNotContainsString(' /XO1 201 0 R', $out);
}
/**
* @throws \Throwable
*/
public function testPatternStreamResourcesParseGradientAndImageDoTokens(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
/** @var \Com\Tecnick\Pdf\Font\Stack $font */
$font = $this->getObjectProperty($obj, 'font');
/** @var int $pon */
$pon = $this->getObjectProperty($obj, 'pon');
/** @var \Com\Tecnick\Pdf\Encrypt\Encrypt $encrypt */
$encrypt = $this->getObjectProperty($obj, 'encrypt');
/** @var \Com\Tecnick\Pdf\Graph\Draw $graph */
$graph = $this->getObjectProperty($obj, 'graph');
$outfont = new \Com\Tecnick\Pdf\Font\Output($font->getFonts(), $pon, $encrypt);
$this->setObjectProperty($obj, 'outfont', $outfont);
$graph->getLinearGradient(0.0, 0.0, 10.0, 10.0, 'red', 'blue');
$graph->getOutGradientShaders($pon);
$gradientId = $graph->getLastGradientID();
$this->assertNotNull($gradientId);
$out = $obj->exposeGetPatternStreamResourceDict('/Sh' . $gradientId . ' sh /I2 Do');
$this->assertStringContainsString(' /Pattern <<', $out);
$this->assertStringContainsString(' /XObject <<', $out);
}
/**
* @throws \Throwable
*/
public function testImplementedAnnotationSubtypeHelpersReturnExpectedFragments(): void
{
$obj = $this->getInternalTestObject();
$lineOut = $obj->exposeGetOutAnnotationOptSubtypeLine([
'opt' => [
'l' => [1.0, 2.0, 3.0, 4.0],
'le' => ['OpenArrow', 'ClosedArrow'],
'ic' => [0.1, 0.2, 0.3],
'cap' => true,
'it' => 'LineDimension',
'cp' => 'Inline',
'co' => [1.5, 2.5],
],
]);
$this->assertStringContainsString(' /L [', $lineOut);
$this->assertStringContainsString(' /LE [/OpenArrow /ClosedArrow]', $lineOut);
$this->assertStringContainsString(' /IC [0.100000 0.200000 0.300000]', $lineOut);
$this->assertStringContainsString(' /Cap true', $lineOut);
$this->assertStringContainsString(' /IT /LineDimension', $lineOut);
$this->assertStringContainsString(' /CP /Inline', $lineOut);
$this->assertStringContainsString(' /CO [', $lineOut);
$squareOut = $obj->exposeGetOutAnnotationOptSubtypeSquare([
'opt' => [
'ic' => [0.2, 0.3, 0.4],
'rd' => [1.0, 2.0, 3.0, 4.0],
],
]);
$this->assertStringContainsString(' /IC [0.200000 0.300000 0.400000]', $squareOut);
$this->assertStringContainsString(' /RD [', $squareOut);
$circleOut = $obj->exposeGetOutAnnotationOptSubtypeCircle([
'opt' => [
'ic' => [0.4, 0.5, 0.6],
'rd' => [1.0, 2.0, 3.0, 4.0],
],
]);
$this->assertStringContainsString(' /IC [0.400000 0.500000 0.600000]', $circleOut);
$this->assertStringContainsString(' /RD [', $circleOut);
$polygonOut = $obj->exposeGetOutAnnotationOptSubtypePolygon([
'opt' => [
'vertices' => [1.0, 2.0, 3.0, 4.0, 5.0, 6.0],
'ic' => [0.1, 0.1, 0.1],
'it' => 'PolygonCloud',
],
]);
$this->assertStringContainsString(' /Vertices [', $polygonOut);
$this->assertStringContainsString(' /IC [0.100000 0.100000 0.100000]', $polygonOut);
$this->assertStringContainsString(' /IT /PolygonCloud', $polygonOut);
$polylineOut = $obj->exposeGetOutAnnotationOptSubtypePolyline([
'opt' => [
'vertices' => [1.0, 2.0, 3.0, 4.0],
'le' => ['Circle', 'Slash'],
],
]);
$this->assertStringContainsString(' /Vertices [', $polylineOut);
$this->assertStringContainsString(' /LE [/Circle /Slash]', $polylineOut);
$quad = [[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]];
$this->assertStringContainsString(' /QuadPoints [', $obj->exposeGetOutAnnotationOptSubtypeHighlight(['opt' => [
'quadpoints' => $quad,
]]));
$this->assertStringContainsString(' /QuadPoints [', $obj->exposeGetOutAnnotationOptSubtypeUnderline(['opt' => [
'quadpoints' => $quad,
]]));
$this->assertStringContainsString(' /QuadPoints [', $obj->exposeGetOutAnnotationOptSubtypeSquiggly(['opt' => [
'quadpoints' => $quad,
]]));
$this->assertStringContainsString(' /QuadPoints [', $obj->exposeGetOutAnnotationOptSubtypeStrikeout(['opt' => [
'quadpoints' => $quad,
]]));
$stampOut = $obj->exposeGetOutAnnotationOptSubtypeStamp(['opt' => ['name' => 'Approved']]);
$this->assertSame(' /Name /Approved', $stampOut);
$caretOut = $obj->exposeGetOutAnnotationOptSubtypeCaret(['opt' => ['rd' => [1.0, 2.0, 3.0, 4.0], 'sy' => 'P']]);
$this->assertStringContainsString(' /RD [', $caretOut);
$this->assertStringContainsString(' /Sy /P', $caretOut);
$inkOut = $obj->exposeGetOutAnnotationOptSubtypeInk([
'opt' => [
'inklist' => [
[1.0, 2.0, 3.0, 4.0],
[5.0, 6.0, 7.0, 8.0],
],
],
]);
$this->assertStringContainsString(' /InkList [[', $inkOut);
$popupOut = $obj->exposeGetOutAnnotationOptSubtypePopup(['opt' => ['parent' => ['n' => 9], 'open' => true]]);
$this->assertStringContainsString(' /Parent 9 0 R', $popupOut);
$this->assertStringContainsString(' /Open true', $popupOut);
}
/**
* @throws \Throwable
*/
public function testAdvancedAnnotationSubtypeHelpersReturnExpectedFragments(): void
{
$obj = $this->getInternalTestObject();
$movieOut = $obj->exposeGetOutAnnotationOptSubtypeMovie([
'n' => 11,
'opt' => [
't' => 'Movie title',
'movie' => [
'f' => 'clip.mov',
'aspect' => [16.0, 9.0],
'rotate' => 90,
'poster' => true,
],
'a' => [
'rate' => 1.25,
'volume' => 0.8,
'showcontrols' => true,
'mode' => 'Repeat',
'synchronous' => false,
],
],
]);
$this->assertStringContainsString(' /T ', $movieOut);
$this->assertStringContainsString(' /Movie << /F ', $movieOut);
$this->assertStringContainsString(' /Aspect [16.000000 9.000000]', $movieOut);
$this->assertStringContainsString(' /Rotate 90', $movieOut);
$this->assertStringContainsString(' /Poster true', $movieOut);
$this->assertStringContainsString(' /A <<', $movieOut);
$this->assertStringContainsString(' /Rate 1.250000', $movieOut);
$this->assertStringContainsString(' /Volume 0.800000', $movieOut);
$this->assertStringContainsString(' /ShowControls true', $movieOut);
$this->assertStringContainsString(' /Mode /Repeat', $movieOut);
$this->assertStringContainsString(' /Synchronous false', $movieOut);
$screenOut = $obj->exposeGetOutAnnotationOptSubtypeScreen([
'n' => 12,
'opt' => [
't' => 'Screen title',
'mk' => [
'r' => 180,
'bc' => [0.1, 0.2, 0.3],
'bg' => [0.7],
'ca' => '(CA)',
'rc' => '(RC)',
'ac' => '(AC)',
'tp' => 4,
],
'a' => '/S /Named /N /NextPage',
'aa' => '/E << /S /Named /N /PrevPage >>',
],
]);
$this->assertStringContainsString(' /T ', $screenOut);
$this->assertStringContainsString(' /MK <<', $screenOut);
$this->assertStringContainsString(' /R 180', $screenOut);
$this->assertStringContainsString(' /BC [0.100000 0.200000 0.300000]', $screenOut);
$this->assertStringContainsString(' /BG [0.700000]', $screenOut);
$this->assertStringContainsString(' /TP 4', $screenOut);
$this->assertStringContainsString(' /A << /S /Named /N /NextPage >>', $screenOut);
$this->assertStringContainsString(' /AA << /E << /S /Named /N /PrevPage >> >>', $screenOut);
$redactOut = $obj->exposeGetOutAnnotationOptSubtypeRedact([
'n' => 13,
'opt' => [
'quadpoints' => [[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]],
'ic' => [0.0, 0.0, 0.0],
'overlaytext' => 'REDACTED',
'repeat' => true,
'da' => '/F1 10 Tf',
'q' => 2,
],
]);
$this->assertStringContainsString(' /QuadPoints [', $redactOut);
$this->assertStringContainsString(' /IC [0.000000 0.000000 0.000000]', $redactOut);
$this->assertStringContainsString(' /OverlayText ', $redactOut);
$this->assertStringContainsString(' /Repeat true', $redactOut);
$this->assertStringContainsString(' /DA ', $redactOut);
$this->assertStringContainsString(' /Q 2', $redactOut);
$watermarkOut = $obj->exposeGetOutAnnotationOptSubtypeWatermark([
'opt' => [
'fixedprint' => [
'type' => 'FixedPrint',
'matrix' => [1.0, 0.0, 0.0, 1.0, 10.0, 20.0],
'h' => 0.25,
'v' => 0.75,
],
],
]);
$this->assertStringContainsString(' /FixedPrint <<', $watermarkOut);
$this->assertStringContainsString(' /Type /FixedPrint', $watermarkOut);
$this->assertStringContainsString(
' /Matrix [1.000000 0.000000 0.000000 1.000000 10.000000 20.000000]',
$watermarkOut,
);
$this->assertStringContainsString(' /H 0.250000', $watermarkOut);
$this->assertStringContainsString(' /V 0.750000', $watermarkOut);
}
/**
* @throws \Throwable
*/
public function testLineAndQuadpointAnnotationHelpersIgnoreInvalidValues(): void
{
$obj = $this->getInternalTestObject();
$lineOut = $obj->exposeGetOutAnnotationOptSubtypeLine([
'opt' => [
'l' => ['x', 'y', 'z', 'w'],
'le' => [1, 2],
'co' => ['left', 'right'],
],
]);
$this->assertStringNotContainsString(' /L [', $lineOut);
$this->assertStringNotContainsString(' /LE [', $lineOut);
$this->assertStringNotContainsString(' /CO [', $lineOut);
$lineBadStylesOut = $obj->exposeGetOutAnnotationOptSubtypeLine([
'opt' => [
'le' => ['BadStyle', 'AlsoBad'],
],
]);
$this->assertStringNotContainsString(' /LE [', $lineBadStylesOut);
$highlightOut = $obj->exposeGetOutAnnotationOptSubtypeHighlight([
'opt' => [
'quadpoints' => [
'not-an-array',
[1.0, 2.0, 3.0],
],
],
]);
$this->assertSame('', $highlightOut);
}
/**
* @throws \Throwable
*/
public function testMovieSubtypeSupportsAdditionalPosterAndActionVariants(): void
{
$obj = $this->getInternalTestObject();
$movieBoolActionOut = $obj->exposeGetOutAnnotationOptSubtypeMovie([
'n' => 21,
'opt' => [
'movie' => [
'f' => 'clip.mov',
'poster' => '(poster-name)',
],
'a' => true,
],
]);
$this->assertStringContainsString(' /Poster ', $movieBoolActionOut);
$this->assertStringContainsString(' /A true', $movieBoolActionOut);
$movieActionOut = $obj->exposeGetOutAnnotationOptSubtypeMovie([
'n' => 22,
'opt' => [
'movie' => ['f' => 'clip2.mov'],
'a' => [
'start' => ['chapter-1', 5],
'duration' => ['segment-a', 2],
'fwscale' => [2, 3],
],
],
]);
$this->assertStringContainsString(' /Start [', $movieActionOut);
$this->assertStringContainsString(' /Duration [', $movieActionOut);
$this->assertStringContainsString(' /FWScale [2 3]', $movieActionOut);
}
/**
* @throws \Throwable
*/
public function testLineSubtypeIncludesExtendedLineAndMeasureOptions(): void
{
$obj = $this->getInternalTestObject();
$lineOut = $obj->exposeGetOutAnnotationOptSubtypeLine([
'opt' => [
'll' => 1.25,
'lle' => 2.5,
'llo' => 3.75,
'measure' => [
'type' => 'Measure',
'subtype' => 'RL',
],
],
]);
$this->assertStringContainsString(' /LL 1.250000', $lineOut);
$this->assertStringContainsString(' /LLE 2.500000', $lineOut);
$this->assertStringContainsString(' /LLO 3.750000', $lineOut);
$this->assertStringContainsString(' /Measure << /Type /Measure /Subtype /RL >>', $lineOut);
}
/**
* @throws \Throwable
*/
public function testPolygonAndInkSubtypeAdditionalBranches(): void
{
$obj = $this->getInternalTestObject();
$polygonOut = $obj->exposeGetOutAnnotationOptSubtypePolygon([
'opt' => [
'vertices' => [1.0, 2.0, 3.0, 4.0],
'measure' => [
'type' => 'Measure',
'subtype' => 'RL',
],
],
]);
$this->assertStringContainsString(' /Vertices [', $polygonOut);
$this->assertStringContainsString(' /Measure << /Type /Measure /Subtype /RL >>', $polygonOut);
$inkOut = $obj->exposeGetOutAnnotationOptSubtypeInk([
'opt' => [
'inklist' => [
'bad-line',
[1.0, 2.0, 3.0],
],
],
]);
$this->assertSame('', $inkOut);
}
/**
* @throws \Throwable
*/
public function testMovieSubtypeCoversStartDurationAndFwPositionVariants(): void
{
$obj = $this->getInternalTestObject();
$numericOut = $obj->exposeGetOutAnnotationOptSubtypeMovie([
'n' => 23,
'opt' => [
'movie' => ['f' => 'clip3.mov'],
'a' => [
'start' => 9,
'duration' => 7,
],
],
]);
$this->assertStringContainsString(' /Start 9', $numericOut);
$this->assertStringContainsString(' /Duration 7', $numericOut);
$stringOut = $obj->exposeGetOutAnnotationOptSubtypeMovie([
'n' => 24,
'opt' => [
'movie' => ['f' => 'clip4.mov'],
'a' => [
'start' => 'chapter-a',
'duration' => 'segment-b',
],
],
]);
$this->assertStringContainsString(' /Start ', $stringOut);
$this->assertStringContainsString(' /Duration ', $stringOut);
$arrayNumericOut = $obj->exposeGetOutAnnotationOptSubtypeMovie([
'n' => 25,
'opt' => [
'movie' => ['f' => 'clip5.mov'],
'a' => [
'start' => [3, 1],
'duration' => [4, 2],
'fwposition' => [0.25, 0.75],
],
],
]);
$this->assertStringContainsString(' /Start [3 1]', $arrayNumericOut);
$this->assertStringContainsString(' /Duration [4 2]', $arrayNumericOut);
$this->assertStringContainsString(' /FWPosition [0.250000 0.750000]', $arrayNumericOut);
}
/**
* @throws \Throwable
*/
public function testIntentionallyUnsupportedAdvancedSubtypeHelpersReturnEmptyString(): void
{
$obj = $this->getInternalTestObject();
$this->assertSame('', $obj->exposeGetOutAnnotationOptSubtypePrintermark([]));
$this->assertSame('', $obj->exposeGetOutAnnotationOptSubtypeTrapnet([]));
$this->assertSame('', $obj->exposeGetOutAnnotationOptSubtype3D([]));
}
/**
* @throws \Throwable
*/
public function testGetAnnotationRadioButtonsReturnsEmptyWhenNoKids(): void
{
$obj = $this->getInternalTestObject();
$out = $obj->exposeGetAnnotationRadioButtons(['txt' => 'missing', 'opt' => []]);
$this->assertSame('', $out);
}
/**
* @throws \Throwable
*/
public function testGetAnnotationAppearanceStreamWithoutApReturnsOnlyAsFlag(): void
{
$obj = $this->getInternalTestObject();
[$aas, $apx] = $obj->exposeGetAnnotationAppearanceStream(['opt' => ['as' => 'On']], 10.0, 5.0);
$this->assertSame(' /AS /On', $aas);
$this->assertSame('', $apx);
}
/**
* @throws \Throwable
*/
public function testGetOutAnnotationMarkupsReturnsMarkupFieldsForTextSubtype(): void
{
$obj = $this->getInternalTestObject();
$annot = [
'opt' => [
'subtype' => 'text',
't' => 'Author',
'ca' => 0.5,
'rc' => 'Reply',
'subj' => 'Topic',
],
];
$out = $obj->exposeGetOutAnnotationMarkups($annot, 10);
$this->assertStringContainsString(' /T ', $out);
$this->assertStringContainsString(' /CA 0.500000', $out);
$this->assertStringContainsString(' /CreationDate ', $out);
$this->assertStringContainsString(' /Subj ', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutAnnotationOptSubtypeTextReturnsDefaultsAndState(): void
{
$obj = $this->getInternalTestObject();
$out = $obj->exposeGetOutAnnotationOptSubtypeText(['opt' => [
'open' => true,
'statemodel' => 'Marked',
'state' => 'Accepted',
]]);
$this->assertStringContainsString(' /Open true', $out);
$this->assertStringContainsString(' /Name /Note', $out);
$this->assertStringContainsString(' /StateModel /Marked', $out);
$this->assertStringContainsString(' /State /Accepted', $out);
}
/**
* @param array<string, mixed> $annot
* @param list<string> $expectedFragments
* @throws \Throwable
*/
#[DataProvider('annotationSubtypeLinkSimpleProvider')]
public function testGetOutAnnotationOptSubtypeLinkSimpleTargets(
array $annot,
int $apid,
int $oid,
array $expectedFragments,
): void {
$obj = $this->getInternalTestObject();
$out = $obj->exposeGetOutAnnotationOptSubtypeLink($annot, $apid, $oid);
foreach ($expectedFragments as $fragment) {
$this->assertIsString($fragment);
$this->assertStringContainsString($fragment, $out);
}
}
/**
* @throws \Throwable
*/
public function testGetOutAnnotationOptSubtypeLinkHandlesInternalAndEmbeddedTargets(): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'embeddedfiles', [
'sample.pdf' => ['a' => 4],
'attach.bin' => ['a' => 2],
]);
$namedDest = $obj->exposeGetOutAnnotationOptSubtypeLink(['txt' => '#dest-1', 'opt' => ['h' => 'N']], 2, 10);
$embeddedPdf = $obj->exposeGetOutAnnotationOptSubtypeLink(['txt' => '%sample.pdf', 'opt' => []], 3, 11);
$embeddedFile = $obj->exposeGetOutAnnotationOptSubtypeLink(['txt' => '*attach.bin', 'opt' => []], 1, 12);
$this->assertStringContainsString(' /S /GoTo /D /dest-1', $namedDest);
$this->assertStringContainsString(' /H /N', $namedDest);
$this->assertStringContainsString(' /S /GoToE', $embeddedPdf);
$this->assertStringContainsString(' /P 2 /A 4', $embeddedPdf);
$this->assertStringContainsString(' /S /JavaScript /JS ', $embeddedFile);
$this->assertStringContainsString(' /H /I', $embeddedFile);
}
/**
* @throws \Throwable
*/
public function testGetOutAnnotationOptSubtypeLinkSkipsEmbeddedFileJavascriptInPdfuaMode(): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'pdfuaMode', 'pdfua2');
$this->setObjectProperty($obj, 'embeddedfiles', [
'attach.bin' => ['a' => 2],
]);
$embeddedFile = $obj->exposeGetOutAnnotationOptSubtypeLink(['txt' => '*attach.bin', 'opt' => []], 1, 12);
$this->assertStringNotContainsString(' /S /JavaScript /JS ', $embeddedFile);
$this->assertStringContainsString(' /H /I', $embeddedFile);
}
/**
* @throws \Throwable
*/
public function testGetOutAnnotationOptSubtypeLinkSkipsEmbeddedFileJavascriptInPdfxMode(): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'pdfx', true);
$this->setObjectProperty($obj, 'embeddedfiles', [
'attach.bin' => ['a' => 2],
]);
$embeddedFile = $obj->exposeGetOutAnnotationOptSubtypeLink(['txt' => '*attach.bin', 'opt' => []], 1, 12);
$this->assertStringNotContainsString(' /S /JavaScript /JS ', $embeddedFile);
$this->assertStringContainsString(' /H /I', $embeddedFile);
}
/**
* @throws \Throwable
*/
public function testGetOutAnnotationOptSubtypeLinkSuppressesGotoeAndGotorInPdfxMode(): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'pdfx', true);
$this->setObjectProperty($obj, 'embeddedfiles', [
'sample.pdf' => ['a' => 4],
]);
$namedDest = $obj->exposeGetOutAnnotationOptSubtypeLink(['txt' => '#dest-1', 'opt' => ['h' => 'N']], 2, 10);
$externalUri = $obj->exposeGetOutAnnotationOptSubtypeLink(['txt' => 'https://example.com', 'opt' => []], 2, 11);
$embeddedPdf = $obj->exposeGetOutAnnotationOptSubtypeLink(['txt' => '%sample.pdf', 'opt' => []], 2, 12);
$relativePdf = $obj->exposeGetOutAnnotationOptSubtypeLink(
['txt' => 'docs/guide.pdf#named=Section2', 'opt' => []],
2,
13,
);
$this->assertStringContainsString(' /S /GoTo /D /dest-1', $namedDest);
$this->assertStringContainsString(' /H /N', $namedDest);
$this->assertStringNotContainsString(' /S /URI /URI ', $externalUri);
$this->assertStringNotContainsString(' /A <<', $externalUri);
$this->assertStringContainsString(' /H /I', $externalUri);
$this->assertStringNotContainsString(' /S /GoToE', $embeddedPdf);
$this->assertStringNotContainsString(' /A <<', $embeddedPdf);
$this->assertStringContainsString(' /H /I', $embeddedPdf);
$this->assertStringNotContainsString(' /S /GoToR', $relativePdf);
$this->assertStringNotContainsString(' /A <<', $relativePdf);
$this->assertStringContainsString(' /H /I', $relativePdf);
}
/**
* @throws \Throwable
*/
public function testGetOutAnnotationOptSubtypeLinkSuppressesExternalUriInPdfxMode(): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'pdfx', true);
$externalUri = $obj->exposeGetOutAnnotationOptSubtypeLink(
['txt' => 'https://example.com/docs?a=1&b=2', 'opt' => []],
2,
11,
);
$this->assertStringNotContainsString(' /S /URI', $externalUri);
$this->assertStringNotContainsString(' /A <<', $externalUri);
$this->assertStringContainsString(' /H /I', $externalUri);
}
/**
* @throws \Throwable
*/
#[DataProvider('pdfxModeProvider')]
public function testPdfxModeMatrixSuppressesInteractiveLinkActions(string $mode): void
{
$obj = new TestableOutput('mm', true, false, true, $mode);
$this->setObjectProperty($obj, 'embeddedfiles', [
'sample.pdf' => ['a' => 4],
]);
$namedDest = $obj->exposeGetOutAnnotationOptSubtypeLink(['txt' => '#dest-1', 'opt' => ['h' => 'N']], 2, 10);
$externalUri = $obj->exposeGetOutAnnotationOptSubtypeLink(['txt' => 'https://example.com', 'opt' => []], 2, 11);
$embeddedPdf = $obj->exposeGetOutAnnotationOptSubtypeLink(['txt' => '%sample.pdf', 'opt' => []], 2, 12);
$relativePdf = $obj->exposeGetOutAnnotationOptSubtypeLink(
['txt' => 'docs/guide.pdf#named=Section2', 'opt' => []],
2,
13,
);
$this->assertStringContainsString(' /S /GoTo /D /dest-1', $namedDest);
$this->assertStringContainsString(' /H /N', $namedDest);
$this->assertStringNotContainsString(' /S /URI', $externalUri);
$this->assertStringNotContainsString(' /A <<', $externalUri);
$this->assertStringContainsString(' /H /I', $externalUri);
$this->assertStringNotContainsString(' /S /GoToE', $embeddedPdf);
$this->assertStringNotContainsString(' /A <<', $embeddedPdf);
$this->assertStringContainsString(' /H /I', $embeddedPdf);
$this->assertStringNotContainsString(' /S /GoToR', $relativePdf);
$this->assertStringNotContainsString(' /A <<', $relativePdf);
$this->assertStringContainsString(' /H /I', $relativePdf);
}
/**
* @throws \Throwable
*/
public function testGetOutAnnotationOptSubtypeFreetextFormatsKnownOptions(): void
{
$obj = $this->getInternalTestObject();
$annot = [
'n' => 10,
'opt' => [
'da' => '/F1 10 Tf',
'q' => 2,
'it' => 'FreeText',
'rd' => [1, 2, 3, 4],
'le' => 'Square',
],
];
$out = $obj->exposeGetOutAnnotationOptSubtypeFreetext($annot, 10);
$this->assertStringContainsString(' /DA ', $out);
$this->assertStringContainsString(' /Q 2', $out);
$this->assertStringContainsString(' /IT /FreeText', $out);
$this->assertStringContainsString(' /RD [', $out);
$this->assertStringContainsString(' /LE /Square', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutAnnotationOptSubtypeFileattachmentHandlesPdfaAndEmbeddedFile(): void
{
$obj = $this->getInternalTestObject();
$annot = ['opt' => ['fs' => 'doc.txt', 'name' => 'Tag']];
$obj->setPdfaMode(1);
$this->assertSame('', $obj->exposeGetOutAnnotationOptSubtypeFileattachment($annot, 2));
$obj->setPdfaMode(0);
$this->setObjectProperty($obj, 'embeddedfiles', ['doc.txt' => ['f' => 7]]);
$out = $obj->exposeGetOutAnnotationOptSubtypeFileattachment($annot, 2);
$this->assertStringContainsString(' /FS 7 0 R', $out);
$this->assertStringContainsString(' /Name /Tag', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutAnnotationOptSubtypeSoundReturnsExpectedNameAndReference(): void
{
$obj = $this->getInternalTestObject();
$this->assertSame('', $obj->exposeGetOutAnnotationOptSubtypeSound(['opt' => []]));
$this->setObjectProperty($obj, 'embeddedfiles', ['snd.wav' => ['f' => 9]]);
$out = $obj->exposeGetOutAnnotationOptSubtypeSound(['opt' => ['fs' => 'snd.wav', 'name' => 'Mic']]);
$this->assertStringContainsString(' /Sound 9 0 R', $out);
$this->assertStringContainsString(' /Name /Mic', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutAnnotationOptSubtypeWidgetIncludesBasicWidgetEntries(): void
{
$obj = $this->getInternalTestObject();
$annot = [
'txt' => 'field-key',
'opt' => [
'h' => 'N',
't' => 'FieldName',
'q' => 1,
'ff' => [1, 2],
'maxlen' => 12,
'v' => 'abc',
'dv' => 'def',
'rv' => 'ghi',
'da' => '/F1 10 Tf',
],
];
$out = $obj->exposeGetOutAnnotationOptSubtypeWidget($annot, 10);
$this->assertStringContainsString(' /H /N', $out);
$this->assertStringContainsString(' /T ', $out);
$this->assertStringContainsString(' /Ff 3', $out);
$this->assertStringContainsString(' /MaxLen 12', $out);
$this->assertStringContainsString(' /Q 1', $out);
$this->assertStringContainsString(' /DA ', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutAnnotationOptSubtypeWidgetIncludesAppearanceAndChoiceOptions(): void
{
$obj = $this->getInternalTestObject();
$annot = [
'txt' => 'field-choice',
'opt' => [
'h' => 'T',
'ff' => 5,
'v' => ['One', 2],
'dv' => ['Two', 3],
'rv' => ['Three', 4],
'a' => '/S /ResetForm',
'aa' => '/E << /S /JavaScript /JS (x) >>',
'opt' => ['one', ['v2', 'Label Two'], 3],
'ti' => 1,
'i' => [0, 2, 'x'],
'mk' => [
'r' => 90,
'bc' => [0.1, 0.2, 0.3],
'bg' => [0.9],
'ca' => '(CA)',
'rc' => '(RC)',
'ac' => '(AC)',
'if' => [
'sw' => 'B',
's' => 'P',
'a' => [0.2, 0.8],
'fb' => true,
],
'tp' => 3,
],
],
];
$out = $obj->exposeGetOutAnnotationOptSubtypeWidget($annot, 31);
$this->assertStringContainsString(' /H /T', $out);
$this->assertStringContainsString(' /MK <<', $out);
$this->assertStringContainsString(' /R 90', $out);
$this->assertStringContainsString(' /IF << /SW /B /S /P /A [0.200000 0.800000] /FB true>>', $out);
$this->assertStringContainsString(' /TP 3', $out);
$this->assertStringContainsString(' /Ff 5', $out);
$this->assertStringContainsString(' /A << /S /ResetForm >>', $out);
$this->assertStringContainsString(' /AA << /E << /S /JavaScript /JS (x) >> >>', $out);
$this->assertStringContainsString(' /Opt [', $out);
$this->assertStringContainsString(' /TI 1', $out);
$this->assertStringContainsString(' /I [0 2 ]', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutAnnotationOptSubtypeWidgetOmitsJavascriptActionsInPdfuaMode(): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'pdfuaMode', 'pdfua1');
$annot = [
'txt' => 'field-choice',
'opt' => [
'a' => '/S /JavaScript /JS (alert)',
'aa' => '/E << /S /JavaScript /JS (x) >>',
'h' => 'T',
'ff' => 5,
],
];
$out = $obj->exposeGetOutAnnotationOptSubtypeWidget($annot, 31);
$this->assertStringNotContainsString(' /A << /S /JavaScript', $out);
$this->assertStringNotContainsString(' /AA << /E << /S /JavaScript', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutAnnotationOptSubtypeWidgetOmitsJavascriptActionsInPdfxMode(): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'pdfx', true);
$annot = [
'txt' => 'field-choice',
'opt' => [
'a' => '/S /JavaScript /JS (alert)',
'aa' => '/E << /S /JavaScript /JS (x) >>',
'h' => 'T',
'ff' => 5,
],
];
$out = $obj->exposeGetOutAnnotationOptSubtypeWidget($annot, 31);
$this->assertStringNotContainsString(' /A << /S /JavaScript', $out);
$this->assertStringNotContainsString(' /AA << /E << /S /JavaScript', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutAnnotationOptSubtypeWidgetOmitsNonJavascriptActionsInPdfxMode(): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'pdfx', true);
$annot = [
'txt' => 'field-choice',
'opt' => [
'a' => '/S /ResetForm',
'aa' => '/E << /S /URI /URI (https://example.test) >>',
'h' => 'T',
'ff' => 5,
],
];
$out = $obj->exposeGetOutAnnotationOptSubtypeWidget($annot, 31);
$this->assertStringNotContainsString(' /A << /S /ResetForm >>', $out);
$this->assertStringNotContainsString(' /AA << /E << /S /URI /URI', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutAnnotationOptSubtypeDispatcherRoutesKnownAndUnknownSubtypes(): void
{
$obj = $this->getInternalTestObject();
$textOut = $obj->exposeGetOutAnnotationOptSubtype(['opt' => ['subtype' => 'Text']], 1, 10, 0);
$unkOut = $obj->exposeGetOutAnnotationOptSubtype(['opt' => ['subtype' => 'Unknown']], 1, 10, 0);
$this->assertStringContainsString(' /Name /Note', $textOut);
$this->assertSame('', $unkOut);
}
/**
* @throws \Throwable
*/
public function testGetOutAnnotationOptSubtypeDispatcherCoversRemainingKnownSubtypes(): void
{
$obj = $this->getInternalTestObject();
$subtypes = [
'3D',
'Caret',
'Circle',
'FileAttachment',
'FreeText',
'Highlight',
'Ink',
'Line',
'Link',
'Movie',
'Polygon',
'Polyline',
'Popup',
'PrinterMark',
'Redact',
'Screen',
'Sound',
'Square',
'Squiggly',
'Stamp',
'StrikeOut',
'TrapNet',
'Underline',
'Watermark',
'Widget',
];
foreach ($subtypes as $subtype) {
$out = $obj->exposeGetOutAnnotationOptSubtype(['opt' => ['subtype' => $subtype]], 1, 10, 0);
$this->assertGreaterThanOrEqual(0, \strlen($out));
}
}
/**
* @throws \Throwable
*/
public function testGetOutPDFHeaderReturnsVersionedHeader(): void
{
$obj = $this->getInternalTestObject();
$out = $obj->exposeGetOutPDFHeader();
$this->assertStringStartsWith('%PDF-', $out);
$this->assertStringContainsString("%\xE2\xE3\xCF\xD3\n", $out);
}
/**
* @throws \Throwable
*/
public function testGetOutPDFBodyBuildsPageTreeResourcesAndCatalog(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$out = $obj->exposeGetOutPDFBody();
$this->assertStringContainsString('/Type /Pages', $out);
$this->assertStringContainsString('/Type /Catalog', $out);
$this->assertStringContainsString('/Type /Page', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutPDFBodyIncludesEncryptionObjectWhenEnabledAndNotPdfx(): void
{
$obj = $this->getInternalUncompressedTestObject();
$this->initFontAndPage($obj);
/** @var \Com\Tecnick\Pdf\Encrypt\Encrypt $encrypt */
$encrypt = $this->getObjectProperty($obj, 'encrypt');
$this->setEncryptData($encrypt, [
'encrypted' => true,
'Filter' => 'Standard',
'V' => 2,
'Length' => 40,
'P' => -4,
'O' => 'owner',
'U' => 'user',
]);
$out = $obj->exposeGetOutPDFBody();
$this->assertStringContainsString('/Filter /Standard', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutPDFBodyIncludesStructTreeRootInPdfuaMode(): void
{
$obj = $this->getInternalTestObject();
$page = $this->initFontAndPage($obj);
$this->setObjectProperty($obj, 'pdfuaMode', 'pdfua1');
$obj->addTextCell('Tagged PDF/UA text', $page['pid'], 10, 10, 60, 10);
$out = $obj->exposeGetOutPDFBody();
$this->assertStringContainsString('/StructParents 0', $out);
$this->assertMatchesRegularExpression('/\/Nums \[ 0 \[\s*\d+ 0 R\s*\]\s*\]/', $out);
$this->assertStringContainsString('/Type /StructTreeRoot', $out);
$this->assertStringContainsString('/ParentTree ', $out);
$this->assertStringContainsString('/ParentTreeNextKey 1', $out);
$this->assertStringContainsString('/K [ ', $out);
$this->assertStringContainsString('/Type /StructElem /S /Document', $out);
$this->assertStringContainsString('/Type /StructElem /S /P', $out);
$this->assertStringContainsString('/Type /MCR', $out);
$this->assertStringContainsString('/MCID 0', $out);
$this->assertMatchesRegularExpression('/\/Pg \d+ 0 R/', $out);
$this->assertStringContainsString('/StructTreeRoot ', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutPDFBodyWrapsFirstPageContentInMarkedContentForPdfuaMode(): void
{
$obj = $this->getInternalUncompressedTestObject();
$page = $this->initFontAndPage($obj);
$this->setObjectProperty($obj, 'pdfuaMode', 'pdfua1');
$obj->addTextCell('Tagged PDF/UA text', $page['pid'], 10, 10, 60, 10);
$out = $obj->exposeGetOutPDFBody();
$this->assertStringContainsString('/P <</MCID 0>> BDC', $out);
$this->assertStringContainsString('EMC', $out);
$this->assertStringContainsString('/Type /MCR', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutPDFBodyIncludesMultipleMcidsForMultipleTextCellsInPdfuaMode(): void
{
$obj = $this->getInternalTestObject();
$page = $this->initFontAndPage($obj);
$this->setObjectProperty($obj, 'pdfuaMode', 'pdfua1');
$obj->addTextCell('First PDF/UA text block', $page['pid'], 10, 10, 60, 10);
$obj->addTextCell('Second PDF/UA text block', $page['pid'], 10, 20, 60, 10);
$out = $obj->exposeGetOutPDFBody();
$this->assertStringContainsString('/MCID 0', $out);
$this->assertStringContainsString('/MCID 1', $out);
$this->assertMatchesRegularExpression('/\/Type \/StructElem \/S \/P.*\/MCID 0/s', $out);
$this->assertMatchesRegularExpression('/\/Type \/StructElem \/S \/P.*\/MCID 1/s', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutPDFBodyBracketedTextCellsShareOneStructElem(): void
{
$obj = $this->getInternalTestObject();
$page = $this->initFontAndPage($obj);
$this->setObjectProperty($obj, 'pdfuaMode', 'pdfua1');
$obj->beginStructElem('H1', $page['pid']);
$obj->addTextCell('Heading text', $page['pid'], 10, 10, 60, 10);
$obj->addTextCell('Continuation', $page['pid'], 10, 20, 60, 10);
$obj->endStructElem();
$out = $obj->exposeGetOutPDFBody();
// Both MCIDs should appear in the output.
$this->assertStringContainsString('/MCID 0', $out);
$this->assertStringContainsString('/MCID 1', $out);
// There should be exactly one H1 StructElem containing both MCID references.
$this->assertMatchesRegularExpression('/\/Type \/StructElem \/S \/H1.*\/MCID 0.*\/MCID 1/s', $out);
// There should be no separate P StructElem for these fragments.
$this->assertStringNotContainsString('/Type /StructElem /S /P', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutPDFBodyBracketedContentUsesStructRoleInBdcTag(): void
{
$obj = $this->getInternalUncompressedTestObject();
$page = $this->initFontAndPage($obj);
$this->setObjectProperty($obj, 'pdfuaMode', 'pdfua1');
$obj->beginStructElem('H2', $page['pid']);
$obj->addTextCell('Subheading', $page['pid'], 10, 10, 60, 10);
$obj->endStructElem();
$out = $obj->exposeGetOutPDFBody();
// BDC tag in the content stream must use the struct elem role, not the fallback /P.
$this->assertStringContainsString('/H2 <</MCID 0>> BDC', $out);
$this->assertStringNotContainsString('/P <</MCID 0>> BDC', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutPDFBodyTagsHtmlTextInPdfuaMode(): void
{
$obj = $this->getInternalUncompressedTestObject();
$this->initFontAndPage($obj);
$this->setObjectProperty($obj, 'pdfuaMode', 'pdfua1');
$obj->addHTMLCell('<h1>PDF/UA</h1><p>Mode: pdfua</p>', 10, 10, 80, 20);
$out = $obj->exposeGetOutPDFBody();
$this->assertStringContainsString('/Type /StructTreeRoot', $out);
$this->assertStringContainsString('/H1 <</MCID 0>> BDC', $out);
$this->assertStringContainsString('/P <</MCID 1>> BDC', $out);
$this->assertStringContainsString('/Type /StructElem /S /H1', $out);
$this->assertStringContainsString('/Type /StructElem /S /P', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutPDFBodyPreservesNestedStructElemHierarchy(): void
{
$obj = $this->getInternalUncompressedTestObject();
$page = $this->initFontAndPage($obj);
$this->setObjectProperty($obj, 'pdfuaMode', 'pdfua1');
$obj->beginStructElem('Sect', $page['pid']);
$obj->beginStructElem('H1', $page['pid']);
$obj->addTextCell('Nested heading', $page['pid'], 10, 10, 60, 10);
$obj->endStructElem();
$obj->beginStructElem('P', $page['pid']);
$obj->addTextCell('Nested paragraph', $page['pid'], 10, 20, 60, 10);
$obj->endStructElem();
$obj->endStructElem();
$out = $obj->exposeGetOutPDFBody();
$docMatch = [];
$this->assertSame(1, \preg_match(
'/(\d+) 0 obj\s*<< \/Type \/StructElem \/S \/Document .*?\/K \[\s*(\d+) 0 R\s*\] >>/s',
$out,
$docMatch,
));
assert(isset($docMatch[2]), "\$docMatch[2] must be set");
$sectOid = $docMatch[2];
$sectMatch = [];
assert(isset($docMatch[1]), "\$docMatch[1] must be set");
$this->assertSame(1, \preg_match(
'/'
. $sectOid
. ' 0 obj\\s*<< \/Type \/StructElem \/S \/Sect \/P '
. $docMatch[1]
. ' 0 R .*?\/K \[\s*(\d+) 0 R\s+(\d+) 0 R\s*\] >>/s',
$out,
$sectMatch,
));
assert(isset($sectMatch[1]), "\$sectMatch[1] must be set");
$this->assertMatchesRegularExpression(
'/' . $sectMatch[1] . ' 0 obj\\s*<< \/Type \/StructElem \/S \/H1 \/P ' . $sectOid . ' 0 R/s',
$out,
);
assert(isset($sectMatch[2]), "\$sectMatch[2] must be set");
$this->assertMatchesRegularExpression(
'/' . $sectMatch[2] . ' 0 obj\\s*<< \/Type \/StructElem \/S \/P \/P ' . $sectOid . ' 0 R/s',
$out,
);
}
/**
* @throws \Throwable
*/
public function testGetOutPDFBodyTagsHtmlImageAsFigureWithAltInPdfuaMode(): void
{
$obj = new TestableOutput('mm', true, false, false, 'pdfua1');
$this->initFontAndPage($obj);
$img = \imagecreate(4, 4);
\imagecolorallocate($img, 255, 255, 255);
\ob_start();
\imagepng($img);
$raw = \ob_get_clean();
$this->assertIsString($raw);
$src = 'data:image/png;base64,' . \base64_encode($raw);
$html = '<img src="' . $src . '" alt="Chart icon" width="4" height="4" />';
$htmlOut = $obj->getHTMLCell($html, 0, 0, 20, 10);
/** @var \Com\Tecnick\Pdf\Page\Page $page */
$page = $this->getObjectProperty($obj, 'page');
$page->addContent($htmlOut, $page->getPageId());
$out = $obj->exposeGetOutPDFBody();
$this->assertStringContainsString('/Figure <</MCID 0>> BDC', $out);
$this->assertStringContainsString('/Type /StructElem /S /Figure', $out);
$this->assertStringContainsString('/Alt ', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutPDFBodyTagsHtmlTableStructRolesInPdfuaMode(): void
{
$obj = new TestableOutput('mm', true, false, false, 'pdfua1');
$this->initFontAndPage($obj);
$html =
'<table><thead><tr><th>Name</th><th>Value</th></tr></thead>'
. '<tbody><tr><td>A</td><td>1</td></tr></tbody></table>';
$htmlOut = $obj->getHTMLCell($html, 0, 0, 80, 30);
/** @var \Com\Tecnick\Pdf\Page\Page $page */
$page = $this->getObjectProperty($obj, 'page');
$page->addContent($htmlOut, $page->getPageId());
$out = $obj->exposeGetOutPDFBody();
$this->assertStringContainsString('/Type /StructElem /S /Table', $out);
$this->assertStringContainsString('/Type /StructElem /S /TR', $out);
$this->assertStringContainsString('/Type /StructElem /S /TH', $out);
$this->assertStringContainsString('/Type /StructElem /S /TD', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutPDFBodyTagsHtmlListStructRolesInPdfuaMode(): void
{
$obj = new TestableOutput('mm', true, false, false, 'pdfua1');
$this->initFontAndPage($obj);
$html = '<ul><li>Alpha</li><li>Beta</li></ul>';
$htmlOut = $obj->getHTMLCell($html, 0, 0, 80, 30);
/** @var \Com\Tecnick\Pdf\Page\Page $page */
$page = $this->getObjectProperty($obj, 'page');
$page->addContent($htmlOut, $page->getPageId());
$out = $obj->exposeGetOutPDFBody();
$this->assertStringContainsString('/Type /StructElem /S /L', $out);
$this->assertStringContainsString('/Type /StructElem /S /LI', $out);
$this->assertStringContainsString('/Type /StructElem /S /LBody', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutPDFBodyTagsManualFigureContentWithAltInPdfuaMode(): void
{
$obj = new TestableOutput('mm', true, false, false, 'pdfua1');
$this->initFontAndPage($obj);
/** @var \Com\Tecnick\Pdf\Page\Page $page */
$page = $this->getObjectProperty($obj, 'page');
$pid = $page->getPageId();
$style = [
'all' => [
'lineWidth' => 0.3,
'lineCap' => 'butt',
'lineJoin' => 'miter',
'dashArray' => [],
'dashPhase' => 0,
'lineColor' => '#336699',
'fillColor' => '#cce0ff',
],
];
$obj->addTaggedFigureContent(
$obj->graph->getRect(10.0, 10.0, 12.0, 8.0, 'DF', $style),
$pid,
'Blue rectangle sample figure',
);
$out = $obj->exposeGetOutPDFBody();
$this->assertStringContainsString('/Figure <</MCID 0>> BDC', $out);
$this->assertStringContainsString('/Type /StructElem /S /Figure', $out);
$this->assertStringContainsString('/Alt ', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutPDFBodyTagsHtmlFigureWithFigcaptionInPdfuaMode(): void
{
$obj = new TestableOutput('mm', true, false, false, 'pdfua1');
$this->initFontAndPage($obj);
$img = \imagecreate(4, 4);
\imagecolorallocate($img, 255, 255, 255);
\ob_start();
\imagepng($img);
$raw = \ob_get_clean();
$this->assertIsString($raw);
$src = 'data:image/png;base64,' . \base64_encode($raw);
$html =
'<figure><img src="'
. $src
. '" alt="Test dot" width="4" height="4" />'
. '<figcaption>Caption text</figcaption></figure>';
$htmlOut = $obj->getHTMLCell($html, 0, 0, 80, 30);
/** @var \Com\Tecnick\Pdf\Page\Page $page */
$page = $this->getObjectProperty($obj, 'page');
$page->addContent($htmlOut, $page->getPageId());
$out = $obj->exposeGetOutPDFBody();
// Single Figure struct elem (no nested Figure > Figure)
$this->assertSame(
1,
\substr_count($out, '/Type /StructElem /S /Figure'),
'Expected exactly one Figure StructElem',
);
$this->assertStringContainsString('/Alt ', $out);
$this->assertStringContainsString('/Type /StructElem /S /Caption', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutPDFBodySerializesStructElemAttributes(): void
{
$obj = $this->getInternalUncompressedTestObject();
$page = $this->initFontAndPage($obj);
$this->setObjectProperty($obj, 'pdfuaMode', 'pdfua1');
$obj->beginStructElem('TH', $page['pid'], null, ['Scope' => 'Column']);
$obj->addTextCell('Header', $page['pid'], 10, 10, 40, 10);
$obj->endStructElem();
$out = $obj->exposeGetOutPDFBody();
$this->assertStringContainsString('/Type /StructElem /S /TH', $out);
$this->assertStringContainsString('/A << /Scope /Column >>', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutPDFBodySerializesFigureBoundingBox(): void
{
$obj = $this->getInternalUncompressedTestObject();
$page = $this->initFontAndPage($obj);
$this->setObjectProperty($obj, 'pdfuaMode', 'pdfua1');
$obj->addTaggedFigureContent("q\nQ\n", $page['pid'], 'Boxed figure', [10.0, 20.0, 110.0, 70.0]);
$out = $obj->exposeGetOutPDFBody();
$this->assertStringContainsString('/Type /StructElem /S /Figure', $out);
$this->assertStringContainsString('/A << /O /Layout /BBox [10.000000 20.000000 110.000000 70.000000] >>', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutPDFBodySerializesTableBoundingBox(): void
{
$obj = $this->getInternalUncompressedTestObject();
$this->initFontAndPage($obj);
$this->setObjectProperty($obj, 'pdfuaMode', 'pdfua1');
$html = '<table border="1"><tr><th>A</th><th>B</th></tr><tr><td>1</td><td>2</td></tr></table>';
$htmlOut = $obj->getHTMLCell($html, 15, 20, 100, 0);
/** @var \Com\Tecnick\Pdf\Page\Page $page */
$page = $this->getObjectProperty($obj, 'page');
$page->addContent($htmlOut, $page->getPageId());
$out = $obj->exposeGetOutPDFBody();
$this->assertMatchesRegularExpression(
'#/Type /StructElem /S /Table .*?/A << /O /Layout /BBox \[[0-9. ]+\] >>#s',
$out,
);
}
/**
* @throws \Throwable
*/
public function testGetOutPDFBodySkipsTableBoundingBoxAcrossPageBreak(): void
{
$obj = $this->getInternalUncompressedTestObject();
$this->initFontAndPage($obj);
$this->setObjectProperty($obj, 'pdfuaMode', 'pdfua1');
$rows = '';
for ($idx = 0; $idx < 120; ++$idx) {
$rows .= '<tr><td>row ' . $idx . '</td><td>value ' . $idx . '</td></tr>';
}
$htmlOut = $obj->getHTMLCell('<table border="1">' . $rows . '</table>', 15, 20, 100, 0);
/** @var \Com\Tecnick\Pdf\Page\Page $page */
$page = $this->getObjectProperty($obj, 'page');
$page->addContent($htmlOut, $page->getPageId());
$out = $obj->exposeGetOutPDFBody();
$this->assertStringContainsString('/Type /StructElem /S /Table', $out);
$this->assertDoesNotMatchRegularExpression('#/Type /StructElem /S /Table .*?/BBox#s', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutPDFBodySkipsFigureBoundingBoxAcrossPageBreak(): void
{
$obj = $this->getInternalUncompressedTestObject();
$page = $this->initFontAndPage($obj);
$this->setObjectProperty($obj, 'pdfuaMode', 'pdfua1');
$secondPage = $obj->addPage();
if (!isset($secondPage['pid']) || !\is_int($secondPage['pid'])) {
$this->fail('Expected addPage to return an integer pid');
}
$secondPid = $secondPage['pid'];
// A single Figure holding content emitted on two different pages.
$obj->beginStructElem('Figure', $page['pid'], 'Split figure');
$obj->addTaggedFigureContent("q\nQ\n", $page['pid'], '', [10.0, 20.0, 110.0, 70.0]);
$obj->addTaggedFigureContent("q\nQ\n", $secondPid, '', [10.0, 20.0, 110.0, 70.0]);
$obj->endStructElem();
$out = $obj->exposeGetOutPDFBody();
$this->assertStringContainsString('/Type /StructElem /S /Figure', $out);
$this->assertStringNotContainsString('/BBox [10.000000 20.000000 110.000000 70.000000]', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutPDFBodyPromotesStructElemIdAttributeToIdEntry(): void
{
$obj = $this->getInternalUncompressedTestObject();
$page = $this->initFontAndPage($obj);
$this->setObjectProperty($obj, 'pdfuaMode', 'pdfua1');
$obj->beginStructElem('Note', $page['pid'], null, ['ID' => 'note-1']);
$obj->addTextCell('Note content', $page['pid'], 10, 10, 40, 10);
$obj->endStructElem();
$out = $obj->exposeGetOutPDFBody();
$this->assertMatchesRegularExpression('/\/Type \/StructElem \/S \/Note.*\/ID /s', $out);
$this->assertStringNotContainsString('/A << /ID /note-1 >>', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutPDFBodyTagsHtmlLinkWithObjrAndStructParent(): void
{
$obj = $this->getInternalUncompressedTestObject();
$this->initFontAndPage($obj);
$this->setObjectProperty($obj, 'pdfuaMode', 'pdfua1');
$obj->addHTMLCell('<p><a href="https://example.com">Link</a></p>', 10, 10, 80, 20);
$out = $obj->exposeGetOutPDFBody();
$this->assertStringContainsString('/Subtype /Link', $out);
$this->assertStringContainsString('/StructParent ', $out);
$this->assertStringContainsString('/Type /OBJR', $out);
$this->assertStringContainsString('/Type /StructElem /S /Link', $out);
$this->assertStringContainsString('/Tabs /S', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutCatalogIncludesRequiredEntries(): void
{
$obj = $this->getInternalTestObject();
/** @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);
$this->addRawPageWithObjectNumber($obj, 6);
$obj->setOutputState(9, ['pages' => 3, 'xmp' => 4]);
$this->setObjectProperty($obj, 'display', [
'layout' => 'SinglePage',
'mode' => 'UseNone',
'zoom' => 'fullpage',
]);
$this->setObjectProperty($obj, 'lang', ['a_meta_language' => 'en-US']);
$out = $obj->exposeGetOutCatalog();
$this->assertContainsAllFragments($out, [
'/Type /Catalog',
'/Pages 3 0 R',
'/PageLayout /SinglePage',
'/PageMode /UseNone',
'/OpenAction [6 0 R /Fit]',
'/Metadata 4 0 R',
'/Lang ',
]);
}
/**
* @throws \Throwable
*/
public function testGetOutCatalogIncludesDssReferenceWhenAvailable(): void
{
$obj = $this->getInternalTestObject();
$this->addRawPageWithObjectNumber($obj, 6);
$obj->setOutputState(9, ['pages' => 3, 'xmp' => 4, 'dss' => 12]);
$out = $obj->exposeGetOutCatalog();
$this->assertStringContainsString('/DSS 12 0 R', $out);
}
/**
* The DSS is no longer part of the initial body: it moves to a post-signing
* incremental revision (appendDssRevision) so its VRI key can be derived from
* the final signature /Contents (ISO 32000-2 clause 12.8.4.3).
*
* @throws \Throwable
*/
public function testGetOutPDFBodyOmitsDssNowInIncrementalRevision(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$certPath = __DIR__ . '/fixtures/cert_with_revocation_urls.pem';
$certPem = (string) \file_get_contents($certPath);
/** @var array<string, mixed> $signature */
$signature = $this->getObjectProperty($obj, 'signature');
$signature = \array_replace_recursive($signature, [
'signcert' => $certPem,
'extracerts' => $certPem,
'ltv' => [
'enabled' => true,
'embed_ocsp' => true,
'embed_crl' => true,
'embed_certs' => true,
'include_dss' => true,
'include_vri' => true,
],
]);
$this->setObjectProperty($obj, 'signature', $signature);
$out = $obj->exposeGetOutPDFBody();
$this->assertStringNotContainsString('/Type /DSS', $out);
$this->assertStringNotContainsString('/Type /VRI', $out);
}
/**
* appendDssRevision is a no-op unless signing with an LTV/DSS profile that has
* validation material to embed.
*
* @throws \Throwable
*/
public function testAppendDssRevisionNoOpPaths(): void
{
$obj = $this->getInternalTestObject();
$base = "%PDF-1.7\nstartxref\n9\n%%EOF\n";
// Not signing.
$this->setObjectProperty($obj, 'sign', false);
$this->assertSame($base, $obj->exposeAppendDssRevision($base));
// Signing but LTV/DSS not requested.
$this->setObjectProperty($obj, 'sign', true);
/** @var array<string, mixed> $signature */
$signature = $this->getObjectProperty($obj, 'signature');
$signature = \array_replace_recursive($signature, [
'signcert' => '',
'extracerts' => '',
'ltv' => ['enabled' => false, 'include_dss' => false],
]);
$this->setObjectProperty($obj, 'signature', $signature);
$this->assertSame($base, $obj->exposeAppendDssRevision($base));
// LTV/DSS requested but no certificate source, so no material to embed.
$signature = \array_replace_recursive($signature, [
'ltv' => ['enabled' => true, 'include_dss' => true, 'embed_certs' => true, 'include_vri' => true],
]);
$this->setObjectProperty($obj, 'signature', $signature);
$this->assertSame($base, $obj->exposeAppendDssRevision($base));
}
/**
* appendDocTimeStampRevision is a no-op unless signing with the pades-b-lta
* profile and a configured TSA.
*
* @throws \Throwable
*/
public function testAppendDocTimeStampRevisionNoOpPaths(): void
{
$obj = $this->getInternalTestObject();
$base = "%PDF-1.7\nstartxref\n9\n%%EOF\n";
// Not signing.
$this->setObjectProperty($obj, 'sign', false);
$this->assertSame($base, $obj->exposeAppendDocTimeStampRevision($base));
// Signing, but not a B-LTA profile.
$this->setObjectProperty($obj, 'sign', true);
/** @var array<string, mixed> $signature */
$signature = $this->getObjectProperty($obj, 'signature');
$signature['profile'] = 'pades-b-lt';
$this->setObjectProperty($obj, 'signature', $signature);
$this->assertSame($base, $obj->exposeAppendDocTimeStampRevision($base));
// B-LTA profile but no TSA configured, so the timestamp revision is skipped.
$signature['profile'] = 'pades-b-lta';
$this->setObjectProperty($obj, 'signature', $signature);
$this->assertSame($base, $obj->exposeAppendDocTimeStampRevision($base));
}
/**
* extractSignatureContents returns the hex-decoded /Contents bytes, or '' when
* the document carries no signature placeholder.
*
* @throws \Throwable
*/
public function testExtractSignatureContents(): void
{
$obj = $this->getInternalTestObject();
$this->assertSame('', $obj->exposeExtractSignatureContents('no signature here'));
$this->assertSame('', $obj->exposeExtractSignatureContents('/Contents<41 no closing bracket'));
$this->assertSame("\x30\x82\x00\x00", $obj->exposeExtractSignatureContents('x /Contents<30820000> y'));
}
/**
* @throws \Throwable
*/
public function testGetOutICCRespectsPdfaMode(): void
{
$obj = $this->getInternalTestObject();
$this->assertSame('', $obj->exposeGetOutICC());
$obj->setPdfaMode(1);
$out = $obj->exposeGetOutICC();
$this->assertStringContainsString('/N 3', $out);
$this->assertStringContainsString('endobj', $out);
}
/**
* @throws \Throwable
*/
public function testOutputIntentsHelpersHandleCatalogAndModes(): void
{
$obj = $this->getInternalTestObject();
$this->assertSame('', $obj->exposeGetOutputIntents());
$this->setObjectProperty($obj, 'objid', ['catalog' => 7, 'srgbicc' => 4]);
$srgb = $obj->exposeGetOutputIntentsSrgb();
$this->assertContainsAllFragments($srgb, [
'/GTS_PDFA1',
'/DestOutputProfile 4 0 R',
]);
$this->setObjectProperty($obj, 'pdfx', true);
$this->assertStringContainsString('/GTS_PDFX', $obj->exposeGetOutputIntentsPdfX());
$this->assertStringContainsString('/GTS_PDFX', $obj->exposeGetOutputIntents());
$this->setObjectProperty($obj, 'pdfxMode', 'pdfx4');
$this->assertStringContainsString(
"\xfe\xff\x00P\x00D\x00F\x00/\x00X\x00-\x004",
$obj->exposeGetOutputIntentsPdfX(),
);
$this->setObjectProperty($obj, 'pdfxMode', 'pdfx1a');
$this->assertStringContainsString(
"\xfe\xff\x00P\x00D\x00F\x00/\x00X\x00-\x001\x00a",
$obj->exposeGetOutputIntentsPdfX(),
);
$this->setObjectProperty($obj, 'pdfxMode', 'pdfx3');
$this->assertStringContainsString(
"\xfe\xff\x00P\x00D\x00F\x00/\x00X\x00-\x003",
$obj->exposeGetOutputIntentsPdfX(),
);
$this->setObjectProperty($obj, 'pdfxMode', 'pdfx5');
$this->assertStringContainsString(
"\xfe\xff\x00P\x00D\x00F\x00/\x00X\x00-\x005",
$obj->exposeGetOutputIntentsPdfX(),
);
$this->setObjectProperty($obj, 'pdfxMode', '');
$this->assertStringContainsString(
"\xfe\xff\x00O\x00F\x00C\x00O\x00M\x00_\x00P\x00O\x00_\x00P\x001\x00_\x00F\x006\x000\x00_\x009\x005",
$obj->exposeGetOutputIntentsPdfX(),
);
}
/**
* @throws \Throwable
*/
public function testGetPDFLayersAndGetOutOCGDefaultAndNonEmpty(): void
{
$obj = $this->getInternalTestObject();
$this->assertSame('', $obj->exposeGetPDFLayers());
$this->assertSame('', $obj->exposeGetOutOCG());
$this->setObjectProperty($obj, 'objid', ['catalog' => 7]);
$this->setObjectProperty($obj, 'pdflayer', [[
'name' => 'Layer A',
'view' => true,
'lock' => false,
'intent' => '/View',
]]);
$ocg = $obj->exposeGetOutOCG();
$layers = $obj->exposeGetPDFLayers();
$this->assertStringContainsString('/Type /OCG', $ocg);
$this->assertStringContainsString('/OCProperties << /OCGs [', $layers);
}
/**
* @throws \Throwable
*/
public function testGetOutAPXObjectsAndXObjectsEmptyPaths(): void
{
$obj = $this->getInternalTestObject();
$apx = $obj->exposeGetOutAPXObjects(12.0, 7.0, 'q Q');
$xobj = $obj->exposeGetOutXObjects();
$this->assertContainsAllFragments($apx, [
'/Subtype /Form',
'/BBox [0 0 12.000000 7.000000]',
]);
$this->assertSame('', $xobj);
}
/**
* @throws \Throwable
*/
public function testGetOutEmbeddedFilesAndAnnotationsDefaultEmpty(): void
{
$obj = $this->getInternalTestObject();
$this->assertSame('', $obj->exposeGetOutEmbeddedFiles());
$this->assertSame('', $obj->exposeGetOutAnnotations());
}
/**
* @throws \Throwable
*/
public function testSignatureHelpersDefaultPathsAndInfoFormatting(): void
{
$obj = $this->getInternalTestObject();
$this->assertSame('', $obj->exposeGetOutSignatureFields());
$this->assertSame('abc', $obj->exposeSignDocument('abc'));
$this->assertSame('', $obj->exposeGetOutSignature());
$this->assertStringContainsString('/TransformMethod /DocMDP', $obj->exposeGetOutSignatureDocMDP());
$userRightsOut = $obj->exposeGetOutSignatureUserRights();
$this->assertStringContainsString('/TransformMethod /UR3', $userRightsOut);
$this->setObjectProperty($obj, 'signature', ['cert_type' => 2]);
$this->assertStringContainsString('/TransformMethod /DocMDP', $obj->exposeGetOutSignatureDocMDP());
}
/**
* @throws \Throwable
*/
public function testGetOutSignatureEmitsUserRightsSignatureForCertTypeZero(): void
{
$obj = $this->getInternalTestObject();
$page = $this->addRawPageWithObjectNumber($obj, 6);
$certPath = __DIR__ . '/../examples/data/cert/tcpdf.crt';
$certPem = (string) \file_get_contents($certPath);
$obj->setSignature([
'appearance' => [
'empty' => [],
'name' => 'Signature',
'page' => $page['pid'],
'rect' => '10 10 60 20',
],
'approval' => '',
'cert_type' => 0,
'extracerts' => '',
'info' => [
'ContactInfo' => '',
'Location' => '',
'Name' => '',
'Reason' => '',
],
'password' => '',
'privkey' => $certPem,
'signcert' => $certPem,
'ltv' => [
'enabled' => false,
'embed_ocsp' => true,
'embed_crl' => true,
'embed_certs' => true,
'include_dss' => true,
'include_vri' => true,
],
]);
$out = $obj->exposeGetOutSignature();
$this->assertContainsAllFragments($out, [
'/Type /Sig',
'/TransformMethod /UR3',
'/SubFilter /adbe.pkcs7.detached',
]);
}
/**
* @throws \Throwable
*/
public function testGetOutSignatureSkipsSignatureReferenceForApprovalMode(): void
{
$obj = $this->getInternalTestObject();
$page = $this->addRawPageWithObjectNumber($obj, 6);
$certPath = __DIR__ . '/../examples/data/cert/tcpdf.crt';
$certPem = (string) \file_get_contents($certPath);
$obj->setSignature([
'appearance' => [
'empty' => [],
'name' => 'Signature',
'page' => $page['pid'],
'rect' => '10 10 60 20',
],
'approval' => 'A',
'cert_type' => 2,
'extracerts' => '',
'info' => [
'ContactInfo' => '',
'Location' => '',
'Name' => '',
'Reason' => '',
],
'password' => '',
'privkey' => $certPem,
'signcert' => $certPem,
'ltv' => [
'enabled' => false,
'embed_ocsp' => true,
'embed_crl' => true,
'embed_certs' => true,
'include_dss' => true,
'include_vri' => true,
],
]);
$out = $obj->exposeGetOutSignature();
$this->assertStringContainsString('/Type /Sig', $out);
$this->assertStringNotContainsString('/Reference [ << /Type /SigRef', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutSignatureIncludesCustomAppearanceStream(): void
{
$obj = $this->getInternalUncompressedTestObject();
$page = $this->addRawPageWithObjectNumber($obj, 6);
$certPath = __DIR__ . '/../examples/data/cert/tcpdf.crt';
$certPem = (string) \file_get_contents($certPath);
$obj->setSignature([
'appearance' => [
'ap' => [
'n' => 'q 1 0 0 rg 0 0 50 10 re f Q',
],
'empty' => [],
'name' => 'Signature',
'page' => $page['pid'],
'rect' => '10 10 60 20',
],
'approval' => '',
'cert_type' => 2,
'extracerts' => '',
'info' => [
'ContactInfo' => '',
'Location' => '',
'Name' => '',
'Reason' => '',
],
'password' => '',
'privkey' => $certPem,
'signcert' => $certPem,
'ltv' => [
'enabled' => false,
'embed_ocsp' => true,
'embed_crl' => true,
'embed_certs' => true,
'include_dss' => true,
'include_vri' => true,
],
]);
$out = $obj->exposeGetOutSignature();
$this->assertContainsAllFragments($out, [
'/Subtype /Widget',
'/AP << /N ',
'/Subtype /Form',
'0 0 50 10 re f',
]);
}
/**
* @throws \Throwable
*/
public function testGetOutSignatureIncludesFittedXObjectAppearance(): void
{
$obj = $this->getInternalUncompressedTestObject();
$page = $this->addRawPageWithObjectNumber($obj, 6);
$certPath = __DIR__ . '/../examples/data/cert/tcpdf.crt';
$certPem = (string) \file_get_contents($certPath);
/** @var array<string, array<string, mixed>> $xobjects */
$xobjects = $this->getObjectProperty($obj, 'xobjects');
$xobjects['IMP1'] = [
'spot_colors' => [],
'extgstate' => [],
'gradient' => [],
'font' => [],
'image' => [],
'xobject' => [],
'annotations' => [],
'id' => 'IMP1',
'n' => 88,
'x' => 0,
'w' => 50,
'y' => 0,
'h' => 20,
'outdata' => '',
'pheight' => 0,
'gheight' => 0,
];
$this->setObjectProperty($obj, 'xobjects', $xobjects);
$obj->setSignature([
'appearance' => [
'empty' => [],
'name' => 'Signature',
'page' => $page['pid'],
'rect' => '10 10 110 50',
'xobj' => 'IMP1',
],
'approval' => '',
'cert_type' => 2,
'extracerts' => '',
'info' => [
'ContactInfo' => '',
'Location' => '',
'Name' => '',
'Reason' => '',
],
'password' => '',
'privkey' => $certPem,
'signcert' => $certPem,
'ltv' => [
'enabled' => false,
'embed_ocsp' => true,
'embed_crl' => true,
'embed_certs' => true,
'include_dss' => true,
'include_vri' => true,
],
]);
$out = $obj->exposeGetOutSignature();
$this->assertContainsAllFragments($out, [
'/AP << /N ',
'/IMP1 Do',
'q 2.000000 0 0 2.000000 0 0 cm',
]);
}
/**
* @throws \Throwable
*/
public function testConvertBinarySignatureToHexPadsToSigMaxLen(): void
{
$obj = $this->getInternalTestObject();
$binary = "\x01\x02\x03";
$hex = $obj->exposeConvertBinarySignatureToHex($binary);
$this->assertSame(11742, \strlen($hex));
$this->assertStringStartsWith('010203', $hex);
$this->assertMatchesRegularExpression('/^[0-9a-f]+$/', $hex);
}
/**
* @throws \Throwable
*/
public function testPrepareDocumentForSignatureComputesByteRangeAndStripsSlot(): void
{
$obj = $this->getInternalTestObject();
$byterangePlaceholder = '/ByteRange[0 ********** ********** **********]';
$prefix = 'XPFX';
$mid = '/Contents ';
$sigSlot = '<' . \str_repeat('0', 11742) . '>';
$suffix = 'XSFX';
$pdfdoc = $prefix . $byterangePlaceholder . $mid . $sigSlot . $suffix . "\n";
$result = $obj->exposePrepareDocumentForSignature($pdfdoc);
$this->assertSame(0, $result['byte_range'][0]);
$this->assertSame($result['pdfdoc_length'], \strlen($result['pdfdoc']));
$this->assertStringNotContainsString('**********', $result['pdfdoc']);
$this->assertMatchesRegularExpression('#/ByteRange\[0 \d+ \d+ \d+\] *#', $result['pdfdoc']);
// Signature slot (SIGMAXLEN + 2 for < >) is stripped from the signing content
$expectedLength = \strlen($prefix) + \strlen($byterangePlaceholder) + \strlen($mid) + \strlen($suffix);
$this->assertSame($expectedLength, $result['pdfdoc_length']);
// byte_range[3] must equal the length of the suffix
$this->assertSame(\strlen($suffix), $result['byte_range'][3]);
}
/**
* B-T: an enabled TSA embeds the signature timestamp as the SignerInfo
* id-aa-signatureTimeStampToken unsigned attribute inside the CMS. The RFC
* 3161 codec lives in the tc-lib-pdf-sign package; the host only supplies the
* HTTP transport (mocked here).
*
* @throws \Throwable
*/
public function testBuildSignatureCmsEmbedsSignatureTimestamp(): void
{
$obj = $this->getInternalTestObject();
$signer = $this->makeSigningCredential();
$this->setSignatureArray($obj, [
'signcert' => $signer['cert_pem'],
'privkey' => $signer['key_pem'],
'password' => '',
'profile' => 'pades-b-t',
]);
$this->setObjectProperty($obj, 'sigtimestamp', [
'enabled' => true,
'host' => 'https://tsa.example.test',
'username' => '',
'password' => '',
'cert' => '',
'hash_algorithm' => 'sha256',
'policy_oid' => '',
'nonce_enabled' => false,
'timeout' => 5,
'verify_peer' => false,
]);
// TimeStampResp: SEQUENCE { PKIStatusInfo{INTEGER 0}, token SEQUENCE{INTEGER 42} }.
$token = (string) \hex2bin('300302012A');
$obj->setMockTimestampResponse((string) \hex2bin('300A3003020100300302012A'));
$cms = $obj->exposeBuildSignatureCms('payload');
$asn1 = new \Com\Tecnick\Pdf\Sign\Cms\Asn1();
$oid = $asn1->encodeObjectIdentifier('1.2.840.113549.1.9.16.2.14');
$this->assertStringContainsString($oid, $cms);
$this->assertStringContainsString($token, $cms);
// The TSA transport received a DER TimeStampReq (SEQUENCE).
$this->assertStringStartsWith("\x30", $obj->getCapturedTimestampRequest());
}
/**
* @throws \Throwable
*/
public function testCollectDssMaterialReturnsEmptyWhenLtvDisabled(): void
{
$obj = $this->getInternalTestObject();
$material = $obj->exposeCollectDssMaterial();
$this->assertSame([], $material['certs']);
$this->assertSame([], $material['ocsp']);
$this->assertSame([], $material['crls']);
}
/**
* @throws \Throwable
*/
public function testCollectDssMaterialLoadsAndDeduplicatesCertificates(): void
{
$obj = $this->getInternalTestObject();
$certPath = __DIR__ . '/../examples/data/cert/tcpdf.crt';
$certPem = (string) \file_get_contents($certPath);
$this->setObjectProperty($obj, 'signature', [
'signcert' => $certPem,
'extracerts' => $certPem,
'ltv' => [
'enabled' => true,
'embed_ocsp' => false,
'embed_crl' => false,
'embed_certs' => true,
'include_dss' => true,
'include_vri' => true,
],
]);
$material = $obj->exposeCollectDssMaterial();
// The signer cert given twice (signcert + extracerts) deduplicates to one.
$this->assertSame(1, \count($material['certs']));
$this->assertSame([], $material['ocsp']);
$this->assertSame([], $material['crls']);
}
/**
* @throws \Throwable
*/
public function testCollectDssMaterialCollectsOcspAndCrlWhenEnabled(): void
{
$obj = $this->getInternalTestObject();
$certPath = __DIR__ . '/fixtures/cert_with_revocation_urls.pem';
$certPem = (string) \file_get_contents($certPath);
$this->setObjectProperty($obj, 'signature', [
'signcert' => $certPem,
'extracerts' => $certPem,
'ltv' => [
'enabled' => true,
'embed_ocsp' => true,
'embed_crl' => true,
'embed_certs' => true,
'include_dss' => true,
'include_vri' => true,
],
]);
$obj->setMockOcspResponse('mock-ocsp-response-binary');
$obj->setMockCrlResponse('mock-crl-binary');
$material = $obj->exposeCollectDssMaterial();
$this->assertSame(1, \count($material['certs']));
$this->assertSame(1, \count($material['ocsp']));
$this->assertSame(1, \count($material['crls']));
$this->assertSame('http://ocsp.example.com/', $obj->getCapturedOcspUrl());
$this->assertSame('http://crl2.example.com/root.crl', $obj->getCapturedCrlUrl());
// The OCSP request is built by the package Ocsp\Client and passed to the host transport.
$this->assertNotSame('', $obj->getCapturedOcspRequest());
}
/**
* @throws \Throwable
*/
public function testCollectDssMaterialFallsBackToCrlWhenOcspFails(): void
{
$obj = $this->getInternalTestObject();
$certPath = __DIR__ . '/fixtures/cert_with_revocation_urls.pem';
$certPem = (string) \file_get_contents($certPath);
$this->setObjectProperty($obj, 'signature', [
'signcert' => $certPem,
'extracerts' => $certPem,
'ltv' => [
'enabled' => true,
'embed_ocsp' => true,
'embed_crl' => true,
'embed_certs' => true,
'include_dss' => true,
'include_vri' => true,
],
]);
$obj->setMockOcspThrows(true);
$obj->setMockCrlResponse('mock-crl-binary');
$material = $obj->exposeCollectDssMaterial();
// The package skips the failing OCSP transport and keeps the CRL.
$this->assertSame([], $material['ocsp']);
$this->assertSame(1, \count($material['crls']));
$this->assertSame('http://ocsp.example.com/', $obj->getCapturedOcspUrl());
}
/**
* A certificate the package cannot decode surfaces as a host PdfException.
*
* @throws \Throwable
*/
public function testCollectDssMaterialThrowsOnUndecodableCertificate(): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'signature', [
'signcert' => "-----BEGIN CERTIFICATE-----\n@@@@\n-----END CERTIFICATE-----",
'extracerts' => '',
'ltv' => [
'enabled' => true,
'embed_ocsp' => false,
'embed_crl' => false,
'embed_certs' => true,
'include_dss' => true,
'include_vri' => true,
],
]);
$this->expectException(\Com\Tecnick\Pdf\Exception::class);
$obj->exposeCollectDssMaterial();
}
/**
* The embed_* flags gate which material is embedded: with them off, only the
* chain is walked and nothing is fetched or kept.
*
* @throws \Throwable
*/
public function testCollectDssMaterialHonoursEmbedFlags(): void
{
$obj = $this->getInternalTestObject();
$certPath = __DIR__ . '/fixtures/cert_with_revocation_urls.pem';
$certPem = (string) \file_get_contents($certPath);
$this->setObjectProperty($obj, 'signature', [
'signcert' => $certPem,
'extracerts' => '',
'ltv' => [
'enabled' => true,
'embed_ocsp' => false,
'embed_crl' => false,
'embed_certs' => false,
'include_dss' => true,
'include_vri' => true,
],
]);
$obj->setMockOcspResponse('mock-ocsp-response-binary');
$obj->setMockCrlResponse('mock-crl-binary');
$material = $obj->exposeCollectDssMaterial();
$this->assertSame([], $material['certs']);
$this->assertSame([], $material['ocsp']);
$this->assertSame([], $material['crls']);
// No transports were invoked.
$this->assertSame('', $obj->getCapturedOcspUrl());
$this->assertSame('', $obj->getCapturedCrlUrl());
}
/**
* @throws \Throwable
*/
public function testGetAnnotationFlagsCodeWithIntegerInput(): void
{
$obj = $this->getInternalTestObject();
$this->assertSame(7, $obj->exposeGetAnnotationFlagsCode(7));
$this->assertSame(0, $obj->exposeGetAnnotationFlagsCode(0));
}
/**
* @throws \Throwable
*/
public function testGetAnnotationFlagsCodeCoversAllIndividualFlags(): void
{
$obj = $this->getInternalTestObject();
$this->assertSame(1, $obj->exposeGetAnnotationFlagsCode(['invisible']));
$this->assertSame(4, $obj->exposeGetAnnotationFlagsCode(['print']));
$this->assertSame(8, $obj->exposeGetAnnotationFlagsCode(['nozoom']));
$this->assertSame(16, $obj->exposeGetAnnotationFlagsCode(['norotate']));
$this->assertSame(32, $obj->exposeGetAnnotationFlagsCode(['noview']));
$this->assertSame(256, $obj->exposeGetAnnotationFlagsCode(['togglenoview']));
$this->assertSame(0, $obj->exposeGetAnnotationFlagsCode(['unknown-flag']));
}
/**
* @throws \Throwable
*/
public function testGetOutICCWithSRGBFlagGeneratesBlock(): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'sRGB', true);
$out = $obj->exposeGetOutICC();
$this->assertStringContainsString('/N 3', $out);
$this->assertStringContainsString('/Filter /FlateDecode', $out);
$this->assertStringContainsString('endobj', $out);
}
/**
* @throws \Throwable
*/
public function testGetAnnotationBorderWithBorderArrayFallback(): void
{
$obj = $this->getInternalTestObject();
$withBorder = $obj->exposeGetAnnotationBorder(['opt' => ['border' => [1, 2, 3, [4, 5]]]]);
$this->assertStringContainsString(' /Border [1 2 3 [ 4 5 ]]', $withBorder);
$defaultBorder = $obj->exposeGetAnnotationBorder(['opt' => []]);
$this->assertStringContainsString(' /Border [0 0 0]', $defaultBorder);
}
/**
* @throws \Throwable
*/
public function testGetAnnotationBorderBeUsesDefaultStyleWhenInvalidS(): void
{
$obj = $this->getInternalTestObject();
$out = $obj->exposeGetAnnotationBorder(['opt' => ['be' => ['s' => 'X']]]);
$this->assertStringContainsString(' /BE << /S /S>>', $out);
}
/**
* @throws \Throwable
*/
public function testGetAnnotationBorderBeSkipsInvalidIntensity(): void
{
$obj = $this->getInternalTestObject();
$out = $obj->exposeGetAnnotationBorder(['opt' => ['be' => ['s' => 'C', 'i' => 5.0]]]);
$this->assertStringContainsString('/S /C', $out);
$this->assertStringNotContainsString('/I', $out);
}
/**
* @throws \Throwable
*/
public function testGetAnnotationAppearanceStreamWithStringAp(): void
{
$obj = $this->getInternalTestObject();
[$aas, $apx] = $obj->exposeGetAnnotationAppearanceStream(['opt' => ['ap' => '/N 1 0 R']], 10.0, 5.0);
$this->assertMatchesRegularExpression('#/AP <<\s*/N 1 0 R\s*>>#', $aas);
$this->assertSame('', $apx);
}
/**
* @throws \Throwable
*/
public function testGetAnnotationAppearanceStreamWithArrayApStringDef(): void
{
$obj = $this->getInternalTestObject();
[$aas, $apx] = $obj->exposeGetAnnotationAppearanceStream(['opt' => ['ap' => ['n' => 'q Q']]], 8.0, 4.0);
$this->assertStringContainsString(' /N ', $aas);
$this->assertStringContainsString('/Subtype /Form', $apx);
}
/**
* @throws \Throwable
*/
public function testGetAnnotationAppearanceStreamWithArrayApArrayDef(): void
{
$obj = $this->getInternalTestObject();
[$aas, $apx] = $obj->exposeGetAnnotationAppearanceStream(
['opt' => ['ap' => ['n' => ['On' => 'q Q', 'Off' => '']]]],
8.0,
4.0,
);
$this->assertStringContainsString(' /N <<', $aas);
$this->assertStringContainsString(' /On ', $aas);
$this->assertStringContainsString('/Subtype /Form', $apx);
}
/**
* @throws \Throwable
*/
public function testGetAnnotationRadioButtonsWithKidsAndReadonly(): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'radiobuttons', [
'gender' => [
'n' => 5,
'#readonly#' => false,
'kids' => [
['n' => 6, 'def' => 'Male'],
['n' => 7, 'def' => 'Off'],
],
],
]);
$out = $obj->exposeGetAnnotationRadioButtons(['txt' => 'gender', 'opt' => []]);
$this->assertStringContainsString('/FT /Btn', $out);
$this->assertStringContainsString('/Kids [', $out);
$this->assertStringContainsString(' 6 0 R', $out);
$this->assertStringContainsString('/V /Male', $out);
$this->assertStringNotContainsString('/F 68', $out);
}
/**
* @throws \Throwable
*/
public function testGetAnnotationRadioButtonsReadonlyFlag(): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'radiobuttons', [
'choice' => [
'n' => 10,
'#readonly#' => true,
'kids' => [
['n' => 11, 'def' => 'Off'],
],
],
]);
$out = $obj->exposeGetAnnotationRadioButtons(['txt' => 'choice', 'opt' => []]);
$this->assertStringContainsString('/F 68 /Ff 49153', $out);
}
/**
* @throws \Throwable
*/
public function testGetAnnotationRadioButtonsWithTuField(): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'radiobuttons', [
'rb' => [
'n' => 20,
'#readonly#' => false,
'kids' => [['n' => 21, 'def' => 'Off']],
],
]);
$out = $obj->exposeGetAnnotationRadioButtons([
'txt' => 'rb',
'opt' => ['tu' => 'Tooltip text'],
]);
$this->assertStringContainsString('/TU ', $out);
}
/**
* @param array<string, mixed> $opt
* @param list<string> $expectedFragments
* @throws \Throwable
*/
#[DataProvider('annotationSubtypeTextProvider')]
public function testGetOutAnnotationOptSubtypeTextVariants(array $opt, array $expectedFragments): void
{
$obj = $this->getInternalTestObject();
$out = $obj->exposeGetOutAnnotationOptSubtypeText(['opt' => $opt]);
foreach ($expectedFragments as $fragment) {
$this->assertStringContainsString($fragment, $out);
}
}
/** @return array<string, array{0: mixed, 1: string}> */
public static function onOffProvider(): array
{
return [
'true_is_on' => [true, 'ON'],
'zero_is_off' => [0, 'OFF'],
];
}
/** @return array<string, array{0: array<string, mixed>, 1: array<int, string>}> */
public static function annotationSubtypeTextProvider(): array
{
return [
'known_icon_name' => [
['name' => 'Help'],
[' /Name /Help'],
],
'unknown_icon_defaults_to_note' => [
['name' => 'Unknown'],
[' /Name /Note'],
],
'invalid_state_model_falls_back' => [
['statemodel' => 'Invalid', 'state' => 'SomeState'],
[' /StateModel /Marked', ' /State /Unmarked'],
],
'review_rejected_state' => [
['statemodel' => 'Review', 'state' => 'Rejected'],
[' /StateModel /Review', ' /State /Rejected'],
],
'review_invalid_state_defaults_none' => [
['statemodel' => 'Review', 'state' => 'InvalidState'],
[' /State /None'],
],
'open_false' => [
['open' => false],
[' /Open false'],
],
];
}
/** @return array<string, array{0: array<string, mixed>, 1: int, 2: int, 3: list<string>}> */
public static function annotationSubtypeLinkSimpleProvider(): array
{
return [
'external_uri_default_highlight' => [
['txt' => 'https://example.com', 'opt' => []],
1,
10,
[' /S /URI', ' /URI ', ' /H /I'],
],
'relative_pdf_gotor' => [
['txt' => 'docs/guide.pdf#named=Section2', 'opt' => []],
1,
22,
[' /S /GoToR', ' /D (Section2)', ' /F ', ' /NewWindow true', ' /H /I'],
],
];
}
/**
* @throws \Throwable
*/
public function testGetOutAnnotationOptSubtypeLinkWithAtInternalLink(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$page = $this->addRawPageWithObjectNumber($obj, 5);
$this->setObjectProperty($obj, 'links', ['@1' => ['p' => $page['pid'], 'y' => 15.0]]);
$out = $obj->exposeGetOutAnnotationOptSubtypeLink(['txt' => '@1', 'opt' => []], 1, 20);
$this->assertStringContainsString('/Dest [', $out);
$this->assertStringContainsString('/XYZ 0 ', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutAnnotationOptSubtypeFreetextDsAndCl(): void
{
$obj = $this->getInternalTestObject();
$annot = [
'n' => 10,
'opt' => [
'da' => '/F1 12 Tf',
'rc' => '<p>Rich</p>',
'ds' => 'font: Arial 12pt',
'cl' => [10.0, 20.0, 30.0],
],
];
$out = $obj->exposeGetOutAnnotationOptSubtypeFreetext($annot, 10);
$this->assertStringContainsString(' /RC ', $out);
$this->assertStringContainsString(' /DS ', $out);
$this->assertStringContainsString(' /CL [', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutAnnotationOptSubtypeWidgetImageLookupsWithMkIcons(): void
{
$obj = $this->getInternalTestObject();
foreach (['i', 'ri', 'ix'] as $ikey) {
try {
$obj->exposeGetOutAnnotationOptSubtypeWidget([
'txt' => 'mk-icons',
'opt' => [
'mk' => [
$ikey => 'non-existent-icon',
],
],
], 77);
$this->fail('Expected missing image key exception for mk.' . $ikey);
} catch (\Com\Tecnick\Pdf\Image\Exception $e) {
$this->assertStringContainsString('Unknown key', $e->getMessage());
}
}
}
/**
* @throws \Throwable
*/
public function testGetOutAnnotationOptSubtypeWidgetWithTuTmAndScalarValues(): void
{
$obj = $this->getInternalTestObject();
$annot = [
'txt' => 'field',
'opt' => [
'tu' => 'Tooltip',
'tm' => 'Mapping',
'v' => 'scalar-value',
'dv' => 'default',
'rv' => 'rich',
],
];
$out = $obj->exposeGetOutAnnotationOptSubtypeWidget($annot, 15);
$this->assertStringContainsString(' /TU ', $out);
$this->assertStringContainsString(' /TM ', $out);
$this->assertStringContainsString(' /V ', $out);
$this->assertStringContainsString(' /DV ', $out);
$this->assertStringContainsString(' /RV ', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutAnnotationOptSubtypeWidgetWithParent(): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'radiobuttons', [
'rb-field' => ['n' => 99],
]);
$out = $obj->exposeGetOutAnnotationOptSubtypeWidget([
'txt' => 'rb-field',
'opt' => [],
], 50);
$this->assertStringContainsString(' /Parent 99 0 R', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutAnnotationOptSubtypeWidgetOptChoiceStrings(): void
{
$obj = $this->getInternalTestObject();
$annot = [
'txt' => 'combo',
'opt' => [
'opt' => [
'stringopt',
['key1', 'Label One'],
['bad-count'],
42,
],
],
];
$out = $obj->exposeGetOutAnnotationOptSubtypeWidget($annot, 31);
$this->assertStringContainsString(' /Opt [', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutCatalogWithEmbeddedFilesAndJavascriptTree(): void
{
$obj = $this->getInternalTestObject();
$this->addRawPageWithObjectNumber($obj, 3);
$obj->setOutputState(9, ['pages' => 3, 'xmp' => 4]);
$this->setObjectProperty($obj, 'jstree', '<< /Names [(JS) 1 0 R] >>');
$this->setObjectProperty($obj, 'embeddedfiles', [
'doc.pdf' => [
'a' => 0,
'f' => 5,
'n' => 6,
'file' => '',
'content' => 'data',
'mimeType' => 'application/pdf',
'afRelationship' => 'Source',
'description' => 'test',
'creationDate' => 0,
'modDate' => 0,
],
]);
$this->setObjectProperty($obj, 'objid', [
'catalog' => 0,
'dests' => 7,
'form' => [],
'info' => 0,
'pages' => 3,
'resdic' => 0,
'signature' => 0,
'srgbicc' => 0,
'xmp' => 4,
]);
$out = $obj->exposeGetOutCatalog();
$this->assertContainsAllFragments($out, [
'/JavaScript',
'/AF [',
'/EmbeddedFiles',
'/Dests 7 0 R',
]);
// The /AF (Associated Files) array must be an entry of the Catalog root,
// not nested inside the /Names dictionary (ISO 32000-2, PDF/A-3).
$namesPos = \strpos($out, '/Names <<');
$this->assertNotFalse($namesPos);
$this->assertStringNotContainsString('/AF', \substr($out, $namesPos));
}
/**
* @throws \Throwable
*/
public function testGetOutCatalogOmitsJavascriptTreeInPdfuaMode(): void
{
$obj = $this->getInternalTestObject();
$this->addRawPageWithObjectNumber($obj, 3);
$obj->setOutputState(9, ['pages' => 3, 'xmp' => 4]);
$this->setObjectProperty($obj, 'jstree', '<< /Names [(JS) 1 0 R] >>');
$this->setObjectProperty($obj, 'pdfuaMode', 'pdfua1');
$out = $obj->exposeGetOutCatalog();
$this->assertStringNotContainsString('/JavaScript', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutCatalogOmitsJavascriptTreeInPdfxMode(): void
{
$obj = $this->getInternalTestObject();
$this->addRawPageWithObjectNumber($obj, 3);
$obj->setOutputState(9, ['pages' => 3, 'xmp' => 4]);
$this->setObjectProperty($obj, 'jstree', '<< /Names [(JS) 1 0 R] >>');
$this->setObjectProperty($obj, 'pdfx', true);
$out = $obj->exposeGetOutCatalog();
$this->assertStringNotContainsString('/JavaScript', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutCatalogIncludesMarkInfoAndDefaultLanguageInPdfuaMode(): void
{
$obj = $this->getInternalTestObject();
$this->addRawPageWithObjectNumber($obj, 3);
$obj->setOutputState(9, ['pages' => 3, 'xmp' => 4]);
$this->setObjectProperty($obj, 'pdfuaMode', 'pdfua1');
$out = $obj->exposeGetOutCatalog();
$this->assertStringContainsString('/MarkInfo << /Marked true >>', $out);
$this->assertStringContainsString('/Lang ', $out);
$this->assertStringContainsString("\x00e\x00n\x00-\x00U\x00S", $out);
}
/**
* @throws \Throwable
*/
public function testGetOutCatalogIncludesStructTreeRootReferenceWhenAvailable(): void
{
$obj = $this->getInternalTestObject();
$this->addRawPageWithObjectNumber($obj, 3);
$obj->setOutputState(9, ['pages' => 3, 'xmp' => 4]);
$this->setObjectProperty($obj, 'pdfuaMode', 'pdfua1');
$this->setObjectProperty($obj, 'structtreerootoid', 42);
$out = $obj->exposeGetOutCatalog();
$this->assertStringContainsString('/StructTreeRoot 42 0 R', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutPDFBodyResetsStructureObjectIdsOutsidePdfuaMode(): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'parenttreeoid', 12);
$this->setObjectProperty($obj, 'structtreerootoid', 34);
$this->setObjectProperty($obj, 'pagestructparents', [9 => 0]);
$this->setObjectProperty($obj, 'pagestructmcids', [9 => 1]);
$method = new \ReflectionMethod($obj, 'getOutStructTreeRoot');
$this->assertSame('', $method->invoke($obj));
$this->assertSame([], $this->getObjectProperty($obj, 'pagestructparents'));
$this->assertSame([], $this->getObjectProperty($obj, 'pagestructmcids'));
$this->assertSame(0, $this->getObjectProperty($obj, 'parenttreeoid'));
$this->assertSame(0, $this->getObjectProperty($obj, 'structtreerootoid'));
}
/**
* @throws \Throwable
*/
public function testGetOutCatalogUsesConfiguredLanguageInPdfuaMode(): void
{
$obj = $this->getInternalTestObject();
$this->addRawPageWithObjectNumber($obj, 3);
$obj->setOutputState(9, ['pages' => 3, 'xmp' => 4]);
$this->setObjectProperty($obj, 'pdfuaMode', 'pdfua1');
$this->setObjectProperty($obj, 'lang', ['a_meta_language' => 'it-IT']);
$out = $obj->exposeGetOutCatalog();
$this->assertStringContainsString('/MarkInfo << /Marked true >>', $out);
$this->assertStringContainsString('/Lang ', $out);
$this->assertStringContainsString("\x00i\x00t\x00-\x00I\x00T", $out);
$this->assertStringNotContainsString("\x00e\x00n\x00-\x00U\x00S", $out);
}
/**
* @throws \Throwable
*/
#[DataProvider('catalogZoomModeProvider')]
public function testGetOutCatalogZoomModes(string|int $zoom, string $expectedFragment): void
{
$obj = $this->getInternalTestObject();
$this->addRawPageWithObjectNumber($obj, 6);
$obj->setOutputState(9, ['pages' => 3, 'xmp' => 4]);
$this->setObjectProperty($obj, 'display', ['layout' => '', 'mode' => 'UseNone', 'zoom' => $zoom]);
$out = $obj->exposeGetOutCatalog();
$this->assertStringContainsString($expectedFragment, $out);
}
/** @return array<string, array{0: string|int, 1: string}> */
public static function catalogZoomModeProvider(): array
{
return [
'fullwidth' => ['fullwidth', '/FitH null]'],
'real' => ['real', '/XYZ null null 1]'],
'numeric' => [150, '/XYZ null null'],
];
}
/**
* @throws \Throwable
*/
public function testGetOutCatalogWithOutlinesAutoSetsDisplayMode(): void
{
$obj = $this->getInternalTestObject();
$page = $this->addRawPageWithObjectNumber($obj, 6);
$obj->setOutputState(9, ['pages' => 3, 'xmp' => 4]);
$obj->setBookmark('Chapter 1', '', 0, $page['pid']);
$obj->exposeGetOutBookmarks();
$this->setObjectProperty($obj, 'display', ['layout' => '', 'mode' => '', 'zoom' => 'default']);
$out = $obj->exposeGetOutCatalog();
$this->assertContainsAllFragments($out, [
'/Outlines ',
'/PageMode /UseOutlines',
]);
}
/**
* @throws \Throwable
*/
public function testGetOutCatalogWithFormFields(): void
{
$obj = $this->getInternalTestObject();
/** @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);
$this->addRawPageWithObjectNumber($obj, 6);
$obj->setOutputState(9, ['pages' => 3, 'xmp' => 4, 'form' => [5, 6]]);
$out = $obj->exposeGetOutCatalog();
$this->assertContainsAllFragments($out, [
'/AcroForm <<',
'/Fields [',
' 5 0 R',
'/NeedAppearances false',
]);
}
/**
* @throws \Throwable
*/
public function testGetOutCatalogWithSignatureAcroformVariants(): void
{
$obj = $this->getInternalTestObject();
$pageInfo = $this->initFontAndPage($obj);
/** @var \Com\Tecnick\Pdf\Page\Page $pageObj */
$pageObj = $this->getObjectProperty($obj, 'page');
/** @var array<int, array<string, mixed>> $pgdata */
$pgdata = $this->getObjectProperty($pageObj, 'page');
$pgdata[$pageInfo['pid']]['n'] = 6;
$pgdata[$pageInfo['pid']]['num'] = 1;
$this->setObjectProperty($pageObj, 'page', $pgdata);
$this->addRawPageWithObjectNumber($obj, 6);
$obj->setOutputState(9, ['pages' => 3, 'xmp' => 4, 'signature' => 40, 'form' => [51]]);
$this->setObjectProperty($obj, 'sign', true);
$this->setObjectProperty($obj, 'annotation_fonts', ['helvetica' => 1]);
$this->setObjectProperty($obj, 'signature', [
'cert_type' => 0,
'approval' => 'P',
'appearance' => [
'empty' => [
['objid' => 41],
],
],
]);
$ur3Out = $obj->exposeGetOutCatalog();
$this->assertContainsAllFragments($ur3Out, [
'/Fields [40 0 R 41 0 R 51 0 R]',
'/SigFlags 1',
'/Perms << /UR3 41 0 R >>',
'/DR << /Font <<',
]);
$this->setObjectProperty($obj, 'signature', [
'cert_type' => 2,
'approval' => 'P',
'appearance' => [
'empty' => [
['objid' => 41],
],
],
]);
$docmdpOut = $obj->exposeGetOutCatalog();
$this->assertContainsAllFragments($docmdpOut, [
'/SigFlags 3',
'/Perms << /DocMDP 41 0 R >>',
]);
}
/**
* @throws \Throwable
*/
public function testGetOutCatalogAcroformDefaultAppearanceWithoutHelvetica(): void
{
self::setUpFontsPath();
$obj = $this->getInternalTestObject();
/** @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/dejavu/dejavusans.json');
$inserted = $font->insert($pon, 'dejavusans', '', 10, null, null, $fontfile);
$this->assertFalse($font->isValidKey('helvetica'));
$this->addRawPageWithObjectNumber($obj, 6);
$obj->setOutputState(9, ['pages' => 3, 'xmp' => 4, 'signature' => 40, 'form' => []]);
$this->setObjectProperty($obj, 'sign', true);
$this->setObjectProperty($obj, 'signature', [
'cert_type' => 2,
'approval' => 'P',
'appearance' => [
'empty' => [],
],
]);
$out = $obj->exposeGetOutCatalog();
$fontIndex = (int) $font->getFont($inserted['key'])['i'];
$this->assertContainsAllFragments($out, [
'/AcroForm <<',
'/DR << /Font << /F' . $fontIndex . ' ',
'/DA (/F' . $fontIndex . ' 0 Tf 0 g)',
]);
}
/**
* @throws \Throwable
*/
public function testGetOutCatalogAcroformDefaultAppearanceWithoutFonts(): void
{
$obj = $this->getInternalTestObject();
$this->addRawPageWithObjectNumber($obj, 6);
$obj->setOutputState(9, ['pages' => 3, 'xmp' => 4, 'signature' => 40, 'form' => []]);
$this->setObjectProperty($obj, 'sign', true);
$this->setObjectProperty($obj, 'signature', [
'cert_type' => 2,
'approval' => 'P',
'appearance' => [
'empty' => [],
],
]);
$out = $obj->exposeGetOutCatalog();
$this->assertStringContainsString('/AcroForm <<', $out);
$this->assertStringNotContainsString('/DA ', $out);
$this->assertStringNotContainsString('/DR ', $out);
}
/**
* @throws \Throwable
*/
public function testGetPDFLayersWithViewFalseAndLockTrue(): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'objid', [
'catalog' => 7,
'dests' => 0,
'form' => [],
'info' => 0,
'pages' => 0,
'resdic' => 0,
'signature' => 0,
'srgbicc' => 0,
'xmp' => 0,
]);
$this->setObjectProperty($obj, 'pdflayer', [[
'layer' => 'lyr1',
'name' => 'Invisible Layer',
'view' => false,
'lock' => true,
'intent' => '',
'print' => true,
'objid' => 3,
]]);
$out = $obj->exposeGetPDFLayers();
$this->assertContainsAllFragments($out, [
'/OFF [ 3 0 R]',
'/Locked [ 3 0 R]',
]);
}
/**
* @throws \Throwable
*/
public function testGetOutOCGWithPrintAndIntent(): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'pdflayer', [[
'layer' => 'lyr1',
'name' => 'Print Layer',
'view' => true,
'lock' => false,
'intent' => '/View',
'print' => true,
'objid' => 0,
]]);
$out = $obj->exposeGetOutOCG();
$this->assertContainsAllFragments($out, [
'/Intent [/View]',
'/Print << /PrintState /ON >>',
]);
}
/**
* @throws \Throwable
*/
public function testGetOutXObjectsWithNonEmptyOutdata(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
/** @var \Com\Tecnick\Pdf\Font\Stack $font */
$font = $this->getObjectProperty($obj, 'font');
/** @var \Com\Tecnick\Pdf\Encrypt\Encrypt $encrypt */
$encrypt = $this->getObjectProperty($obj, 'encrypt');
/** @var int $pon */
$pon = $this->getObjectProperty($obj, 'pon');
$outfont = new \Com\Tecnick\Pdf\Font\Output($font->getFonts(), $pon, $encrypt);
$this->setObjectProperty($obj, 'outfont', $outfont);
$this->setObjectProperty($obj, 'xobjects', [
'XT1' => [
'id' => 'XT1',
'n' => 1,
'x' => 0.0,
'y' => 0.0,
'w' => 100.0,
'h' => 50.0,
'pheight' => 0.0,
'gheight' => 0.0,
'outdata' => 'q Q',
'spot_colors' => [],
'extgstate' => [],
'gradient' => [],
'font' => [],
'image' => [],
'xobject' => [],
'annotations' => [],
'transparency' => null,
],
]);
$out = $obj->exposeGetOutXObjects();
$this->assertContainsAllFragments($out, [
'/Type /XObject',
'/Subtype /Form',
'/BBox [',
]);
}
/**
* @throws \Throwable
*/
public function testGetOutXObjectsWithTransparencyGroup(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
/** @var \Com\Tecnick\Pdf\Font\Stack $font */
$font = $this->getObjectProperty($obj, 'font');
/** @var \Com\Tecnick\Pdf\Encrypt\Encrypt $encrypt */
$encrypt = $this->getObjectProperty($obj, 'encrypt');
/** @var int $pon */
$pon = $this->getObjectProperty($obj, 'pon');
$outfont = new \Com\Tecnick\Pdf\Font\Output($font->getFonts(), $pon, $encrypt);
$this->setObjectProperty($obj, 'outfont', $outfont);
$this->setObjectProperty($obj, 'xobjects', [
'XT2' => [
'id' => 'XT2',
'n' => 2,
'x' => 0.0,
'y' => 0.0,
'w' => 50.0,
'h' => 25.0,
'pheight' => 0.0,
'gheight' => 0.0,
'outdata' => 'q Q',
'spot_colors' => [],
'extgstate' => [],
'gradient' => [],
'font' => [],
'image' => [],
'xobject' => [],
'annotations' => [],
'transparency' => ['CS' => 'DeviceRGB', 'I' => true, 'K' => false],
],
]);
$out = $obj->exposeGetOutXObjects();
$this->assertContainsAllFragments($out, [
'/Group << /Type /Group /S /Transparency',
'/CS /DeviceRGB',
'/I /true',
'/K /false',
]);
}
/**
* @throws \Throwable
*/
public function testGetOutXObjectsSuppressesTransparencyGroupInPdfx3(): void
{
$obj = new TestableOutput('mm', true, false, true, 'pdfx3');
$this->initFontAndPage($obj);
/** @var \Com\Tecnick\Pdf\Font\Stack $font */
$font = $this->getObjectProperty($obj, 'font');
/** @var \Com\Tecnick\Pdf\Encrypt\Encrypt $encrypt */
$encrypt = $this->getObjectProperty($obj, 'encrypt');
/** @var int $pon */
$pon = $this->getObjectProperty($obj, 'pon');
$outfont = new \Com\Tecnick\Pdf\Font\Output($font->getFonts(), $pon, $encrypt);
$this->setObjectProperty($obj, 'outfont', $outfont);
$this->setObjectProperty($obj, 'xobjects', [
'XT3' => [
'id' => 'XT3',
'n' => 3,
'x' => 0.0,
'y' => 0.0,
'w' => 50.0,
'h' => 25.0,
'pheight' => 0.0,
'gheight' => 0.0,
'outdata' => 'q Q',
'spot_colors' => [],
'extgstate' => [],
'gradient' => [],
'font' => [],
'image' => [],
'xobject' => [],
'annotations' => [],
'transparency' => ['CS' => 'DeviceRGB', 'I' => true, 'K' => false],
],
]);
$out = $obj->exposeGetOutXObjects();
$this->assertStringNotContainsString('/Group << /Type /Group /S /Transparency', $out);
}
/**
* @throws \Throwable
*/
public function testStreamUsesTransparencyDetectsRealAlphaGsButIgnoresTrivialAndOpaqueContent(): void
{
$obj = $this->getInternalTestObject();
$transpNames = ['GS3' => true, 'MSK_A' => true];
// Opaque vector/text content has no gs/Do triggers.
$this->assertFalse($obj->exposeStreamUsesTransparency('0 0 10 10 re f BT /F1 12 Tf (Hi) Tj ET', $transpNames));
// A gs that references a non-transparency ExtGState (e.g. an opaque
// reset such as /GS1 with /ca 1 /CA 1) must NOT count as transparency.
$this->assertFalse($obj->exposeStreamUsesTransparency('/GS1 gs 0 0 10 10 re f', $transpNames));
// A gs that references a real-transparency ExtGState counts.
$this->assertTrue($obj->exposeStreamUsesTransparency('/GS3 gs 0 0 10 10 re f', $transpNames));
// An SVG soft-mask graphics state counts.
$this->assertTrue($obj->exposeStreamUsesTransparency('/MSK_A gs 0 0 10 10 re f', $transpNames));
// A bare /GSn token of "GS31" must not be matched by the "GS3" name.
$this->assertFalse($obj->exposeStreamUsesTransparency('/GS31 gs 0 0 10 10 re f', $transpNames));
}
/**
* @throws \Throwable
*/
public function testStreamUsesTransparencyDetectsSoftMaskedImagesAndImportsButNotOpaqueImages(): void
{
$obj = $this->getInternalTestObject();
// A plain (opaque) image does not need a transparency group.
$this->assertFalse($obj->exposeStreamUsesTransparency('q 1 0 0 1 0 0 cm /IMG5 Do Q', []));
// Soft-masked image variants carry an alpha channel.
$this->assertTrue($obj->exposeStreamUsesTransparency('q 1 0 0 1 0 0 cm /IMGmask5 Do Q', []));
$this->assertTrue($obj->exposeStreamUsesTransparency('q 1 0 0 1 0 0 cm /IMGplain5 Do Q', []));
// Imported pages are treated conservatively as potentially transparent.
$this->assertTrue($obj->exposeStreamUsesTransparency('q 1 0 0 1 0 0 cm /IMP12 Do Q', []));
}
/**
* @throws \Throwable
*/
public function testStreamUsesTransparencyFollowsTransparencyGroupXObjects(): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'xobjects', [
'XG' => [
'spot_colors' => [],
'extgstate' => [],
'gradient' => [],
'font' => [],
'image' => [],
'xobject' => [],
'annotations' => [],
'id' => 'XG',
'n' => 7,
'x' => 0.0,
'y' => 0.0,
'w' => 10.0,
'h' => 10.0,
'outdata' => 'q Q',
'pheight' => 0.0,
'gheight' => 0.0,
'transparency' => ['CS' => 'DeviceRGB', 'I' => true, 'K' => false],
],
'XO' => [
'spot_colors' => [],
'extgstate' => [],
'gradient' => [],
'font' => [],
'image' => [],
'xobject' => [],
'annotations' => [],
'id' => 'XO',
'n' => 8,
'x' => 0.0,
'y' => 0.0,
'w' => 10.0,
'h' => 10.0,
'outdata' => '/GS3 gs 0 0 10 10 re f',
'pheight' => 0.0,
'gheight' => 0.0,
],
]);
// A page that paints a Form XObject declaring its own transparency group.
$this->assertTrue($obj->exposeStreamUsesTransparency('q /XG Do Q', []));
// A page that paints a Form XObject which blends internally (its own
// stream references a real-transparency gs) is detected via recursion.
$this->assertTrue($obj->exposeStreamUsesTransparency('q /XO Do Q', ['GS3' => true]));
// The same opaque page with no transparency-bearing references stays flat.
$this->assertFalse($obj->exposeStreamUsesTransparency('q /XO Do Q', []));
}
/**
* @throws \Throwable
*/
public function testGetOutEmbeddedFilesWithContent(): void
{
$obj = $this->getInternalTestObject();
$obj->addContentAsEmbeddedFile('hello.txt', 'Hello World!', 'text/plain', 'Source', 'Test file');
$out = $obj->exposeGetOutEmbeddedFiles();
$this->assertContainsAllFragments($out, [
'/Type /Filespec',
'/Type /EmbeddedFile',
'/AFRelationship /Source',
]);
}
/**
* The embedded file name must be a plain (PDFDocEncoded) ASCII string in /F,
* not a BOM-less UTF-16BE string, otherwise ZUGFeRD/Factur-X validators and
* PDF readers cannot resolve the attachment ("InputStream cannot be null").
* The Unicode /UF variant must carry the mandatory UTF-16BE byte order mark.
*
* @throws \Throwable
*/
public function testGetOutEmbeddedFilesNameEncoding(): void
{
$obj = $this->getInternalTestObject();
$obj->setPdfaMode(3);
$obj->addContentAsEmbeddedFile('factur-x.xml', '<x/>', 'text/xml', 'Alternative');
$out = $obj->exposeGetOutEmbeddedFiles();
// /F holds the plain ASCII name (no UTF-16BE null padding).
$this->assertStringContainsString('/F (factur-x.xml)', $out);
// /UF holds the UTF-16BE name with the leading BOM.
$this->assertStringContainsString("/UF (\xFE\xFF\x00f\x00a\x00c\x00t", $out);
// text/xml MIME yields the text#2Fxml subtype expected by validators.
$this->assertStringContainsString('/Subtype /text#2Fxml', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutEmbeddedFilesSkippedInPdfa1And2(): void
{
$obj = $this->getInternalTestObject();
$obj->setPdfaMode(1);
$out = $obj->exposeGetOutEmbeddedFiles();
$this->assertSame('', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutAnnotationsWithTextAnnotation(): void
{
$obj = $this->getInternalTestObject();
$pageInfo = $this->initFontAndPage($obj);
/** @var \Com\Tecnick\Pdf\Page\Page $pageObj */
$pageObj = $this->getObjectProperty($obj, 'page');
/** @var array<int, array<string, mixed>> $pgdata */
$pgdata = $this->getObjectProperty($pageObj, 'page');
$pgdata[$pageInfo['pid']]['n'] = 5;
$pgdata[$pageInfo['pid']]['num'] = 1;
$this->setObjectProperty($pageObj, 'page', $pgdata);
$aoid = $obj->setAnnotation(10.0, 20.0, 50.0, 10.0, 'Test note', ['subtype' => 'text']);
/** @var \Com\Tecnick\Pdf\Page\Page $page */
$page = $this->getObjectProperty($obj, 'page');
$page->addAnnotRef($aoid);
/** @var \Com\Tecnick\Pdf\Font\Stack $font */
$font = $this->getObjectProperty($obj, 'font');
/** @var \Com\Tecnick\Pdf\Encrypt\Encrypt $encrypt */
$encrypt = $this->getObjectProperty($obj, 'encrypt');
/** @var int $pon */
$pon = $this->getObjectProperty($obj, 'pon');
$outfont = new \Com\Tecnick\Pdf\Font\Output($font->getFonts(), $pon, $encrypt);
$this->setObjectProperty($obj, 'outfont', $outfont);
$out = $obj->exposeGetOutAnnotations();
$this->assertContainsAllFragments($out, [
'/Type /Annot',
'/Subtype /text',
'/Rect [',
'/Contents ',
]);
}
/**
* @throws \Throwable
*/
public function testGetOutAnnotationsWithLinkAnnotation(): void
{
$obj = $this->getInternalTestObject();
$pageInfo = $this->initFontAndPage($obj);
/** @var \Com\Tecnick\Pdf\Page\Page $pageObj */
$pageObj = $this->getObjectProperty($obj, 'page');
/** @var array<int, array<string, mixed>> $pgdata */
$pgdata = $this->getObjectProperty($pageObj, 'page');
$pgdata[$pageInfo['pid']]['n'] = 5;
$pgdata[$pageInfo['pid']]['num'] = 1;
$this->setObjectProperty($pageObj, 'page', $pgdata);
$aoid = $obj->setAnnotation(10.0, 20.0, 50.0, 10.0, 'https://example.com', ['subtype' => 'Link']);
/** @var \Com\Tecnick\Pdf\Page\Page $page */
$page = $this->getObjectProperty($obj, 'page');
$page->addAnnotRef($aoid);
/** @var \Com\Tecnick\Pdf\Font\Stack $font */
$font = $this->getObjectProperty($obj, 'font');
/** @var \Com\Tecnick\Pdf\Encrypt\Encrypt $encrypt */
$encrypt = $this->getObjectProperty($obj, 'encrypt');
/** @var int $pon */
$pon = $this->getObjectProperty($obj, 'pon');
$outfont = new \Com\Tecnick\Pdf\Font\Output($font->getFonts(), $pon, $encrypt);
$this->setObjectProperty($obj, 'outfont', $outfont);
$out = $obj->exposeGetOutAnnotations();
$this->assertStringContainsString('/Subtype /Link', $out);
$this->assertStringNotContainsString('/Contents ', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutAnnotationsLinkAnnotationIncludesContentsInPdfuaMode(): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'pdfuaMode', 'pdfua1');
$pageInfo = $this->initFontAndPage($obj);
/** @var \Com\Tecnick\Pdf\Page\Page $pageObj */
$pageObj = $this->getObjectProperty($obj, 'page');
/** @var array<int, array<string, mixed>> $pgdata */
$pgdata = $this->getObjectProperty($pageObj, 'page');
$pgdata[$pageInfo['pid']]['n'] = 5;
$pgdata[$pageInfo['pid']]['num'] = 1;
$this->setObjectProperty($pageObj, 'page', $pgdata);
$aoid = $obj->setAnnotation(10.0, 20.0, 50.0, 10.0, 'https://example.com', ['subtype' => 'Link']);
/** @var \Com\Tecnick\Pdf\Page\Page $page */
$page = $this->getObjectProperty($obj, 'page');
$page->addAnnotRef($aoid);
/** @var \Com\Tecnick\Pdf\Font\Stack $font */
$font = $this->getObjectProperty($obj, 'font');
/** @var \Com\Tecnick\Pdf\Encrypt\Encrypt $encrypt */
$encrypt = $this->getObjectProperty($obj, 'encrypt');
/** @var int $pon */
$pon = $this->getObjectProperty($obj, 'pon');
$outfont = new \Com\Tecnick\Pdf\Font\Output($font->getFonts(), $pon, $encrypt);
$this->setObjectProperty($obj, 'outfont', $outfont);
$out = $obj->exposeGetOutAnnotations();
$this->assertStringContainsString('/Subtype /Link', $out);
$this->assertStringContainsString('/Contents ', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutAnnotationsWithFormFieldAnnotation(): void
{
$obj = $this->getInternalTestObject();
$pageInfo = $this->initFontAndPage($obj);
/** @var \Com\Tecnick\Pdf\Page\Page $pageObj */
$pageObj = $this->getObjectProperty($obj, 'page');
/** @var array<int, array<string, mixed>> $pgdata */
$pgdata = $this->getObjectProperty($pageObj, 'page');
$pgdata[$pageInfo['pid']]['n'] = 5;
$pgdata[$pageInfo['pid']]['num'] = 1;
$this->setObjectProperty($pageObj, 'page', $pgdata);
$setAnnotation = new \ReflectionMethod($obj, 'setAnnotation');
/** @var int $aoid */
$aoid = $setAnnotation->invokeArgs($obj, [
5.0,
10.0,
80.0,
12.0,
'myfield',
['subtype' => 'Widget', 'ft' => 'Tx'],
]);
/** @var \Com\Tecnick\Pdf\Page\Page $page */
$page = $this->getObjectProperty($obj, 'page');
$page->addAnnotRef($aoid);
/** @var \Com\Tecnick\Pdf\Font\Stack $font */
$font = $this->getObjectProperty($obj, 'font');
/** @var \Com\Tecnick\Pdf\Encrypt\Encrypt $encrypt */
$encrypt = $this->getObjectProperty($obj, 'encrypt');
/** @var int $pon */
$pon = $this->getObjectProperty($obj, 'pon');
$outfont = new \Com\Tecnick\Pdf\Font\Output($font->getFonts(), $pon, $encrypt);
$this->setObjectProperty($obj, 'outfont', $outfont);
$out = $obj->exposeGetOutAnnotations();
$this->assertStringContainsString('/FT /Tx', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutAnnotationsWithColorOption(): void
{
$obj = $this->getInternalTestObject();
$pageInfo = $this->initFontAndPage($obj);
/** @var \Com\Tecnick\Pdf\Page\Page $pageObj */
$pageObj = $this->getObjectProperty($obj, 'page');
/** @var array<int, array<string, mixed>> $pgdata */
$pgdata = $this->getObjectProperty($pageObj, 'page');
$pgdata[$pageInfo['pid']]['n'] = 5;
$pgdata[$pageInfo['pid']]['num'] = 1;
$this->setObjectProperty($pageObj, 'page', $pgdata);
$setAnnotation = new \ReflectionMethod($obj, 'setAnnotation');
/** @var int $aoid */
$aoid = $setAnnotation->invokeArgs($obj, [
5.0,
10.0,
80.0,
12.0,
'Colored note',
['subtype' => 'text', 'c' => '#FF0000'],
]);
/** @var \Com\Tecnick\Pdf\Page\Page $page */
$page = $this->getObjectProperty($obj, 'page');
$page->addAnnotRef($aoid);
/** @var \Com\Tecnick\Pdf\Font\Stack $font */
$font = $this->getObjectProperty($obj, 'font');
/** @var \Com\Tecnick\Pdf\Encrypt\Encrypt $encrypt */
$encrypt = $this->getObjectProperty($obj, 'encrypt');
/** @var int $pon */
$pon = $this->getObjectProperty($obj, 'pon');
$outfont = new \Com\Tecnick\Pdf\Font\Output($font->getFonts(), $pon, $encrypt);
$this->setObjectProperty($obj, 'outfont', $outfont);
$out = $obj->exposeGetOutAnnotations();
$this->assertStringContainsString('/C [', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutBookmarksWithAtLinkType(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$page = $this->addRawPageWithObjectNumber($obj, 5);
$this->setObjectProperty($obj, 'links', ['@1' => ['p' => $page['pid'], 'y' => 10.0]]);
$obj->setBookmark('Linked Section', '@1', 0, $page['pid']);
$out = $obj->exposeGetOutBookmarks();
$this->assertStringContainsString('/Dest [', $out);
$this->assertStringContainsString('/XYZ 0 ', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutBookmarksWithStarEmbeddedFileLink(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$page = $this->addRawPageWithObjectNumber($obj, 5);
$this->setObjectProperty($obj, 'embeddedfiles', [
'report.bin' => [
'a' => 2,
'f' => 3,
'n' => 4,
'file' => '',
'content' => 'data',
'mimeType' => 'application/octet-stream',
'afRelationship' => 'Source',
'description' => '',
'creationDate' => 0,
'modDate' => 0,
],
]);
$obj->setBookmark('Embedded File', '*report.bin', 0, $page['pid']);
$out = $obj->exposeGetOutBookmarks();
$this->assertStringContainsString('/S /JavaScript', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutBookmarksWithStarEmbeddedFileLinkOmitsJavascriptInPdfuaMode(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$this->setObjectProperty($obj, 'pdfuaMode', 'pdfua1');
$page = $this->addRawPageWithObjectNumber($obj, 5);
$this->setObjectProperty($obj, 'embeddedfiles', [
'report.bin' => [
'a' => 2,
'f' => 3,
'n' => 4,
'file' => '',
'content' => 'data',
'mimeType' => 'application/octet-stream',
'afRelationship' => 'Source',
'description' => '',
'creationDate' => 0,
'modDate' => 0,
],
]);
$obj->setBookmark('Embedded File', '*report.bin', 0, $page['pid']);
$out = $obj->exposeGetOutBookmarks();
$this->assertStringNotContainsString('/S /JavaScript', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutBookmarksWithStarEmbeddedFileLinkOmitsJavascriptInPdfxMode(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$this->setObjectProperty($obj, 'pdfx', true);
$page = $this->addRawPageWithObjectNumber($obj, 5);
$this->setObjectProperty($obj, 'embeddedfiles', [
'report.bin' => [
'a' => 2,
'f' => 3,
'n' => 4,
'file' => '',
'content' => 'data',
'mimeType' => 'application/octet-stream',
'afRelationship' => 'Source',
'description' => '',
'creationDate' => 0,
'modDate' => 0,
],
]);
$obj->setBookmark('Embedded File', '*report.bin', 0, $page['pid']);
$out = $obj->exposeGetOutBookmarks();
$this->assertStringNotContainsString('/S /JavaScript', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutBookmarksWithPercentEmbeddedPdfLink(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$page = $this->addRawPageWithObjectNumber($obj, 7);
$this->setObjectProperty($obj, 'embeddedfiles', [
'manual.pdf' => [
'a' => 5,
'f' => 3,
'n' => 4,
'file' => '',
'content' => 'data',
'mimeType' => 'application/pdf',
'afRelationship' => 'Source',
'description' => 'Embedded PDF',
'creationDate' => 0,
'modDate' => 0,
],
]);
$obj->setBookmark('Embedded PDF', '%manual.pdf', 0, $page['pid']);
$out = $obj->exposeGetOutBookmarks();
$this->assertStringContainsString('/S /GoToE', $out);
$this->assertStringContainsString('/A 5', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutBookmarksWithPercentEmbeddedPdfLinkOmitsGotoeInPdfxMode(): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'pdfx', true);
$this->initFontAndPage($obj);
$page = $this->addRawPageWithObjectNumber($obj, 7);
$this->setObjectProperty($obj, 'embeddedfiles', [
'manual.pdf' => [
'a' => 5,
'f' => 3,
'n' => 4,
'file' => '',
'content' => 'data',
'mimeType' => 'application/pdf',
'afRelationship' => 'Source',
'description' => 'Embedded PDF',
'creationDate' => 0,
'modDate' => 0,
],
]);
$obj->setBookmark('Embedded PDF', '%manual.pdf', 0, $page['pid']);
$out = $obj->exposeGetOutBookmarks();
$this->assertStringNotContainsString('/S /GoToE', $out);
$this->assertStringNotContainsString('/A 5', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutBookmarksWithExternalUriLink(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$page = $this->addRawPageWithObjectNumber($obj, 6);
$obj->setBookmark('External Site', 'https://example.com/docs?a=1&b=2', 0, $page['pid']);
$out = $obj->exposeGetOutBookmarks();
$this->assertStringContainsString('/S /URI', $out);
$this->assertStringContainsString('/URI ', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutBookmarksWithExternalUriLinkOmitsUriActionInPdfxMode(): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'pdfx', true);
$this->initFontAndPage($obj);
$page = $this->addRawPageWithObjectNumber($obj, 6);
$obj->setBookmark('External Site', 'https://example.com/docs?a=1&b=2', 0, $page['pid']);
$out = $obj->exposeGetOutBookmarks();
$this->assertStringNotContainsString('/S /URI', $out);
$this->assertStringNotContainsString('/URI ', $out);
}
/**
* @throws \Throwable
*/
#[DataProvider('pdfxModeProvider')]
public function testPdfxModeMatrixSuppressesInteractiveBookmarkActions(string $mode): void
{
$obj = new TestableOutput('mm', true, false, true, $mode);
$this->initFontAndPage($obj);
$page = $this->addRawPageWithObjectNumber($obj, 7);
$this->setObjectProperty($obj, 'embeddedfiles', [
'manual.pdf' => [
'a' => 5,
'f' => 3,
'n' => 4,
'file' => '',
'content' => 'data',
'mimeType' => 'application/pdf',
'afRelationship' => 'Source',
'description' => 'Embedded PDF',
'creationDate' => 0,
'modDate' => 0,
],
]);
$obj->setBookmark('External Site', 'https://example.com/docs?a=1&b=2', 0, $page['pid']);
$obj->setBookmark('Embedded PDF', '%manual.pdf', 0, $page['pid']);
$out = $obj->exposeGetOutBookmarks();
$this->assertStringNotContainsString('/S /URI', $out);
$this->assertStringNotContainsString('/URI ', $out);
$this->assertStringNotContainsString('/S /GoToE', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutBookmarksWithNoUrlUsesPageDest(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$page = $this->addRawPageWithObjectNumber($obj, 8);
$obj->setBookmark('Page Section', '', 0, $page['pid'], 5.0, 10.0);
$out = $obj->exposeGetOutBookmarks();
$this->assertStringContainsString('/Dest [', $out);
$this->assertStringContainsString('/XYZ ', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutBookmarksIncludesFirstAndLastForParentsWithChildren(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$page = $this->addRawPageWithObjectNumber($obj, 10);
$obj->setBookmark('Parent', '', 0, $page['pid']);
$obj->setBookmark('Child 1', '', 1, $page['pid']);
$obj->setBookmark('Child 2', '', 1, $page['pid']);
$out = $obj->exposeGetOutBookmarks();
$this->assertStringContainsString('/First ', $out);
$this->assertStringContainsString('/Last ', $out);
}
/** @return array<string, array{0: string}> */
public static function pdfxModeProvider(): array
{
return [
'pdfx_alias' => ['pdfx'],
'pdfx1a' => ['pdfx1a'],
'pdfx3' => ['pdfx3'],
'pdfx4' => ['pdfx4'],
'pdfx5' => ['pdfx5'],
];
}
/**
* @throws \Throwable
*/
public function testGetOutJavascriptWithAddFieldTriggersWrapper(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$this->setObjectProperty($obj, 'javascript', "var f=1;\nthis.addField('x','text',0,[0,0,100,50]);");
$out = $obj->exposeGetOutJavascript();
/** @var string $jsmod */
$jsmod = $this->getObjectProperty($obj, 'javascript');
$this->assertStringContainsString('ftcpdfdocsaved', $jsmod);
$this->assertStringContainsString('/S /JavaScript', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutJavascriptReturnsEmptyInPdfuaMode(): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'pdfuaMode', 'pdfua1');
$this->setObjectProperty($obj, 'javascript', 'app.alert("hi");');
$this->setObjectProperty($obj, 'jsobjects', [[
'n' => 1,
'js' => 'console.println("x")',
'onload' => true,
]]);
$this->assertSame('', $obj->exposeGetOutJavascript());
}
/**
* @throws \Throwable
*/
public function testGetOutJavascriptReturnsEmptyInPdfxMode(): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'pdfx', true);
$this->setObjectProperty($obj, 'javascript', 'app.alert("hi");');
$this->setObjectProperty($obj, 'jsobjects', [[
'n' => 1,
'js' => 'console.println("x")',
'onload' => true,
]]);
$this->assertSame('', $obj->exposeGetOutJavascript());
}
/**
* @throws \Throwable
*/
public function testGetOutSignatureUserRightsWithAllFields(): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'userrights', [
'enabled' => true,
'document' => '/FullSave',
'form' => '/Add /FillIn',
'signature' => '/Modify',
'annots' => '/Create /Delete /Modify /Copy /Import /Export',
'ef' => '/Create /Delete /Modify /Import',
'formex' => '',
]);
$out = $obj->exposeGetOutSignatureUserRights();
$this->assertContainsAllFragments($out, [
'/TransformMethod /UR3',
'/Document[/FullSave]',
'/Form[/Add /FillIn]',
'/Signature[/Modify]',
'/Annots[/Create /Delete /Modify /Copy /Import /Export]',
'/EF[/Create /Delete /Modify /Import]',
]);
}
/**
* @throws \Throwable
*/
public function testGetOutSignatureFieldsWithAppearanceEntries(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$page = $this->addRawPageWithObjectNumber($obj, 9);
$this->setObjectProperty($obj, 'signature', [
'appearance' => [
'empty' => [[
'page' => $page['pid'],
'name' => 'ApprovalSig',
'objid' => 91,
'rect' => '10 20 30 40',
]],
],
]);
$out = $obj->exposeGetOutSignatureFields();
$this->assertContainsAllFragments($out, [
'/Subtype /Widget',
'/FT /Sig',
'/T ',
]);
}
/**
* @throws \Throwable
*/
public function testGetOutSignatureFieldsReturnsEmptyWithEmptySignatureArray(): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'signature', []);
$this->assertSame('', $obj->exposeGetOutSignatureFields());
}
/**
* @throws \Throwable
*/
public function testGetOutSignatureWithDocMdpAndApprovalModes(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$page = $this->addRawPageWithObjectNumber($obj, 11);
$this->setObjectProperty($obj, 'sign', true);
$this->setObjectProperty($obj, 'objid', ['signature' => 70]);
$this->setObjectProperty($obj, 'signature', [
'cert_type' => 2,
'approval' => 'P',
'appearance' => [
'page' => $page['pid'],
'rect' => '5 15 45 25',
'name' => 'SigMain',
],
'info' => ['Name' => 'Tester'],
]);
$outWithRef = $obj->exposeGetOutSignature();
$this->assertContainsAllFragments($outWithRef, [
'/Type /Sig',
'/TransformMethod /DocMDP',
'/Reference [ << /Type /SigRef',
]);
$this->setObjectProperty($obj, 'signature', [
'cert_type' => 2,
'approval' => 'A',
'appearance' => [
'page' => $page['pid'],
'rect' => '5 15 45 25',
'name' => 'SigMain',
],
'info' => [],
]);
$outApproval = $obj->exposeGetOutSignature();
$this->assertStringNotContainsString('/Reference [ << /Type /SigRef', $outApproval);
}
/**
* @throws \Throwable
*/
public function testGetOutSignatureWithUserRightsReferenceWhenCertTypeZero(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$page = $this->addRawPageWithObjectNumber($obj, 13);
$this->setObjectProperty($obj, 'sign', true);
$this->setObjectProperty($obj, 'objid', ['signature' => 80]);
$this->setObjectProperty($obj, 'signature', [
'cert_type' => 0,
'approval' => 'P',
'appearance' => [
'page' => $page['pid'],
'rect' => '6 16 46 26',
'name' => 'SigUR3',
],
'info' => [],
]);
$out = $obj->exposeGetOutSignature();
$this->assertStringContainsString('/TransformMethod /UR3', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutSignatureDocMDPReturnsEmptyWhenCertTypeMissing(): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'signature', []);
$this->assertSame('', $obj->exposeGetOutSignatureDocMDP());
}
/**
* @throws \Throwable
*/
public function testGetOutSignatureUserRightsWithFormExField(): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'userrights', [
'enabled' => true,
'document' => '',
'form' => '',
'signature' => '',
'annots' => '',
'ef' => '',
'formex' => '/BarcodePlaintext',
]);
$out = $obj->exposeGetOutSignatureUserRights();
$this->assertStringContainsString('/FormEX[/BarcodePlaintext]', $out);
}
/**
* The /Sig value object (emitted through the package Output\Signature) carries
* every configured info field, encoded with the host text encoder.
*
* @throws \Throwable
*/
public function testGetOutSignatureIncludesAllInfoFields(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$page = $this->addRawPageWithObjectNumber($obj, 15);
$this->setObjectProperty($obj, 'sign', true);
$this->setObjectProperty($obj, 'objid', ['signature' => 90]);
$this->setObjectProperty($obj, 'signature', [
'cert_type' => 2,
'approval' => 'P',
'appearance' => ['page' => $page['pid'], 'rect' => '5 15 45 25', 'name' => 'SigInfo'],
'info' => ['Name' => 'N', 'Location' => 'L', 'Reason' => 'R', 'ContactInfo' => 'C'],
]);
$out = $obj->exposeGetOutSignature();
$this->assertContainsAllFragments($out, [
'/Name ',
'/Location ',
'/Reason ',
'/ContactInfo ',
]);
}
/**
* @throws \Throwable
*/
public function testEndToEndOutputIncludesSignatureTsaAndDssAcrossRevisions(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$page = $this->addRawPageWithObjectNumber($obj, 14);
$certPath = (string) \realpath(__DIR__ . '/../examples/data/cert/tcpdf.crt');
$certFile = 'file://' . $certPath;
$obj->setSignature([
'appearance' => [
'empty' => [],
'name' => 'SigE2E',
'page' => $page['pid'],
'rect' => '10 20 50 30',
],
'approval' => '',
'cert_type' => 2,
'extracerts' => null,
'info' => [
'ContactInfo' => 'contact@example.test',
'Location' => 'Lab',
'Name' => 'Signer',
'Reason' => 'End-to-end test',
],
'password' => '',
'privkey' => $certFile,
'signcert' => $certFile,
'ltv' => [
'enabled' => true,
'embed_ocsp' => false,
'embed_crl' => false,
'embed_certs' => true,
'include_dss' => true,
'include_vri' => true,
],
]);
$obj->setSignTimeStamp([
'enabled' => true,
'host' => 'https://tsa.example.test',
'username' => '',
'password' => '',
'cert' => '',
'hash_algorithm' => 'sha256',
'policy_oid' => '',
'nonce_enabled' => false,
'timeout' => 5,
'verify_peer' => false,
]);
// SEQUENCE { SEQUENCE { INTEGER 0 }, SEQUENCE { INTEGER 42 } }
$obj->setMockTimestampResponse((string) \hex2bin('300A3003020100300302012A'));
$pdf = $obj->getOutPDFString();
$this->assertContainsAllFragments($pdf, [
'/Type /Sig',
'/SubFilter /adbe.pkcs7.detached',
'/TransformMethod /DocMDP',
'/Type /DSS',
'/VRI <<',
'/Certs [',
'/DSS ',
'startxref',
'%%EOF',
]);
// The signature is in the first revision; the DSS is appended in a second.
$this->assertSame(2, \substr_count($pdf, '%%EOF'));
$firstEof = \strpos($pdf, '%%EOF');
$this->assertIsInt($firstEof);
$this->assertStringNotContainsString('/Type /DSS', \substr($pdf, 0, $firstEof));
$this->assertStringContainsString('/Type /DSS', \substr($pdf, $firstEof));
$this->assertMatchesRegularExpression('#/ByteRange\[0 \d+ \d+ \d+\]#', $pdf);
$this->assertStringNotContainsString('**********', $pdf);
$this->assertStringStartsWith("\x30", $obj->getCapturedTimestampRequest());
}
/**
* PAdES B-LTA: on top of the signature (B-T) and DSS (B-LT) revisions, a third
* incremental revision adds a /Type /DocTimeStamp archive timestamp (an
* invisible signature field) whose /Contents is a bare RFC 3161 token over its
* own ByteRange.
*
* @throws \Throwable
*/
public function testGetOutPDFStringAppendsBltaDocTimeStampRevision(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$page = $this->addRawPageWithObjectNumber($obj, 14);
$certPath = (string) \realpath(__DIR__ . '/../examples/data/cert/tcpdf.crt');
$certFile = 'file://' . $certPath;
$obj->setSignature([
'appearance' => ['empty' => [], 'name' => 'SigLTA', 'page' => $page['pid'], 'rect' => '10 20 50 30'],
'approval' => '',
'cert_type' => 2,
'extracerts' => null,
'info' => ['ContactInfo' => '', 'Location' => '', 'Name' => '', 'Reason' => ''],
'password' => '',
'privkey' => $certFile,
'signcert' => $certFile,
'profile' => 'pades-b-lta',
'ltv' => [
'enabled' => true,
'embed_ocsp' => false,
'embed_crl' => false,
'embed_certs' => true,
'include_dss' => true,
'include_vri' => true,
],
]);
$obj->setSignTimeStamp([
'enabled' => true,
'host' => 'https://tsa.example.test',
'username' => '',
'password' => '',
'cert' => '',
'hash_algorithm' => 'sha256',
'policy_oid' => '',
'nonce_enabled' => false,
'timeout' => 5,
'verify_peer' => false,
]);
// SEQUENCE { SEQUENCE { INTEGER 0 }, SEQUENCE { INTEGER 42 } }
$obj->setMockTimestampResponse((string) \hex2bin('300A3003020100300302012A'));
$pdf = $obj->getOutPDFString();
// Three revisions: signature (B-T), DSS (B-LT), archive timestamp (B-LTA).
$this->assertSame(3, \substr_count($pdf, '%%EOF'));
$this->assertSame(3, \substr_count($pdf, "startxref\n"));
// A PAdES profile uses the CAdES subfilter for the main signature.
$this->assertStringContainsString('/SubFilter /ETSI.CAdES.detached', $pdf);
// The DocTimeStamp lives only in the third (last) revision.
$eofs = [];
$pos = -1;
while (($pos = \strpos($pdf, '%%EOF', $pos + 1)) !== false) {
$eofs[] = $pos;
}
$secondEof = $eofs[1] ?? 0;
$lastRevision = \substr($pdf, $secondEof);
$this->assertStringNotContainsString('/Type /DocTimeStamp', \substr($pdf, 0, $secondEof));
$this->assertStringContainsString('/Type /DocTimeStamp', $lastRevision);
$this->assertStringContainsString('/SubFilter /ETSI.RFC3161', $lastRevision);
// Its ByteRange is filled (no placeholder left anywhere) and the DSS is kept.
$this->assertStringNotContainsString('**********', $pdf);
$this->assertStringContainsString('/Type /DSS', $pdf);
// Two filled ByteRanges now exist: the main signature and the document timestamp.
$this->assertSame(2, \preg_match_all('#/ByteRange\[0 \d+ \d+ \d+\]#', $pdf));
// The archive-timestamp widget is added to the AcroForm as a second field.
$m = [];
$matched = \preg_match('#/AcroForm << /Fields \[([^\]]*)\]#', $lastRevision, $m);
$this->assertSame(1, $matched);
$this->assertSame(2, \substr_count($m[1] ?? '', ' 0 R'));
}
/**
* @throws \Throwable
*/
public function testSavePDFThrowsWhenDirectoryDoesNotExist(): void
{
$obj = $this->getTestObject();
$obj->setPDFFilename('output.pdf');
$this->expectException(\Throwable::class);
$obj->savePDF('/path/that/does/not/exist/at/all', 'data');
}
/**
* @throws \Throwable
*/
public function testGetOutAnnotationMarkupsWithRcAndCa(): void
{
$obj = $this->getInternalTestObject();
$out = $obj->exposeGetOutAnnotationMarkups([
'opt' => [
'subtype' => 'ink',
'rc' => 'Rich content',
'ca' => 0.75,
],
], 5);
$this->assertContainsAllFragments($out, [
' /RC ',
' /CA 0.750000',
' /CreationDate ',
]);
}
/**
* @throws \Throwable
*/
public function testGetOutAnnotationMarkupsIgnoresNonMarkupSubtype(): void
{
$obj = $this->getInternalTestObject();
$out = $obj->exposeGetOutAnnotationMarkups([
'opt' => ['subtype' => 'Link'],
], 5);
$this->assertSame('', $out);
}
/**
* @throws \Throwable
*/
public function testOutputAdditionalAnnotationBranches(): void
{
$obj = $this->getInternalTestObject();
$pageInfo = $this->initFontAndPage($obj);
/** @var \Com\Tecnick\Pdf\Page\Page $pageObj */
$pageObj = $this->getObjectProperty($obj, 'page');
$pageObj->addAnnotRef(999, $pageInfo['pid']);
$this->assertSame('', $obj->exposeGetOutAnnotations());
$missingAttachment = $obj->exposeGetOutAnnotationOptSubtypeFileattachment([
'opt' => ['fs' => 'missing.bin'],
], 1);
$this->assertSame('', $missingAttachment);
$this->setObjectProperty($obj, 'embeddedfiles', ['doc.txt' => ['f' => 7]]);
$defaultAttachIcon = $obj->exposeGetOutAnnotationOptSubtypeFileattachment([
'opt' => ['fs' => 'doc.txt', 'name' => 'UnknownIcon'],
], 2);
$this->assertContainsAllFragments($defaultAttachIcon, [
' /FS 7 0 R',
' /Name /PushPin',
]);
$missingSound = $obj->exposeGetOutAnnotationOptSubtypeSound([
'opt' => ['fs' => 'missing.wav'],
]);
$this->assertSame('', $missingSound);
$this->setObjectProperty($obj, 'embeddedfiles', ['snd.wav' => ['f' => 9]]);
$defaultSoundIcon = $obj->exposeGetOutAnnotationOptSubtypeSound([
'opt' => ['fs' => 'snd.wav', 'name' => 'UnknownMic'],
]);
$this->assertContainsAllFragments($defaultSoundIcon, [
' /Sound 9 0 R',
' /Name /Speaker',
]);
$widget = $obj->exposeGetOutAnnotationOptSubtypeWidget([
'txt' => 'field-mixed',
'opt' => [
'h' => 'I',
'v' => ['A', new \stdClass(), 1],
'dv' => ['B', new \stdClass(), 2],
'rv' => ['C', new \stdClass(), 3],
],
], 31);
$this->assertContainsAllFragments($widget, [
' /V A 1.000000',
' /DV B 2.000000',
' /RV C 3.000000',
]);
[$appearanceState, $appearanceXObject] = $obj->exposeGetAnnotationAppearanceStream(
[
'opt' => [
'ap' => [
'n' => ['On' => 123, 'Off' => 'q 1 0 0 1 0 0 cm Q'],
],
],
],
10,
5,
);
$this->assertContainsAllFragments($appearanceState, [
' /AP <<',
'/Off',
]);
$this->assertContainsAllFragments($appearanceXObject, [
'/Subtype /Form',
]);
}
/**
* @throws \Throwable
*/
public function testOutputAdditionalEmbeddedFileBranches(): void
{
$obj = $this->getInternalTestObject();
$tmpFile = \tempnam(\sys_get_temp_dir(), 'tc-out-ef-');
$this->assertNotFalse($tmpFile);
\file_put_contents($tmpFile, 'embedded-content');
try {
$this->setPdfaModeOnObject($obj, 3);
$this->setObjectProperty($obj, 'embeddedfiles', [
'plain.txt' => [
'a' => 0,
'f' => 11,
'n' => 12,
'file' => $tmpFile,
'content' => '',
'mimeType' => 'text/plain',
'afRelationship' => 'Source',
'description' => 'desc',
'creationDate' => \time(),
'modDate' => \time(),
],
]);
$pdfa3Out = $obj->exposeGetOutEmbeddedFiles();
$this->assertStringContainsString('/Subtype /text#2Fplain', $pdfa3Out);
$this->setPdfaModeOnObject($obj, 0);
$this->setObjectProperty($obj, 'embeddedfiles', [
'plain2.txt' => [
'a' => 0,
'f' => 21,
'n' => 22,
'file' => $tmpFile,
'content' => '',
'mimeType' => 'text/plain',
'afRelationship' => 'Source',
'description' => 'desc2',
'creationDate' => \time(),
'modDate' => \time(),
],
]);
$compressedOut = $obj->exposeGetOutEmbeddedFiles();
$this->assertStringContainsString('/Filter /FlateDecode', $compressedOut);
} finally {
if (\file_exists($tmpFile)) {
\unlink($tmpFile);
}
}
}
/**
* @throws \Throwable
*/
public function testGetOutEmbeddedFilesSkipsEmptyFiles(): void
{
$obj = $this->getInternalTestObject();
$tmpFile = \tempnam(\sys_get_temp_dir(), 'tc-out-empty-');
$this->assertNotFalse($tmpFile);
\file_put_contents($tmpFile, '');
try {
$this->setObjectProperty($obj, 'embeddedfiles', [
'empty.bin' => [
'a' => 0,
'f' => 63,
'n' => 64,
'file' => $tmpFile,
'content' => '',
'mimeType' => 'application/octet-stream',
'afRelationship' => 'Source',
'description' => '',
'creationDate' => 0,
'modDate' => 0,
],
]);
$this->assertSame('', $obj->exposeGetOutEmbeddedFiles());
} finally {
if (\file_exists($tmpFile)) {
\unlink($tmpFile);
}
}
}
/**
* @throws \Throwable
*/
public function testGetOutEmbeddedFilesSkipsUnreadableSourceFiles(): void
{
$obj = $this->getInternalTestObject();
$throwingFile = new class() extends \Com\Tecnick\File\File {
/**
* @throws \Throwable
*/
public function fileGetContents(string $file): string
{
throw new \Com\Tecnick\Pdf\Exception('mock unreadable file: ' . $file);
}
};
$this->setObjectProperty($obj, 'file', $throwingFile);
$this->setObjectProperty($obj, 'embeddedfiles', [
'missing.bin' => [
'a' => 0,
'f' => 75,
'n' => 76,
'file' => '/tmp/mock.bin',
'content' => '',
'mimeType' => 'application/octet-stream',
'afRelationship' => 'Source',
'description' => '',
'creationDate' => 0,
'modDate' => 0,
],
]);
$this->assertSame('', $obj->exposeGetOutEmbeddedFiles());
}
/**
* @throws \Throwable
*/
public function testGetOutXObjectsIncludesNestedXObjectReferences(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
/** @var \Com\Tecnick\Pdf\Font\Stack $font */
$font = $this->getObjectProperty($obj, 'font');
/** @var \Com\Tecnick\Pdf\Encrypt\Encrypt $encrypt */
$encrypt = $this->getObjectProperty($obj, 'encrypt');
/** @var int $pon */
$pon = $this->getObjectProperty($obj, 'pon');
$outfont = new \Com\Tecnick\Pdf\Font\Output($font->getFonts(), $pon, $encrypt);
$this->setObjectProperty($obj, 'outfont', $outfont);
$this->setObjectProperty($obj, 'xobjects', [
'XO1' => [
'n' => 101,
'x' => 0,
'y' => 0,
'w' => 10,
'h' => 10,
'outdata' => 'q Q',
'spot_colors' => [],
'extgstate' => [],
'gradient' => [],
'font' => [],
'image' => [],
'xobject' => ['XO2'],
'annotations' => [],
'id' => 'XO1',
'pheight' => 0,
'gheight' => 0,
],
'XO2' => [
'n' => 102,
'x' => 0,
'y' => 0,
'w' => 10,
'h' => 10,
'outdata' => '',
'spot_colors' => [],
'extgstate' => [],
'gradient' => [],
'font' => [],
'image' => [],
'xobject' => [],
'annotations' => [],
'id' => 'XO2',
'pheight' => 0,
'gheight' => 0,
],
]);
$out = $obj->exposeGetOutXObjects();
$this->assertStringContainsString('/XObject <<', $out);
$this->assertStringContainsString('/XO2 102 0 R', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutXObjectsSkipsMissingNestedXObjectReference(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
/** @var \Com\Tecnick\Pdf\Font\Stack $font */
$font = $this->getObjectProperty($obj, 'font');
/** @var \Com\Tecnick\Pdf\Encrypt\Encrypt $encrypt */
$encrypt = $this->getObjectProperty($obj, 'encrypt');
/** @var int $pon */
$pon = $this->getObjectProperty($obj, 'pon');
$outfont = new \Com\Tecnick\Pdf\Font\Output($font->getFonts(), $pon, $encrypt);
$this->setObjectProperty($obj, 'outfont', $outfont);
$this->setObjectProperty($obj, 'xobjects', [
'XO1' => [
'n' => 101,
'x' => 0,
'y' => 0,
'w' => 10,
'h' => 10,
'outdata' => 'q Q',
'spot_colors' => [],
'extgstate' => [],
'gradient' => [],
'font' => [],
'image' => [],
'xobject' => ['DOES_NOT_EXIST'],
'annotations' => [],
'id' => 'XO1',
'pheight' => 0,
'gheight' => 0,
],
]);
$out = $obj->exposeGetOutXObjects();
$this->assertStringContainsString('/Type /XObject', $out);
$this->assertStringNotContainsString('/DOES_NOT_EXIST ', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutAnnotationsSkipsFormRegistrationForKnownRadioButtonGroup(): void
{
$obj = $this->getInternalTestObject();
$pageInfo = $this->initFontAndPage($obj);
/** @var \Com\Tecnick\Pdf\Page\Page $pageObj */
$pageObj = $this->getObjectProperty($obj, 'page');
/** @var array<int, array<string, mixed>> $pgdata */
$pgdata = $this->getObjectProperty($pageObj, 'page');
$pgdata[$pageInfo['pid']]['n'] = 12;
$pgdata[$pageInfo['pid']]['num'] = 1;
$pgdata[$pageInfo['pid']]['annotrefs'] = [77];
$this->setObjectProperty($pageObj, 'page', $pgdata);
$this->setObjectProperty($obj, 'radiobuttons', [
'rb-direct' => [
'n' => 88,
'#readonly#' => false,
'kids' => [
['n' => 89, 'def' => 'Off'],
],
],
]);
$this->setObjectProperty($obj, 'annotation', [
77 => [
'n' => 77,
'x' => 5.0,
'y' => 10.0,
'w' => 40.0,
'h' => 8.0,
'txt' => 'rb-direct',
'opt' => [
'subtype' => 'Widget',
'ft' => 'Btn',
],
],
]);
/** @var \Com\Tecnick\Pdf\Font\Stack $font */
$font = $this->getObjectProperty($obj, 'font');
/** @var \Com\Tecnick\Pdf\Encrypt\Encrypt $encrypt */
$encrypt = $this->getObjectProperty($obj, 'encrypt');
/** @var int $pon */
$pon = $this->getObjectProperty($obj, 'pon');
$outfont = new \Com\Tecnick\Pdf\Font\Output($font->getFonts(), $pon, $encrypt);
$this->setObjectProperty($obj, 'outfont', $outfont);
$out = $obj->exposeGetOutAnnotations();
$this->assertStringContainsString('/FT /Btn', $out);
$this->assertStringContainsString('/Parent 88 0 R', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutPDFTrailerSuppressesEncryptInPdfxMode(): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'pdfx', true);
$obj->setOutputState(4, ['catalog' => 7, 'info' => 8], 'FEEDBEEF', 12);
$trailer = $obj->exposeGetOutPDFTrailer();
$this->assertStringNotContainsString('/Encrypt', $trailer);
}
/**
* @throws \Throwable
*/
private function setPdfaModeOnObject(TestableOutput $obj, int $pdfa): void
{
$obj->setPdfaMode($pdfa);
}
// -------------------------------------------------------------------------
// E-4 SVG mask PDF output tests
// -------------------------------------------------------------------------
/**
* getOutSVGMasks emits Form XObject + SMask + ExtGState for a registered mask
* and records the ExtGState object number in gs_n.
* @throws \Throwable
*/
public function testGetOutSVGMasksEmitsThreePdfObjectsAndSetsGsN(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->setSvgMasks([
'MSK_AABBCCDD' => [
'id' => 'MSK_AABBCCDD',
'stream' => 'q Q',
'bbox' => [0.0, 0.0, 595.0, 841.0],
'gs_n' => 0,
],
]);
$out = $obj->exposeGetOutSVGMasks();
// Three PDF objects emitted.
$this->assertSame(3, \substr_count($out, 'endobj'));
// Form XObject.
$this->assertStringContainsString('/Type /XObject', $out);
$this->assertStringContainsString('/Subtype /Form', $out);
$this->assertStringContainsString('/Group <<', $out);
$this->assertStringContainsString('/CS /DeviceGray', $out);
// SMask dict.
$this->assertStringContainsString('/Type /Mask', $out);
$this->assertStringContainsString('/S /Luminosity', $out);
// ExtGState.
$this->assertStringContainsString('/Type /ExtGState', $out);
$this->assertStringContainsString('/SMask', $out);
$this->assertStringContainsString('/AIS false', $out);
// gs_n must be set to the ExtGState object number.
$masks = $obj->getSvgMasks();
if (!isset($masks['MSK_AABBCCDD']) || !\is_array($masks['MSK_AABBCCDD'])) {
$this->fail('Expected MSK_AABBCCDD mask entry.');
}
$mask = $masks['MSK_AABBCCDD'];
if (!isset($mask['gs_n']) || !\is_int($mask['gs_n'])) {
$this->fail('Expected integer gs_n in MSK_AABBCCDD mask.');
}
$this->assertGreaterThan(0, $mask['gs_n']);
}
/**
* getSVGMaskExtGStateEntries returns an entry for each mask with gs_n > 0.
* @throws \Throwable
*/
public function testGetSVGMaskExtGStateEntriesReturnsResourceEntries(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->setSvgMasks([
'MSK_AA' => ['id' => 'MSK_AA', 'stream' => 'q Q', 'bbox' => [0.0, 0.0, 1.0, 1.0], 'gs_n' => 42],
'MSK_BB' => ['id' => 'MSK_BB', 'stream' => 'q Q', 'bbox' => [0.0, 0.0, 1.0, 1.0], 'gs_n' => 0],
]);
$entries = $obj->exposeGetSVGMaskExtGStateEntries();
$this->assertStringContainsString('/MSK_AA 42 0 R', $entries);
$this->assertStringNotContainsString('/MSK_BB', $entries);
}
/**
* getOutSVGMasks skips masks with empty streams.
* @throws \Throwable
*/
public function testGetOutSVGMasksSkipsEmptyStreamMasks(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->setSvgMasks([
'MSK_EMPTY' => ['id' => 'MSK_EMPTY', 'stream' => '', 'bbox' => [0.0, 0.0, 1.0, 1.0], 'gs_n' => 0],
]);
$out = $obj->exposeGetOutSVGMasks();
$this->assertSame('', $out);
$masks = $obj->getSvgMasks();
if (!isset($masks['MSK_EMPTY']) || !\is_array($masks['MSK_EMPTY'])) {
$this->fail('Expected MSK_EMPTY mask entry.');
}
$mask = $masks['MSK_EMPTY'];
if (!isset($mask['gs_n']) || !\is_int($mask['gs_n'])) {
$this->fail('Expected integer gs_n in MSK_EMPTY mask.');
}
$this->assertSame(0, $mask['gs_n']);
}
// -------------------------------------------------------------------------
// Widget MK appearance image object references
// -------------------------------------------------------------------------
/**
* @throws \Throwable
*/
public function testGetOutAnnotationOptSubtypeWidgetMkImageRefsWithRegisteredImages(): void
{
$obj = $this->getInternalTestObject();
/** @var \Com\Tecnick\Pdf\Image\Import $imageObj */
$imageObj = $this->getObjectProperty($obj, 'image');
// Build a minimal image cache entry with obj > 0.
$minEntry = [
'bits' => 8,
'channels' => 3,
'colspace' => 'DeviceRGB',
'data' => '',
'exturl' => false,
'file' => '',
'filter' => 'FlateDecode',
'height' => 4,
'icc' => '',
'ismask' => false,
'key' => '',
'mapto' => \IMAGETYPE_PNG,
'native' => true,
'obj' => 0,
'obj_alt' => 0,
'obj_icc' => 0,
'obj_pal' => 0,
'pal' => '',
'parms' => '',
'raw' => '',
'recode' => false,
'recoded' => false,
'splitalpha' => false,
'trns' => [],
'type' => \IMAGETYPE_PNG,
'width' => 4,
];
$keyI = $imageObj->getKey('icon-i.png');
$keyRi = $imageObj->getKey('icon-ri.png');
$keyIx = $imageObj->getKey('icon-ix.png');
$entryI = \array_merge($minEntry, ['key' => $keyI, 'obj' => 51]);
$entryRi = \array_merge($minEntry, ['key' => $keyRi, 'obj' => 52]);
$entryIx = \array_merge($minEntry, ['key' => $keyIx, 'obj' => 53]);
$this->setObjectProperty($imageObj, 'cache', [
$keyI => $entryI,
$keyRi => $entryRi,
$keyIx => $entryIx,
]);
$out = $obj->exposeGetOutAnnotationOptSubtypeWidget([
'txt' => 'mk-image-field',
'opt' => [
'mk' => [
'i' => 'icon-i.png',
'ri' => 'icon-ri.png',
'ix' => 'icon-ix.png',
],
],
], 20);
$this->assertStringContainsString(' /I 51 0 R', $out);
$this->assertStringContainsString(' /RI 52 0 R', $out);
$this->assertStringContainsString(' /IX 53 0 R', $out);
}
// -------------------------------------------------------------------------
// extractNamedResourceRefs empty-name skip
// -------------------------------------------------------------------------
/**
* @throws \Throwable
*/
public function testExtractNamedResourceRefsSkipsEmptyAndNonStringNames(): void
{
$obj = $this->getInternalTestObject();
$dict = '/Font << /F1 1 0 R /F2 2 0 R >>';
// Empty string name is skipped; non-string names are skipped via is_string check.
$out = $obj->exposeExtractNamedResourceRefs($dict, ['F1', '', 'F2']);
$this->assertStringContainsString('/F1 1 0 R', $out);
$this->assertStringContainsString('/F2 2 0 R', $out);
// Only two entries should appear (the empty string was skipped).
$this->assertSame(2, \substr_count($out, '0 R'));
}
// -------------------------------------------------------------------------
// getOutStructTreeRoot with pdfuaMode set but empty structLog
// -------------------------------------------------------------------------
/**
* @throws \Throwable
*/
public function testGetOutStructTreeRootReturnsEmptyWhenStructLogIsEmpty(): void
{
$obj = $this->getInternalTestObject();
// pdfuaMode is set but pdfuaStructLog is empty.
$this->setObjectProperty($obj, 'pdfuaMode', 'pdfua1');
$this->setObjectProperty($obj, 'pdfuaStructLog', []);
$out = $obj->exposeGetOutStructTreeRoot();
$this->assertSame('', $out);
// OID tracking must be reset.
$this->assertSame(0, $this->getObjectProperty($obj, 'parenttreeoid'));
$this->assertSame(0, $this->getObjectProperty($obj, 'structtreerootoid'));
}
// -------------------------------------------------------------------------
// getOutAnnotationOptSubtypeRedact: /RO branch
// -------------------------------------------------------------------------
/**
* @throws \Throwable
*/
public function testGetOutAnnotationOptSubtypeRedactIncludesRoField(): void
{
$obj = $this->getInternalTestObject();
$out = $obj->exposeGetOutAnnotationOptSubtypeRedact([
'n' => 50,
'opt' => [
'ro' => '(Redacted-XMP)',
],
]);
$this->assertStringContainsString(' /RO ', $out);
}
// -------------------------------------------------------------------------
// getOutAnnotationOptSubtypeWatermark: empty fixedprint returns ''
// -------------------------------------------------------------------------
/**
* @throws \Throwable
*/
public function testGetOutAnnotationOptSubtypeWatermarkReturnsEmptyWhenFixedprintProducesNoOutput(): void
{
$obj = $this->getInternalTestObject();
// fixedprint array present but none of its keys have valid values.
$out = $obj->exposeGetOutAnnotationOptSubtypeWatermark([
'opt' => [
'fixedprint' => [
'type' => 123,
'h' => 'not-numeric',
'v' => [],
],
],
]);
$this->assertSame('', $out);
}
// -------------------------------------------------------------------------
// getOutAnnotationRectDifferences via exposeGetOutAnnotationRD
// -------------------------------------------------------------------------
/**
* @throws \Throwable
*/
public function testGetOutAnnotationRectDifferencesWithValidRdArray(): void
{
$obj = $this->getInternalTestObject();
$out = $obj->exposeGetOutAnnotationRD([
'opt' => ['rd' => [1.0, 2.0, 3.0, 4.0]],
]);
$this->assertStringContainsString(' /RD [', $out);
}
/**
* @throws \Throwable
*/
public function testGetOutAnnotationRectDifferencesReturnsEmptyForInvalidRd(): void
{
$obj = $this->getInternalTestObject();
// Wrong count.
$this->assertSame('', $obj->exposeGetOutAnnotationRD([
'opt' => ['rd' => [1.0, 2.0]],
]));
// Non-numeric value.
$this->assertSame('', $obj->exposeGetOutAnnotationRD([
'opt' => ['rd' => [1.0, 2.0, 'x', 4.0]],
]));
// Missing rd key.
$this->assertSame('', $obj->exposeGetOutAnnotationRD(['opt' => []]));
}
// -------------------------------------------------------------------------
// collectDssMaterial: empty certificate-inputs path
// -------------------------------------------------------------------------
/**
* @throws \Throwable
*/
public function testCollectDssMaterialReturnsEmptyWhenSigncertIsEmpty(): void
{
$obj = $this->getInternalTestObject();
// ltv.enabled is truthy but signcert is '' → getCertificateSourceContent('') returns ''
// → collectValidationCertificateInputs returns [] → empty material.
$this->setObjectProperty($obj, 'signature', [
'signcert' => '',
'extracerts' => '',
'privkey' => null,
'password' => '',
'ltv' => ['enabled' => true],
]);
$material = $obj->exposeCollectDssMaterial();
$this->assertSame([], $material['certs']);
$this->assertSame([], $material['ocsp']);
$this->assertSame([], $material['crls']);
}
// -------------------------------------------------------------------------
// setPageStructParents: skip page without 'n' key (line 1796)
// -------------------------------------------------------------------------
/**
* @throws \Throwable
*/
public function testSetPageStructParentsSkipsPagesWithoutObjectNumber(): void
{
$obj = $this->getInternalTestObject();
/** @var \Com\Tecnick\Pdf\Page\Page $pageObj */
$pageObj = $this->getObjectProperty($obj, 'page');
// Add a real page and then inject a fake one missing 'n'.
$pageData = $pageObj->add([]);
/** @var array<int, array<string, mixed>> $pages */
$pages = $this->getObjectProperty($pageObj, 'page');
$pages[$pageData['pid']]['n'] = 99;
// Add a fake entry without 'n' key.
$pages[9999] = ['pid' => 9999, 'content' => []];
$this->setObjectProperty($pageObj, 'page', $pages);
$obj->exposeSetPageStructParents('');
// Only the real page (with 'n' = 99) should appear in pagestructparents.
/** @var array<int, int> $structParents */
$structParents = $this->getObjectProperty($obj, 'pagestructparents');
$this->assertArrayHasKey(99, $structParents);
$this->assertArrayNotHasKey(9999, $structParents);
}
/**
* @throws \Throwable
*/
public function testPostTimestampRequestThrowsOnEmptyHost(): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'sigtimestamp', [
'enabled' => true,
'host' => '',
'username' => '',
'password' => '',
'cert' => '',
'hash_algorithm' => 'sha256',
'policy_oid' => '',
'nonce_enabled' => false,
'timeout' => 5,
'verify_peer' => true,
]);
$this->bcExpectException(\Com\Tecnick\Pdf\Exception::class);
$obj->exposeParentPostTimestampRequest('req');
}
/**
* @throws \Throwable
*/
public function testGetCertificateSourceContentWithEmptySourceReturnsEmpty(): void
{
$obj = $this->getInternalTestObject();
$result = $obj->exposeGetCertificateSourceContent('');
$this->assertSame('', $result);
}
/**
* @throws \Throwable
*/
public function testGetCertificateSourceContentWithInlinePemReturnsAsIs(): void
{
$obj = $this->getInternalTestObject();
$pemContent = "-----BEGIN CERTIFICATE-----\nMIIBxxx\n-----END CERTIFICATE-----\n";
$result = $obj->exposeGetCertificateSourceContent($pemContent);
$this->assertSame($pemContent, $result);
}
/**
* @throws \Throwable
*/
public function testExtractPemCertificatesWithNoPemBlocksReturnsEmptyArray(): void
{
$obj = $this->getInternalTestObject();
$result = $obj->exposeExtractPemCertificates('no pem certificates here at all');
$this->assertSame([], $result);
}
/**
* @throws \Throwable
*/
public function testGetPatternDictSkipsPatternWithoutNKey(): void
{
$obj = $this->getInternalTestObject();
// Add two patterns: one with 'n' and one without.
$this->setObjectProperty($obj, 'patterns', [
'P1' => ['n' => 7],
'P2' => [],
]);
$result = $obj->exposeGetPatternDict();
$this->assertStringContainsString('/P1 7 0 R', $result);
$this->assertStringNotContainsString('/P2', $result);
}
/**
* @throws \Throwable
*/
public function testSavePdfThrowsOnInvalidDirectory(): void
{
$obj = $this->getInternalTestObject();
$this->setObjectProperty($obj, 'pdffilename', 'test.pdf');
$this->expectException(\Com\Tecnick\File\Exception::class);
$obj->savePDF('/this/path/does/not/exist/at/all', '%PDF-1.4');
}
/**
* The font subsystem used at PDF output time must
* honor the application-provided file helper (fileOptions['allowedPaths'])
* instead of building its own helper restricted to K_PATH_FONTS. This lets
* custom fonts live outside the (often read-only) vendor/K_PATH_FONTS path.
*
* The font here is loaded by explicit path from a directory that is trusted
* only via fileOptions['allowedPaths'] and is NOT covered by K_PATH_FONTS,
* so embedding the font program at output exercises exactly the shared helper.
*
* @throws \Throwable
*/
public function testCustomFontPathFromAllowedPathsIsEmbedded(): void
{
$bundled = (string) \realpath((string) \constant('K_PATH_FONTS'));
$customDir = \sys_get_temp_dir() . '/tc-lib-pdf-fonts-' . \bin2hex(\random_bytes(6));
$this->assertTrue(\mkdir($customDir, 0o777, true));
try {
foreach ((array) \glob($bundled . '/dejavu/dejavusans.*') as $src) {
\copy((string) $src, $customDir . '/' . \basename((string) $src));
}
$ifile = $customDir . '/dejavusans.json';
$customReal = (string) \realpath($customDir);
$this->assertFileIsReadable($ifile);
// The custom directory is outside K_PATH_FONTS; only allowedPaths trusts it.
$this->assertFalse(
\str_starts_with($customReal, $bundled),
'Custom font dir must be outside K_PATH_FONTS for this regression test to be meaningful.',
);
$pdf = new \Com\Tecnick\Pdf\Tcpdf('mm', true, true, true, '', null, ['allowedPaths' => [$customReal]]);
$pdf->addPage();
$objnum = $pdf->pon;
$font = $pdf->font->insert($objnum, 'dejavusans', '', 12, null, null, $ifile, true);
$pdf->page->addContent($font['out']);
$pdf->addTextCell('Embedded custom-path font: AÉ', -1, 10, 20);
$raw = $pdf->getOutPDFString();
$this->assertNotSame('', $raw);
// /FontFile2 is the embedded TrueType program stream; its presence proves
// the font file was read through the application-provided allowedPaths.
$this->assertStringContainsString('/FontFile2', $raw);
} finally {
$this->removeTempDir($customDir);
}
}
/**
* Remove a flat temporary directory and the files it contains.
*/
private function removeTempDir(string $dir): void
{
foreach ((array) \glob($dir . '/*') as $file) {
if (!\is_file((string) $file)) {
continue;
}
\unlink((string) $file);
}
if (\is_dir($dir)) {
\rmdir($dir);
}
}
/** @throws \Throwable */
public function testSignatureSubFilterFollowsProfile(): void
{
$obj = $this->getInternalTestObject();
$this->setSignatureArray($obj, ['profile' => 'legacy']);
$this->assertSame('adbe.pkcs7.detached', $obj->exposeSignatureSubFilter());
$this->setSignatureArray($obj, ['profile' => 'pades-b-b']);
$this->assertSame('ETSI.CAdES.detached', $obj->exposeSignatureSubFilter());
$this->setSignatureArray($obj, []);
$this->assertSame('adbe.pkcs7.detached', $obj->exposeSignatureSubFilter());
}
/** @throws \Throwable */
public function testAppendIncrementalRevisionChainsANewRevision(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$base = $obj->getOutPDFString();
/** @var int $pon */
$pon = $this->getObjectProperty($obj, 'pon');
$newNum = $pon + 1;
$dummy = $newNum . " 0 obj\n<< /Type /TestMarker >>\nendobj\n";
$result = $obj->exposeAppendIncrementalRevision($base, [$newNum => $dummy]);
// The original revision is a byte-for-byte prefix; a second revision is appended.
$this->assertTrue(\str_starts_with($result, $base));
$this->assertStringEndsWith("%%EOF\n", $result);
$this->assertSame(2, \substr_count($result, "startxref\n"));
$this->assertStringContainsString('/Type /TestMarker', $result);
// The incremental trailer chains to the previous revision's xref.
$baseMatches = [];
\preg_match('/\d+/', \substr($base, (int) \strrpos($base, 'startxref') + 9), $baseMatches);
$this->assertStringContainsString('/Prev ' . ($baseMatches[0] ?? ''), $result);
// The last startxref points at the incremental xref, which lists the new
// object at its true byte offset (the end of the original document).
$lastMatches = [];
\preg_match('/\d+/', \substr($result, (int) \strrpos($result, 'startxref') + 9), $lastMatches);
$xrefOffset = (int) ($lastMatches[0] ?? '0');
$this->assertSame('xref', \substr($result, $xrefOffset, 4));
$this->assertStringContainsString($newNum . ' 1' . "\n", \substr($result, $xrefOffset));
$this->assertStringContainsString(\sprintf('%010d 00000 n', \strlen($base)), \substr($result, $xrefOffset));
}
/** @throws \Throwable */
public function testAppendIncrementalRevisionReturnsInputWhenNoObjects(): void
{
$obj = $this->getInternalTestObject();
$this->assertSame("%PDF-1.7\n", $obj->exposeAppendIncrementalRevision("%PDF-1.7\n", []));
}
/** @throws \Throwable */
public function testSignatureContentsLengthGrowsWhenTimestamping(): void
{
$obj = $this->getInternalTestObject();
$this->assertSame(11_742, $obj->exposeSignatureContentsLength());
$this->setObjectProperty($obj, 'sigtimestamp', ['enabled' => true]);
$this->assertSame(31_742, $obj->exposeSignatureContentsLength());
}
/** @throws \Throwable */
public function testSignatureDigestAlgorithmDefaultsAndValidates(): void
{
$obj = $this->getInternalTestObject();
$this->setSignatureArray($obj, []);
$this->assertSame('sha256', $obj->exposeSignatureDigestAlgorithm());
$this->setSignatureArray($obj, ['digest_algorithm' => 'sha384']);
$this->assertSame('sha384', $obj->exposeSignatureDigestAlgorithm());
$this->setSignatureArray($obj, ['digest_algorithm' => 'md5']);
$this->assertSame('sha256', $obj->exposeSignatureDigestAlgorithm());
}
/** @throws \Throwable */
public function testLoadExtraCertificatesParsesPemAndFileBundles(): void
{
$obj = $this->getInternalTestObject();
$one = $this->makeSigningCredential();
$two = $this->makeSigningCredential();
$this->assertSame([], $obj->exposeLoadExtraCertificates(''));
$pemBundle = $one['cert_pem'] . "\n" . $two['cert_pem'];
$expected = [$this->pemCertToDer($one['cert_pem']), $this->pemCertToDer($two['cert_pem'])];
$this->assertSame($expected, $obj->exposeLoadExtraCertificates($pemBundle));
$file = (string) \tempnam(\sys_get_temp_dir(), 'tcchain');
\file_put_contents($file, $pemBundle);
try {
$fromFile = $obj->exposeLoadExtraCertificates('file://' . $file);
} finally {
\unlink($file);
}
$this->assertSame($expected, $fromFile);
}
/** @throws \Throwable */
public function testBuildSignatureCmsEmbedsExtraCertificates(): void
{
$obj = $this->getInternalTestObject();
$signer = $this->makeSigningCredential();
$extra = $this->makeSigningCredential();
$this->setSignatureArray($obj, [
'signcert' => $signer['cert_pem'],
'privkey' => $signer['key_pem'],
'password' => '',
'extracerts' => $extra['cert_pem'],
]);
$cms = $obj->exposeBuildSignatureCms('payload');
$this->assertStringContainsString($this->pemCertToDer($extra['cert_pem']), $cms);
}
/** @throws \Throwable */
public function testBuildSignatureCmsOmitsSigningTimeForPadesProfile(): void
{
$signer = $this->makeSigningCredential();
$asn1 = new \Com\Tecnick\Pdf\Sign\Cms\Asn1();
$signingTimeOid = $asn1->encodeObjectIdentifier('1.2.840.113549.1.9.5');
// The legacy (ISO 32000-1) profile keeps the CMS signing-time attribute.
$legacy = $this->getInternalTestObject();
$this->setSignatureArray($legacy, [
'signcert' => $signer['cert_pem'],
'privkey' => $signer['key_pem'],
'password' => '',
'profile' => 'legacy',
]);
$this->assertStringContainsString($signingTimeOid, $legacy->exposeBuildSignatureCms('payload'));
// Every PAdES profile omits it (ETSI EN 319 142-1): a PAdES CMS that carries
// signing-time is demoted by validators from PAdES-BASELINE-B to PAdES-BES.
// The signing time is carried by the /M signature dictionary entry instead.
$pades = $this->getInternalTestObject();
$this->setSignatureArray($pades, [
'signcert' => $signer['cert_pem'],
'privkey' => $signer['key_pem'],
'password' => '',
'profile' => 'pades-b-b',
]);
$this->assertStringNotContainsString($signingTimeOid, $pades->exposeBuildSignatureCms('payload'));
}
/** @throws \Throwable */
public function testBuildSignatureCmsRejectsInvalidCertificate(): void
{
$obj = $this->getInternalTestObject();
$this->setSignatureArray($obj, [
'signcert' => 'not-a-certificate',
'privkey' => 'not-a-key',
'password' => '',
]);
$this->expectException(\Com\Tecnick\Pdf\Exception::class);
$obj->exposeBuildSignatureCms('payload');
}
/** @throws \Throwable */
public function testBuildSignatureCmsRejectsInvalidPrivateKey(): void
{
$obj = $this->getInternalTestObject();
$signer = $this->makeSigningCredential();
$this->setSignatureArray($obj, [
'signcert' => $signer['cert_pem'],
'privkey' => 'not-a-key',
'password' => '',
]);
$this->expectException(\Com\Tecnick\Pdf\Exception::class);
\set_error_handler(static fn(): bool => true);
try {
$obj->exposeBuildSignatureCms('payload');
} finally {
\restore_error_handler();
}
}
/** @throws \Throwable */
public function testConvertBinarySignatureToHexRejectsOversizedSignature(): void
{
$obj = $this->getInternalTestObject();
$oversized = \str_repeat("\x00", 6_000); // 12000 hex chars > SIGMAXLEN (11742)
$this->expectException(\Com\Tecnick\Pdf\Exception::class);
$obj->exposeConvertBinarySignatureToHex($oversized);
}
/**
* Set the protected signature configuration array on a test object.
*
* @param array<string, mixed> $signature
* @throws \Throwable
*/
private function setSignatureArray(TestableOutput $obj, array $signature): void
{
$prop = new \ReflectionProperty($obj, 'signature');
$prop->setValue($obj, $signature);
}
/**
* Convert the first PEM CERTIFICATE block to DER.
*/
private function pemCertToDer(string $pem): string
{
$stripped = (string) \preg_replace('/-----[^-]+-----|\s+/', '', $pem);
return (string) \base64_decode($stripped, true);
}
/**
* Generate an RSA signing key and matching self-signed certificate (PEM).
*
* @return array{cert_pem: string, key_pem: string}
*/
private function makeSigningCredential(): 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];
}
}