<?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];
    }
}
