* @copyright 2002-2026 Nicola Asuni - Tecnick.com LTD * @license https://www.gnu.org/copyleft/lesser.html GNU-LGPL v3 (see LICENSE) * @link https://github.com/tecnickcom/tc-lib-pdf * * This file is part of tc-lib-pdf software library. */ namespace Test; /** @phpstan-import-type TXOBject from \Com\Tecnick\Pdf\Output */ class JavaScriptTest extends TestUtil { 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(): TestableJavaScript { return new TestableJavaScript(); } /** * @return array{pid: int} * @throws \Throwable */ private function addRawPage(\Com\Tecnick\Pdf\Tcpdf $obj): array { /** @var \Com\Tecnick\Pdf\Page\Page $page */ $page = $this->getObjectProperty($obj, 'page'); $rawPage = $page->add([]); return ['pid' => (int) $rawPage['pid']]; } /** * @param array> $annotations * @return array */ private function getAnnotationEntry(array $annotations, int $oid): array { $entry = $annotations[$oid] ?? null; if ($entry === null) { $this->fail('Missing annotation entry: ' . (string) $oid); } return $entry; } /** * @param array> $map * @return array */ private function getStringMapEntry(array $map, string $key, string $label): array { $entry = $map[$key] ?? null; if ($entry === null) { $this->fail('Missing ' . $label . ' entry: ' . $key); } return $entry; } /** * @param array $map */ private function getRequiredString(array $map, string $key, string $label): string { if (!isset($map[$key]) || !\is_string($map[$key])) { $this->fail('Missing string value for ' . $label . ': ' . $key); } return $map[$key]; } /** * @param array> $annotations * @return array */ private function getAnnotationOpt(array $annotations, int $oid): array { $entry = $this->getAnnotationEntry($annotations, $oid); if (!isset($entry['opt']) || !\is_array($entry['opt'])) { $this->fail('Missing annotation options: ' . (string) $oid); } /** @var array */ return $entry['opt']; } /** * @throws \Throwable */ public function testAppendRawJavaScriptAppendsScript(): void { $obj = $this->getTestObject(); $obj->appendRawJavaScript('var a = 1;'); $obj->appendRawJavaScript('var b = 2;'); /** @var string $javascript */ $javascript = $this->getObjectProperty($obj, 'javascript'); $this->assertSame('var a = 1;var b = 2;', $javascript); } /** * @throws \Throwable */ public function testAddRawJavaScriptObjAddsObjectAndReturnsId(): void { $obj = $this->getTestObject(); $objectId = $obj->addRawJavaScriptObj('app.alert("ok");', true); $this->assertGreaterThan(0, $objectId); /** @var array $list */ $list = $this->getObjectProperty($obj, 'jsobjects'); $this->assertCount(1, $list); assert(isset($list[0]), "\$list[0] must be set"); $this->assertSame($objectId, $list[0]['n']); $this->assertSame('app.alert("ok");', $list[0]['js']); $this->assertTrue($list[0]['onload']); } /** * @throws \Throwable */ public function testAddRawJavaScriptObjReturnsMinusOneInPdfaMode(): void { $obj = $this->getTestObject(); $this->setObjectProperty($obj, 'pdfa', 1); $objectId = $obj->addRawJavaScriptObj('app.alert("no");'); $this->assertSame(-1, $objectId); /** @var array $jsobjects */ $jsobjects = $this->getObjectProperty($obj, 'jsobjects'); $this->assertCount(0, $jsobjects); } /** * @throws \Throwable */ public function testAppendRawJavaScriptIsIgnoredInPdfuaMode(): void { $obj = $this->getTestObject(); $this->setObjectProperty($obj, 'pdfuaMode', 'pdfua1'); $obj->appendRawJavaScript('var blocked = true;'); /** @var string $javascript */ $javascript = $this->getObjectProperty($obj, 'javascript'); $this->assertSame('', $javascript); } /** * @throws \Throwable */ public function testAddRawJavaScriptObjReturnsMinusOneInPdfuaMode(): void { $obj = $this->getTestObject(); $this->setObjectProperty($obj, 'pdfuaMode', 'pdfua2'); $objectId = $obj->addRawJavaScriptObj('app.alert("no");'); $this->assertSame(-1, $objectId); /** @var array $jsobjects */ $jsobjects = $this->getObjectProperty($obj, 'jsobjects'); $this->assertCount(0, $jsobjects); } /** * @throws \Throwable */ public function testAppendRawJavaScriptIsIgnoredInPdfxMode(): void { $obj = $this->getTestObject(); $this->setObjectProperty($obj, 'pdfx', true); $obj->appendRawJavaScript('var blocked = true;'); /** @var string $javascript */ $javascript = $this->getObjectProperty($obj, 'javascript'); $this->assertSame('', $javascript); } /** * @throws \Throwable */ public function testAddRawJavaScriptObjReturnsMinusOneInPdfxMode(): void { $obj = $this->getTestObject(); $this->setObjectProperty($obj, 'pdfx', true); $objectId = $obj->addRawJavaScriptObj('app.alert("no");'); $this->assertSame(-1, $objectId); /** @var array $jsobjects */ $jsobjects = $this->getObjectProperty($obj, 'jsobjects'); $this->assertCount(0, $jsobjects); } /** * @throws \Throwable */ public function testSetAndGetDefaultAnnotationProperties(): void { $obj = $this->getTestObject(); $props = ['lineWidth' => 2, 'borderStyle' => 'dashed', 'fillColor' => 'yellow']; $obj->setDefJSAnnotProp($props); $this->assertSame($props, $obj->getDefJSAnnotProp()); } /** * @throws \Throwable */ public function testAddInternalLinkStoresLinkData(): void { $obj = $this->getTestObject(); $page = $this->addRawPage($obj); $lnk = $obj->addInternalLink($page['pid'], 12.5); /** @var array $links */ $links = $this->getObjectProperty($obj, 'links'); $this->assertSame('@1', $lnk); $link = $this->getStringMapEntry($links, $lnk, 'link'); $this->assertSame($page['pid'], $link['p'] ?? null); $this->assertEqualsWithDelta(12.5, $link['y'] ?? 0.0, 0.0001); } /** * @throws \Throwable */ public function testSetNamedDestinationStoresEncodedDestination(): void { $obj = $this->getTestObject(); $page = $this->addRawPage($obj); $ret = $obj->setNamedDestination('My Dest', $page['pid'], 10.0, 20.0); $this->assertStringStartsWith('#', $ret); $key = \substr($ret, 1); /** @var array $dests */ $dests = $this->getObjectProperty($obj, 'dests'); $this->assertArrayHasKey($key, $dests); $dest = $this->getStringMapEntry($dests, $key, 'destination'); $this->assertSame($page['pid'], $dest['p'] ?? null); $this->assertEqualsWithDelta(10.0, $dest['x'] ?? 0.0, 0.0001); $this->assertEqualsWithDelta(20.0, $dest['y'] ?? 0.0, 0.0001); } /** * @throws \Throwable */ public function testSetBookmarkClampsLevelAndUppercasesStyle(): void { $obj = $this->getTestObject(); $page = $this->addRawPage($obj); $obj->setBookmark('Parent', '', 0, $page['pid'], 0, 0, 'b', 'red'); $obj->setBookmark('Child', '', 9, $page['pid'], 1, 2, 'iu', 'blue'); /** @var array $outlines */ $outlines = $this->getObjectProperty($obj, 'outlines'); $this->assertCount(2, $outlines); assert(isset($outlines[0]), "\$outlines[0] must be set"); $this->assertSame(0, $outlines[0]['l']); $this->assertSame('B', $outlines[0]['s']); assert(isset($outlines[1]), "\$outlines[1] must be set"); $this->assertSame(1, $outlines[1]['l']); $this->assertSame('IU', $outlines[1]['s']); } /** * @throws \Throwable */ public function testSetBookmarkClampsNegativeLevelToZero(): void { $obj = $this->getTestObject(); $page = $this->addRawPage($obj); $obj->setBookmark('Negative', '', -5, $page['pid']); /** @var array $outlines */ $outlines = $this->getObjectProperty($obj, 'outlines'); $this->assertCount(1, $outlines); assert(isset($outlines[0]), "\$outlines[0] must be set"); $this->assertSame(0, $outlines[0]['l']); } /** * @throws \Throwable */ public function testXObjectTemplateLifecycleAndMutators(): void { $obj = $this->getTestObject(); $this->addRawPage($obj); $tid = $obj->newXObjectTemplate(30, 40); $obj->addXObjectContent($tid, 'q Q'); $obj->addXObjectXObjectID($tid, 'XT999'); $obj->addXObjectImageID($tid, 7); $obj->addXObjectFontID($tid, 'F1'); $obj->addXObjectGradientID($tid, 8); $obj->addXObjectExtGStateID($tid, 9); $obj->addXObjectSpotColorID($tid, 'SC1'); /** @var array $xobjects */ $xobjects = $this->getObjectProperty($obj, 'xobjects'); $this->assertArrayHasKey($tid, $xobjects); $xobject = $this->getStringMapEntry($xobjects, $tid, 'xobject'); $this->assertSame('q Q', $xobject['outdata'] ?? null); $this->assertSame(['XT999'], $xobject['xobject'] ?? null); $this->assertSame([7], $xobject['image'] ?? null); $this->assertSame(['F1'], $xobject['font'] ?? null); $this->assertSame([8], $xobject['gradient'] ?? null); $this->assertSame([9], $xobject['extgstate'] ?? null); $this->assertSame(['SC1'], $xobject['spot_colors'] ?? null); $obj->exitXObjectTemplate(); /** @var string $xobjtid */ $xobjtid = $this->getObjectProperty($obj, 'xobjtid'); $this->assertSame('', $xobjtid); $this->assertSame('', $obj->getXObjectTemplate('UNKNOWN')); $draw = $obj->getXObjectTemplate($tid, 1, 2, 10, 20); $this->assertStringContainsString('/' . $tid . ' Do', $draw); } /** * @throws \Throwable */ public function testXObjectMutatorsGracefullyIgnoreUnknownTemplateId(): void { $obj = $this->getTestObject(); $obj->exitXObjectTemplate(); $obj->addXObjectContent('UNKNOWN', 'q Q'); $obj->addXObjectXObjectID('UNKNOWN', 'XT1'); $obj->addXObjectImageID('UNKNOWN', 1); $obj->addXObjectFontID('UNKNOWN', 'F1'); $obj->addXObjectGradientID('UNKNOWN', 2); $obj->addXObjectExtGStateID('UNKNOWN', 3); $obj->addXObjectSpotColorID('UNKNOWN', 'SC1'); /** @var array $xobjects */ $xobjects = $this->getObjectProperty($obj, 'xobjects'); $this->assertSame([], $xobjects); } /** * @throws \Throwable */ public function testAddEmbeddedFileStoresDataAndIgnoresDuplicates(): void { $obj = $this->getTestObject(); $path = (string) \realpath(__DIR__ . '/../README.md'); $obj->addEmbeddedFile($path, 'text/plain', 'Source', 'desc'); $obj->addEmbeddedFile($path, 'text/plain', 'Source', 'desc2'); /** @var array $files */ $files = $this->getObjectProperty($obj, 'embeddedfiles'); $key = \basename($path); $this->assertArrayHasKey($key, $files); $this->assertCount(1, $files); $fileData = $this->getStringMapEntry($files, $key, 'embedded file'); $this->assertSame('desc', $fileData['description'] ?? null); } /** * @throws \Throwable */ public function testAddEmbeddedFileThrowsInPdfa1(): void { $obj = $this->getTestObject(); $this->setObjectProperty($obj, 'pdfa', 1); $this->bcExpectException(\Com\Tecnick\Pdf\Exception::class); $obj->addEmbeddedFile('file.bin'); } /** * @throws \Throwable */ public function testAddContentAsEmbeddedFileStoresContent(): void { $obj = $this->getTestObject(); $obj->addContentAsEmbeddedFile('payload.txt', 'abc123', 'text/plain', 'Data', 'payload'); /** @var array $files */ $files = $this->getObjectProperty($obj, 'embeddedfiles'); $this->assertArrayHasKey('payload.txt', $files); $payload = $this->getStringMapEntry($files, 'payload.txt', 'embedded content'); $this->assertSame('abc123', $payload['content'] ?? null); $this->assertSame('payload', $payload['description'] ?? null); } /** * @throws \Throwable */ public function testAddContentAsEmbeddedFileThrowsOnEmptyContent(): void { $obj = $this->getTestObject(); $this->bcExpectException(\Com\Tecnick\Pdf\Exception::class); $obj->addContentAsEmbeddedFile('payload.txt', ''); } /** * @throws \Throwable */ public function testAddContentAsEmbeddedFileThrowsInPdfaMode(): void { $obj = $this->getTestObject(); $this->setObjectProperty($obj, 'pdfa', 2); try { $obj->addContentAsEmbeddedFile('payload.txt', 'abc123'); $this->fail('Expected PDF/A embedded content exception'); } catch (\Com\Tecnick\Pdf\Exception $e) { $this->assertStringContainsString('Embedded files are not allowed', $e->getMessage()); } } /** * @throws \Throwable */ public function testGetAnnotOptFromJSPropCoversDensePropertyMapping(): void { $obj = $this->getInternalTestObject(); $this->assertSame([], $obj->exposeGetAnnotOptFromJSProp([])); $this->assertSame( ['Subtype' => 'Widget'], $obj->exposeGetAnnotOptFromJSProp(['aopt' => ['Subtype' => 'Widget']]), ); $this->setObjectProperty($obj, 'rtl', true); $rtlOpt = $obj->exposeGetAnnotOptFromJSProp(['alignment' => 'weird']); $this->assertSame(2, $rtlOpt['q'] ?? null); $opt = $obj->exposeGetAnnotOptFromJSProp([ 'alignment' => 'center', 'lineWidth' => '2', 'borderStyle' => 'dashed', 'buttonAlignX' => 0.25, 'buttonAlignY' => 0.75, 'buttonFitBounds' => 'true', 'buttonScaleHow' => 'scaleHow.anamorphic', 'buttonScaleWhen' => 'scaleWhen.tooSmall', 'buttonPosition' => 'position.overlay', 'fillColor' => 'yellow', 'strokeColor' => 'blue', 'rotation' => 90, 'charLimit' => '5', 'readonly' => 'true', 'required' => 'true', 'multiline' => 'true', 'password' => 'true', 'NoToggleToOff' => 'true', 'Radio' => 'true', 'Pushbutton' => 'true', 'Combo' => 'true', 'editable' => 'true', 'Sort' => 'true', 'fileSelect' => 'true', 'multipleSelection' => 'true', 'doNotSpellCheck' => 'true', 'doNotScroll' => 'true', 'comb' => 'true', 'radiosInUnison' => 'true', 'richText' => 'true', 'commitOnSelChange' => 'true', 'defaultValue' => 'dv', 'display' => 'display.noView', 'currentValueIndices' => [1, 2], 'value' => ['Visible A', 'Visible B'], 'exportValues' => ['Export A', 'Export B'], 'richValue' => 'visible', 'submitName' => 'submit-field', 'name' => 'field-name', 'userName' => 'Field Name', 'highlight' => 'outline', ]); /** @var array $markerOptions */ $markerOptions = $opt['mk'] ?? []; /** @var array $iconFit */ $iconFit = \is_array($markerOptions['if'] ?? null) ? $markerOptions['if'] : []; $this->assertSame(1, $opt['q'] ?? null); $this->assertSame([0, 0, 2, [3, 2]], $opt['border'] ?? null); $this->assertSame(['w' => 2, 's' => 'D', 'd' => [3, 2]], $opt['bs'] ?? null); $this->assertSame([0.25, 0.75], $iconFit['a'] ?? null); $this->assertTrue(($iconFit['fb'] ?? null) === true); $this->assertSame('A', $iconFit['s'] ?? null); $this->assertSame('S', $iconFit['sw'] ?? null); $this->assertSame(6, $markerOptions['tp'] ?? null); $this->assertArrayHasKey('bg', $markerOptions); $this->assertArrayHasKey('bc', $markerOptions); $this->assertSame(90, $markerOptions['r'] ?? null); $this->assertSame(5, $opt['maxlen'] ?? null); $this->assertGreaterThan(0, (int) ($opt['ff'] ?? 0)); $this->assertSame('dv', $opt['dv'] ?? null); $this->assertSame(100, $opt['f'] ?? null); $this->assertSame([1, 2], $opt['i'] ?? null); $this->assertSame( [ ['Export A', 'Visible A'], ['Export B', 'Visible B'], ], $opt['opt'] ?? null, ); $this->assertSame('visible', $opt['rv'] ?? null); $this->assertSame('submit-field', $opt['tm'] ?? null); $this->assertSame('field-name', $opt['t'] ?? null); $this->assertSame('Field Name', $opt['tu'] ?? null); $this->assertSame('O', $opt['h'] ?? null); } /** * @throws \Throwable */ public function testEmbeddedFileValidationCoversEmptyNameAndPdfa3Relationship(): void { $obj = $this->getTestObject(); try { $obj->addEmbeddedFile(''); $this->fail('Expected empty file name exception'); } catch (\Com\Tecnick\Pdf\Exception $e) { $this->assertStringContainsString('Empty file name', $e->getMessage()); } $this->setObjectProperty($obj, 'pdfa', 3); try { $obj->addEmbeddedFile(__DIR__ . '/../README.md', 'text/plain', 'NotValid'); $this->fail('Expected invalid afrel exception'); } catch (\Com\Tecnick\Pdf\Exception $e) { $this->assertStringContainsString('afrel must be one of', $e->getMessage()); } } /** * @throws \Throwable */ public function testSetAnnotationAndSetLinkCreateAnnotationEntries(): void { $obj = $this->getTestObject(); $textAnnotationId = $obj->setAnnotation(1, 2, 3, 4, 'note', ['subtype' => 'Text']); $linkAnnotationId = $obj->setLink(5, 6, 7, 8, '#dest'); /** @var array}> $ann */ $ann = $this->getObjectProperty($obj, 'annotation'); $this->assertArrayHasKey($textAnnotationId, $ann); $this->assertArrayHasKey($linkAnnotationId, $ann); $textAnn = $this->getAnnotationEntry($ann, $textAnnotationId); $linkOpt = $this->getAnnotationOpt($ann, $linkAnnotationId); $this->assertSame('note', $textAnn['txt'] ?? null); $this->assertSame('Link', $linkOpt['subtype'] ?? null); } /** * @throws \Throwable */ public function testSetAnnotationInsideXObjectIsDeferred(): void { $obj = $this->getTestObject(); $this->addRawPage($obj); $tid = $obj->newXObjectTemplate(20, 20); $oid = $obj->setAnnotation(1, 1, 5, 5, 'in-template', ['subtype' => 'Text']); $this->assertSame(0, $oid); /** @var array}> $xobjects */ $xobjects = $this->getObjectProperty($obj, 'xobjects'); $this->assertCount(1, $xobjects[$tid]['annotations'] ?? []); $obj->exitXObjectTemplate(); } /** * @throws \Throwable */ public function testAddFFTextCreatesWidgetAnnotation(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $oid = $obj->addFFText('field1', 1, 2, 40, 8, ['subtype' => 'Widget', 'v' => 'hello']); /** @var array}> $ann */ $ann = $this->getObjectProperty($obj, 'annotation'); $this->assertArrayHasKey($oid, $ann); $opt = $this->getAnnotationOpt($ann, $oid); $this->assertSame('Widget', $opt['Subtype'] ?? null); $this->assertSame('Tx', $opt['ft'] ?? null); $this->assertSame('field1', $opt['t'] ?? null); } /** * @throws \Throwable */ public function testSetAnnotationWidgetIsSuppressedInPdfxMode(): void { $obj = new \Com\Tecnick\Pdf\Tcpdf('mm', true, false, true, 'pdfx3'); $this->initFontAndPage($obj); $oid = $obj->setAnnotation(1, 2, 40, 8, 'field1', ['subtype' => 'Widget']); $this->assertSame(0, $oid); /** @var array}> $ann */ $ann = $this->getObjectProperty($obj, 'annotation'); $this->assertSame([], $ann); } /** * @throws \Throwable */ public function testSetAnnotationLinkStillWorksInPdfxMode(): void { $obj = new \Com\Tecnick\Pdf\Tcpdf('mm', true, false, true, 'pdfx3'); $this->initFontAndPage($obj); $oid = $obj->setLink(1, 2, 10, 4, '#dest'); $this->assertGreaterThan(0, $oid); /** @var array}> $ann */ $ann = $this->getObjectProperty($obj, 'annotation'); $this->assertArrayHasKey($oid, $ann); $opt = $this->getAnnotationOpt($ann, $oid); $this->assertSame('Link', $opt['subtype'] ?? null); } /** * @throws \Throwable */ public function testSetAnnotationInteractiveSubtypesAreSuppressedInPdfxMode(): void { $obj = new \Com\Tecnick\Pdf\Tcpdf('mm', true, false, true, 'pdfx3'); $this->initFontAndPage($obj); $file = (string) \realpath(__DIR__ . '/../README.md'); $this->assertNotSame('', $file); $subtypes = ['screen', 'movie', 'sound', 'fileattachment', '3d']; foreach ($subtypes as $subtype) { $opt = ['subtype' => $subtype]; if ($subtype === 'fileattachment' || $subtype === 'sound') { $opt['fs'] = $file; } $oid = $obj->setAnnotation(1, 2, 10, 4, 'blocked', $opt); $this->assertSame(0, $oid); } /** @var array}> $ann */ $ann = $this->getObjectProperty($obj, 'annotation'); $this->assertSame([], $ann); /** @var array $embeddedfiles */ $embeddedfiles = $this->getObjectProperty($obj, 'embeddedfiles'); $this->assertSame([], $embeddedfiles); } /** * @throws \Throwable */ public function testAddFFButtonCreatesButtonWidgetWithAction(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $oid = $obj->addFFButton('btnField', 1, 2, 30, 10, 'Caption', 'app.alert("x");'); /** @var array}> $ann */ $ann = $this->getObjectProperty($obj, 'annotation'); $this->assertArrayHasKey($oid, $ann); $opt = $this->getAnnotationOpt($ann, $oid); $this->assertSame('Widget', $opt['Subtype'] ?? null); $this->assertSame('Btn', $opt['ft'] ?? null); $this->assertSame('Caption', $opt['t'] ?? null); $this->assertSame('btnField', $opt['v'] ?? null); $this->assertArrayHasKey('a', $opt); $this->assertStringContainsString('/S /JavaScript /JS', $this->getRequiredString($opt, 'a', 'button action')); } /** * @throws \Throwable */ public function testAddFFButtonSupportsStructuredFormActionOptions(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $oid = $obj->addFFButton('submitField', 1, 2, 30, 10, 'Submit', [ 'S' => 'SubmitForm', 'F' => 'https://example.test/form', 'Fields' => ['alpha', 'beta', 123], 'Flags' => ['IncludeNoValueFields', 'SubmitPDF', 'EmbedForm'], ]); /** @var array}> $ann */ $ann = $this->getObjectProperty($obj, 'annotation'); $this->assertArrayHasKey($oid, $ann); $opt = $this->getAnnotationOpt($ann, $oid); $actionString = $this->getRequiredString($opt, 'a', 'structured action'); $this->assertStringContainsString('/S /SubmitForm', $actionString); $this->assertStringContainsString('/Fields [', $actionString); $this->assertStringContainsString('/Flags 8450', $actionString); $obj->addFFButton('allFlags', 1, 2, 30, 10, 'Submit', [ 'S' => 'SubmitForm', 'Flags' => [ 'Include/Exclude', 'ExportFormat', 'GetMethod', 'SubmitCoordinates', 'XFDF', 'IncludeAppendSaves', 'IncludeAnnotations', 'CanonicalFormat', 'ExclNonUserAnnots', 'ExclFKey', 123, 'UnknownFlag', ], ]); } /** * @throws \Throwable */ public function testAddFFCheckBoxCreatesCheckboxWidget(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $oid = $obj->addFFCheckBox('chkField', 2, 3, 5, 'Yes', true); /** @var array}> $ann */ $ann = $this->getObjectProperty($obj, 'annotation'); $this->assertArrayHasKey($oid, $ann); $opt = $this->getAnnotationOpt($ann, $oid); $this->assertSame('Widget', $opt['Subtype'] ?? null); $this->assertSame('Btn', $opt['ft'] ?? null); $this->assertSame('Yes', $opt['as'] ?? null); $this->assertSame(['Yes'], $opt['opt'] ?? null); } /** * @throws \Throwable */ public function testAddFFComboBoxCreatesChoiceWidget(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $vals = [['A', 'Alpha'], ['B', 'Beta']]; $oid = $obj->addFFComboBox('cmbField', 1, 2, 30, 12, $vals); /** @var array}> $ann */ $ann = $this->getObjectProperty($obj, 'annotation'); $this->assertArrayHasKey($oid, $ann); $opt = $this->getAnnotationOpt($ann, $oid); $this->assertSame('Widget', $opt['Subtype'] ?? null); $this->assertSame('Ch', $opt['ft'] ?? null); $this->assertSame('cmbField', $opt['t'] ?? null); $this->assertSame($vals, $opt['opt'] ?? null); } /** * Regression: addFFComboBox() must set the "Combo" field-flag bit so the * widget renders as a dropdown combo box and not as a list box. * getAnnotOptFromJSProp() matches the Combo property against the string * 'true'; passing a boolean true left the bit clear (ff === 0). * * @throws \Throwable */ public function testAddFFComboBoxSetsComboFieldFlag(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $comboId = $obj->addFFComboBox('cmbFlag', 1, 2, 30, 12, ['One', 'Two']); $listId = $obj->addFFListBox('lstFlag', 1, 2, 30, 12, ['One', 'Two']); // "Combo" field flag: bit 18 in the PDF /Ff entry (zero-based bit 17). $comboBit = 1 << 17; /** @var array}> $ann */ $ann = $this->getObjectProperty($obj, 'annotation'); /** @var int|null $comboFf */ $comboFf = $this->getAnnotationOpt($ann, $comboId)['ff'] ?? null; $this->assertIsInt($comboFf); $this->assertSame($comboBit, $comboFf & $comboBit, 'addFFComboBox() must set the Combo field-flag bit.'); // A list box reuses the same choice-widget builder but must NOT set the // Combo bit; this guards against the flag being applied unconditionally. /** @var int|null $listFf */ $listFf = $this->getAnnotationOpt($ann, $listId)['ff'] ?? null; $this->assertIsInt($listFf); $this->assertSame(0, $listFf & $comboBit, 'addFFListBox() must not set the Combo field-flag bit.'); } /** * @throws \Throwable */ public function testAddFFListBoxCreatesChoiceWidget(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $vals = ['One', 'Two']; $oid = $obj->addFFListBox('lstField', 1, 2, 30, 12, $vals); /** @var array}> $ann */ $ann = $this->getObjectProperty($obj, 'annotation'); $this->assertArrayHasKey($oid, $ann); $opt = $this->getAnnotationOpt($ann, $oid); $this->assertSame('Widget', $opt['Subtype'] ?? null); $this->assertSame('Ch', $opt['ft'] ?? null); $this->assertSame('lstField', $opt['t'] ?? null); $this->assertSame($vals, $opt['opt'] ?? null); } /** * @throws \Throwable */ public function testAddFFRadioButtonCreatesRadioGroupAndWidget(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $oid = $obj->addFFRadioButton('radField', 3, 4, 6, 'On', true); /** @var array}> $ann */ $ann = $this->getObjectProperty($obj, 'annotation'); $this->assertArrayHasKey($oid, $ann); $opt = $this->getAnnotationOpt($ann, $oid); $this->assertSame('Widget', $opt['Subtype'] ?? null); $this->assertSame('Btn', $opt['ft'] ?? null); $this->assertSame('On', $opt['as'] ?? null); /** @var array}> $groups */ $groups = $this->getObjectProperty($obj, 'radiobuttons'); $this->assertArrayHasKey('radField', $groups); $this->assertNotEmpty($groups['radField']['kids'] ?? []); } /** * @throws \Throwable */ public function testAddJSFieldWrappersAppendExpectedScripts(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); \set_error_handler( static fn(int $errno, string $errstr): bool => $errno === E_WARNING && \str_contains($errstr, 'Undefined array key "num"'), ); try { $obj->addJSButton('btn', 1, 2, 10, 5, 'Go', 'app.alert(1);'); $obj->addJSCheckBox('chk', 2, 3, 4); $obj->addJSComboBox('cmb', 3, 4, 20, 6, ['A', 'B']); $obj->addJSListBox('lst', 4, 5, 20, 6, ['X', 'Y']); $obj->addJSRadioButton('rad', 5, 6, 4); $obj->addJSText('txt', 6, 7, 20, 6); } finally { \restore_error_handler(); } /** @var string $jsScript */ $jsScript = $this->getObjectProperty($obj, 'javascript'); $this->assertStringContainsString("fbtn.buttonSetCaption('Go');", $jsScript); $this->assertStringContainsString("fbtn.setAction('MouseUp'", $jsScript); $this->assertStringContainsString("fchk=this.addField('chk','checkbox'", $jsScript); $this->assertStringContainsString('fcmb.setItems(', $jsScript); $this->assertStringContainsString('flst.setItems(', $jsScript); $this->assertStringContainsString("frad=this.addField('rad','radiobutton'", $jsScript); $this->assertStringContainsString("ftxt=this.addField('txt','text'", $jsScript); } /** * @throws \Throwable */ public function testAddJSFieldWrappersHandleArrayValuedSelections(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); \set_error_handler( static fn(int $errno, string $errstr): bool => $errno === E_WARNING && \str_contains($errstr, 'Undefined array key "num"'), ); try { $obj->addJSComboBox('cmb2', 1, 2, 20, 6, [['A', 'Alpha'], ['B', 'Beta']]); $obj->addJSListBox('lst2', 1, 2, 20, 6, [['X', 'Ex'], ['Y', 'Why']]); } finally { \restore_error_handler(); } /** @var string $jsScript */ $jsScript = $this->getObjectProperty($obj, 'javascript'); $this->assertStringContainsString("fcmb2.setItems(['Alpha','A'],['Beta','B']);", $jsScript); $this->assertStringContainsString("flst2.setItems(['Ex','X'],['Why','Y']);", $jsScript); } /** * @throws \Throwable */ public function testGetAnnotOptFromJSPropCoversAdditionalMappingVariants(): void { $obj = $this->getInternalTestObject(); $this->assertSame(['f' => 7], $obj->exposeGetAnnotOptFromJSProp(['aopt' => ['f' => 7]])); $styleVariants = [ 'beveled' => 'B', 'inset' => 'I', 'underline' => 'U', 'solid' => 'S', ]; foreach ($styleVariants as $style => $expected) { $mapped = $obj->exposeGetAnnotOptFromJSProp(['borderStyle' => $style]); /** @var array $borderSpec */ $borderSpec = \is_array($mapped['bs'] ?? null) ? $mapped['bs'] : []; $this->assertSame($expected, $borderSpec['s'] ?? null); } $numericPos = $obj->exposeGetAnnotOptFromJSProp(['buttonPosition' => 5]); /** @var array $numericMk */ $numericMk = \is_array($numericPos['mk'] ?? null) ? $numericPos['mk'] : []; $this->assertSame(5, $numericMk['tp'] ?? null); $invalidPos = $obj->exposeGetAnnotOptFromJSProp(['buttonPosition' => 99]); /** @var array $invalidMk */ $invalidMk = \is_array($invalidPos['mk'] ?? null) ? $invalidPos['mk'] : []; $this->assertArrayNotHasKey('tp', $invalidMk); $filled = $obj->exposeGetAnnotOptFromJSProp([ 'fillColor' => [0.1, 0.2, 0.3], 'strokeColor' => [0.4, 0.5, 0.6], 'display' => 'display.hidden', 'highlight' => 'push', 'value' => 'plain-value', ]); /** @var array $filledMk */ $filledMk = \is_array($filled['mk'] ?? null) ? $filled['mk'] : []; $this->assertSame([0.1, 0.2, 0.3], $filledMk['bg'] ?? null); $this->assertSame([0.4, 0.5, 0.6], $filledMk['bc'] ?? null); $this->assertSame('P', $filled['h'] ?? null); $this->assertSame('plain-value', $filled['v'] ?? null); $this->assertSame(6, $filled['f'] ?? null); $noPrint = $obj->exposeGetAnnotOptFromJSProp(['display' => 'display.noPrint']); $this->assertSame(0, $noPrint['f'] ?? null); $visible = $obj->exposeGetAnnotOptFromJSProp(['display' => 'display.visible', 'highlight' => 'invert']); $this->assertSame(4, $visible['f'] ?? null); $this->assertSame('i', $visible['h'] ?? null); $defaultHighlight = $obj->exposeGetAnnotOptFromJSProp(['highlight' => 'unknown']); $this->assertSame('N', $defaultHighlight['h'] ?? null); $highlightAlias = $obj->exposeGetAnnotOptFromJSProp(['highlight' => 'highlight.o']); $this->assertSame('O', $highlightAlias['h'] ?? null); $borderOpt = $obj->exposeGetAnnotOptFromJSProp(['border' => [1, 2, 3]]); $this->assertSame([1, 2, 3], $borderOpt['border'] ?? null); $scaleHowMap = [ 'scaleHow.proportional' => 'P', 'scaleHow.invalid' => 'P', ]; foreach ($scaleHowMap as $scaleHow => $expected) { $mapped = $obj->exposeGetAnnotOptFromJSProp(['buttonScaleHow' => $scaleHow]); /** @var array $scaleHowMk */ $scaleHowMk = \is_array($mapped['mk'] ?? null) ? $mapped['mk'] : []; /** @var array $scaleHowIf */ $scaleHowIf = \is_array($scaleHowMk['if'] ?? null) ? $scaleHowMk['if'] : []; $this->assertSame($expected, $scaleHowIf['s'] ?? null); } $scaleWhenMap = [ 'scaleWhen.always' => 'A', 'scaleWhen.never' => 'N', 'scaleWhen.tooBig' => 'B', 'scaleWhen.invalid' => 'N', ]; foreach ($scaleWhenMap as $scaleWhen => $expected) { $mapped = $obj->exposeGetAnnotOptFromJSProp(['buttonScaleWhen' => $scaleWhen]); /** @var array $scaleWhenMk */ $scaleWhenMk = \is_array($mapped['mk'] ?? null) ? $mapped['mk'] : []; /** @var array $scaleWhenIf */ $scaleWhenIf = \is_array($scaleWhenMk['if'] ?? null) ? $scaleWhenMk['if'] : []; $this->assertSame($expected, $scaleWhenIf['sw'] ?? null); } $positionMap = [ 'position.textOnly' => 0, 'position.iconOnly' => 1, 'position.iconTextV' => 2, 'position.textIconV' => 3, 'position.iconTextH' => 4, 'position.textIconH' => 5, 'position.overlay' => 6, 'position.unknown' => 0, ]; foreach ($positionMap as $position => $expected) { $mapped = $obj->exposeGetAnnotOptFromJSProp(['buttonPosition' => $position]); /** @var array $marker */ $marker = \is_array($mapped['mk'] ?? null) ? $mapped['mk'] : []; $this->assertSame($expected, $marker['tp'] ?? null); } $highlightMap = [ 'none' => 'N', 'highlight.n' => 'N', 'highlight.i' => 'i', 'highlight.p' => 'P', ]; foreach ($highlightMap as $highlight => $expected) { $mapped = $obj->exposeGetAnnotOptFromJSProp(['highlight' => $highlight]); $this->assertSame($expected, $mapped['h'] ?? null); } $fillColorFallback = $this->getInternalTestObject(); $this->assertMatchesRegularExpression('/(rg\\n|^$)/', $fillColorFallback->exposeGetPDFDefFillColor()); $this->initFontAndPage($fillColorFallback); $merged = $fillColorFallback->exposeMergeAnnotOptions(['subtype' => 'text'], []); $this->assertArrayHasKey('da', $merged); $this->assertStringContainsString('0.000000 0.000000 0.000000 rg', $this->getRequiredString( $merged, 'da', 'default appearance stream', )); $fillColorFallback->addFFText( 'disabled_preview', 15, 18, 80, 8, ['subtype' => 'text', 'v' => 'input:disabled rule'], [ 'readonly' => 'true', 'fillColor' => 'rgba(93%,93%,93%,1)', 'textColor' => 'rgba(55%,55%,55%,1)', ], ); /** @var array> $ann */ $ann = $this->getObjectProperty($fillColorFallback, 'annotation'); $last = reset($ann); if (!\is_array($last)) { $this->fail('Expected at least one annotation entry.'); } $apn = ''; if (isset($last['opt']) && \is_array($last['opt'])) { $opt = $last['opt']; if (isset($opt['ap']) && \is_array($opt['ap']) && isset($opt['ap']['n']) && \is_string($opt['ap']['n'])) { $apn = $opt['ap']['n']; } } $this->assertStringContainsString('(input:disabled rule) Tj', $apn); $this->assertDoesNotMatchRegularExpression('/\s-\d+\.\d+\s+Td\s+\(input:disabled rule\)\s+Tj/', $apn); } /** * @throws \Throwable */ public function testXObjectTemplateAppliesDeferredAnnotationTransform(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $templateId = $obj->newXObjectTemplate(-1, -1); $deferredId = $obj->setAnnotation(1, 1, 5, 5, 'in-template', ['subtype' => 'Text']); $this->assertSame(0, $deferredId); $obj->exitXObjectTemplate(); $rendered = $obj->getXObjectTemplate($templateId, 1, 1, 0, 0); $this->assertStringContainsString('/' . $templateId . ' Do', $rendered); /** @var array $annotation */ $annotation = $this->getObjectProperty($obj, 'annotation'); $this->assertNotEmpty($annotation); } /** * @throws \Throwable */ public function testJsFieldPropertiesAndAnnotationAttachmentsCoverResidualBranches(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); \set_error_handler( static fn(int $errno, string $errstr): bool => $errno === E_WARNING && \str_contains($errstr, 'Undefined array key "num"'), ); try { $obj->addJSText('txtProps', 1, 2, 20, 6, ['strokeColor' => 'red', 'value' => 'abc']); } finally { \restore_error_handler(); } /** @var string $jsScript */ $jsScript = $this->getObjectProperty($obj, 'javascript'); $this->assertStringContainsString('ftxtProps.strokeColor=', $jsScript); $this->assertStringContainsString("ftxtProps.value='abc';", $jsScript); if (!\function_exists('imagecreatetruecolor') || !\function_exists('imagepng')) { $this->markTestSkipped('GD image functions are required for icon annotation coverage test.'); } $attachmentPath = (string) \realpath(__DIR__ . '/../README.md'); $iconPath = \tempnam(\sys_get_temp_dir(), 'tc-ico-'); $this->assertNotFalse($iconPath); $iconResource = \imagecreatetruecolor(1, 1); $this->assertNotFalse($iconResource); \imagepng($iconResource, $iconPath); $annotId = -1; try { $annotId = $obj->setAnnotation(1, 2, 3, 4, 'attach', [ 'subtype' => 'fileattachment', 'fs' => $attachmentPath, 'mk' => ['i' => $iconPath, 'ri' => $iconPath, 'ix' => $iconPath], ]); } catch (\Throwable $e) { $this->assertNotSame('', $e->getMessage()); } finally { if (\file_exists($iconPath)) { \unlink($iconPath); } } $this->assertGreaterThanOrEqual(-1, $annotId); /** @var array $embeddedfiles */ $embeddedfiles = $this->getObjectProperty($obj, 'embeddedfiles'); $this->assertArrayHasKey('README.md', $embeddedfiles); } /** * @throws \Throwable */ public function testAddFFButtonSupportsNumericFlagsAndAoptWithoutMk(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $oid = $obj->addFFButton( 'submitNumeric', 1, 2, 30, 10, 'Submit', ['S' => 'ResetForm', 'Flags' => 1024], ['subtype' => 'Widget'], ['aopt' => ['Subtype' => 'Widget', 'ft' => 'Btn']], ); /** @var array}> $annotation */ $annotation = $this->getObjectProperty($obj, 'annotation'); $this->assertArrayHasKey($oid, $annotation); $opt = $this->getAnnotationOpt($annotation, $oid); $actionString = $this->getRequiredString($opt, 'a', 'numeric flags action'); $this->assertStringContainsString('/S /ResetForm', $actionString); $this->assertStringContainsString('/Flags 1024', $actionString); } /** * @throws \Throwable */ public function testFFChoiceAndTextVariantsCoverScalarAndAlignmentPaths(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $comboId = $obj->addFFComboBox('comboScalar', 1, 2, 30, 12, ['One', 'Two']); $listId = $obj->addFFListBox('listScalar', 1, 2, 30, 12, ['Red', 'Blue']); $listArrayId = $obj->addFFListBox('listArray', 1, 2, 30, 12, [['V1', 'Label 1'], ['V2', 'Label 2']]); $radioOffId = $obj->addFFRadioButton( 'radioGroup', 3, 4, 6, 'On', false, ['subtype' => 'Widget', 'q' => 0], ['aopt' => ['Subtype' => 'Widget', 'f' => 0], 'readonly' => 'true'], ); $radioOnId = $obj->addFFRadioButton('radioGroup', 9, 4, 6, 'On', true); $txtLeftId = $obj->addFFText( 'txtLeft', 1, 2, 30, 10, ['subtype' => 'Widget', 'q' => 0], ['alignment' => 'left', 'value' => 'L'], ); $txtCenterId = $obj->addFFText( 'txtCenter', 1, 2, 30, 10, ['subtype' => 'Widget'], ['alignment' => 'center', 'value' => 'C'], ); $txtRightId = $obj->addFFText( 'txtRight', 1, 2, 30, 10, ['subtype' => 'Widget', 'q' => 2], ['alignment' => 'right', 'value' => 'R'], ); $txtUnknownId = $obj->addFFText( 'txtUnknown', 1, 2, 30, 10, ['subtype' => 'Widget', 'q' => 99], ['value' => 'U'], ); /** @var array}> $annotation */ $annotation = $this->getObjectProperty($obj, 'annotation'); $this->assertArrayHasKey($comboId, $annotation); $this->assertArrayHasKey($listId, $annotation); $this->assertArrayHasKey($listArrayId, $annotation); $this->assertArrayHasKey($radioOffId, $annotation); $this->assertArrayHasKey($radioOnId, $annotation); $this->assertArrayHasKey($txtLeftId, $annotation); $this->assertArrayHasKey($txtCenterId, $annotation); $this->assertArrayHasKey($txtRightId, $annotation); $this->assertArrayHasKey($txtUnknownId, $annotation); /** @var array, '#readonly#': bool}> $radioGroups */ $radioGroups = $this->getObjectProperty($obj, 'radiobuttons'); $this->assertArrayHasKey('radioGroup', $radioGroups); $radioGroup = $radioGroups['radioGroup'] ?? ['#readonly#' => true, 'kids' => []]; $this->assertFalse($radioGroup['#readonly#'] ?? true); $kids = \is_array($radioGroup['kids'] ?? null) ? $radioGroup['kids'] : []; $this->assertSame('Off', $kids[0]['def'] ?? null); $this->assertSame('On', $kids[1]['def'] ?? null); } /** * @throws \Throwable */ public function testSetAnnotationDefaultsEmptySubtypeToText(): void { $obj = $this->getTestObject(); $oid = $obj->setAnnotation(1, 2, 3, 4, 'note', ['subtype' => '']); /** @var array}> $ann */ $ann = $this->getObjectProperty($obj, 'annotation'); $this->assertArrayHasKey($oid, $ann); $opt = $this->getAnnotationOpt($ann, $oid); $this->assertSame('text', \strtolower((string) ($opt['subtype'] ?? ''))); } /** * @throws \Throwable */ public function testGetAnnotOptFromJSPropCastsBooleanValuesToStrings(): void { $obj = $this->getInternalTestObject(); $opt = $obj->exposeGetAnnotOptFromJSProp(['value' => [true, false]]); $this->assertSame(['true', 'false'], $opt['opt'] ?? null); } /** * @throws \Throwable */ public function testAddFFButtonAppliesStyleOverridesFromJsProperties(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $oid = $obj->addFFButton( 'styledField', 2, 3, 28, 10, 'Styled', '', ['subtype' => 'Widget'], [ 'strokeColor' => ' #112233 ', 'fillColor' => ' #ddeeff ', 'lineWidth' => 2, 'borderStyle' => 'DaShEd', ], ); /** @var array}> $ann */ $ann = $this->getObjectProperty($obj, 'annotation'); $this->assertArrayHasKey($oid, $ann); $opt = $this->getAnnotationOpt($ann, $oid); $this->assertArrayHasKey('ap', $opt); } /** * @throws \Throwable */ public function testGetPdfDefaultFillColorReturnsEmptyWhenNoFillIsSet(): void { $obj = $this->getInternalTestObject(); $this->assertMatchesRegularExpression('/(rg\\n|^$)/', $obj->exposeGetPDFDefFillColor()); } }