From 6e700f0c1897777b1424eb579158dcdfbc9af158 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kai=20Pre=C3=9Fmar?= Date: Sat, 5 Sep 2026 14:06:10 +0200 Subject: [PATCH] feat: add an onClick option for a form field's mouse-up action pdfkit deliberately restricted AcroForm options to documented mappings and a small set of raw escape hatches (Ff, MK.CA, and AA only together with a format option), with a stated intent to shrink and eventually remove even those. A mouse-up JavaScript action on a field -- most useful on a push button, e.g. to drive custom client-side logic -- had no supported way to reach the API at all outside that discouraged, format-only AA path. Add a dedicated onClick option instead, consistent with the project's stated direction of adding purpose-built options rather than widening raw dictionary access: it needs no PDF dictionary knowledge, works on its own, and still combines with format-validation actions exactly as the old AA + format combination did. Replaces the raw AA escape hatch: options.AA is no longer read at all, only options.onClick. --- CHANGELOG.md | 2 + docs/forms.md | 45 ++++++++++++++++- lib/mixins/acroform.js | 28 ++++++++++- package.json | 4 ++ tests/unit/acroform.spec.js | 60 +++++++++++++++++++++++ types/acrobat-js.d.ts | 96 +++++++++++++++++++++++++++++++++++++ 6 files changed, 232 insertions(+), 3 deletions(-) create mode 100644 types/acrobat-js.d.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f0a210ae8..5e0ea28b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ### Unreleased +- Add an `onClick` option to `formPushButton` (and other form annotation methods) for a field's mouse-up JavaScript action, replacing the previous `AA`-plus-`format` escape hatch. Accepts a plain function, called with Acrobat's `app`/`getField`/`display`/`event` as arguments and `this` bound to the Document, as well as a string. TypeScript projects can import types for this signature from the new `pdfkit/types/acrobat-js` + ### [v0.20.2] - 2026-08-29 - Fix bundlers and file tracers packing the ESM copies of the standard font metrics instead of the CommonJS ones the Node build actually loads, which left `Cannot find module` errors for every standard font at runtime, by resolving the internal `#standard-fonts/*` mapping to a single file under all conditions diff --git a/docs/forms.md b/docs/forms.md index 544eafb69..2a080be66 100644 --- a/docs/forms.md +++ b/docs/forms.md @@ -118,15 +118,56 @@ These options are accepted by `formPushButton`: - `label` [_string_] - Sets the label text. You can also set an icon, but for this you will need to 'expert-up' and dig deeper into the PDF Reference manual. +- `onClick` [_string | function_] - JavaScript to run when the button is + clicked (its mouse-up action). If a `format` option is also given, its + keystroke/format validation actions are added alongside this one rather + than replacing it. ```js var opts = { backgroundColor: 'yellow', - label: 'Test Button' + label: 'Test Button', + onClick: 'app.alert("clicked");' }; doc.formPushButton('btn1', 10, 200, 100, 30, opts); ``` +`onClick` also accepts a plain function, called with Acrobat's own `app`, +`getField`, `display` and `event` passed in as arguments, in that order (and +`this` bound to the Document, exactly as Acrobat itself binds it). Declare +only the leading parameters your handler actually uses — `function (app) {}` +or even `function () {}` are both fine, since the call always passes all of +them regardless of how many the handler declares; the rest are simply +ignored, the same way `array.map(item => ...)` can ignore the `index` and +`array` parameters its callback type also offers. This runs inside the PDF +viewer's own JavaScript engine, not wherever the PDF was generated, so it +can't close over outside variables — use only plain function syntax (not +e.g. arrow functions, which also can't bind `this`) for the widest viewer +support: + +```js +doc.formPushButton('btn1', 10, 200, 100, 30, { + label: 'Test Button', + onClick: function (app, getField) { + app.alert('clicked'); + this.getField('otherField').value = 'updated from btn1'; + } +}); +``` + +TypeScript projects can import `AcrobatOnClick` and the other types this +signature uses from `pdfkit/types/acrobat-js` — a small, best-effort set of +types for the handful of Acrobat globals most `onClick` handlers need, kept +separate from pdfkit's own types so nothing is declared globally: + +```ts +import type { AcrobatOnClick } from 'pdfkit/types/acrobat-js'; + +const onClick: AcrobatOnClick = function (app) { + app.alert('clicked'); +}; +``` + #### Radio Button Field Options These options are accepted by `formRadioButton`: @@ -302,7 +343,7 @@ The output of this example looks like this. ### Advanced Form Field Use -Older implementations used to pass all unknown options to the internal PDF object structure. A small set of direct PDF dictionary escape hatches is still recognized: `Ff`, `MK.CA`, and `AA` when a `format` option is used but its use is discouraged and likely will be removed in future versions. +Older implementations used to pass all unknown options to the internal PDF object structure. A small set of direct PDF dictionary escape hatches is still recognized: `Ff` and `MK.CA`, but their use is discouraged and they may be removed in future versions. A previously-recognized `AA` escape hatch (only reachable together with a `format` option) has been replaced by the `onClick` option above, which needs no PDF dictionary knowledge and works on its own. If an option is not supported, open an issue on Github and it will be considered for addition to the API. diff --git a/lib/mixins/acroform.js b/lib/mixins/acroform.js index d283b1c4b..2077f4206 100644 --- a/lib/mixins/acroform.js +++ b/lib/mixins/acroform.js @@ -108,6 +108,31 @@ function mapStrings(options, pdfObject) { } } +function mapActions(options, pdfObject) { + if (options.onClick) { + // A function is stringified and immediately invoked with `this` bound to + // the Document (exactly as Acrobat itself binds it in any field action) + // and Acrobat's own `app`, `getField`, `display` and `event` globals + // passed in as arguments, so authors can write the action as a real, + // typed function (see types/acrobat-js.d.ts) instead of a hand-built + // string or relying on ambient global declarations that risk colliding + // with an unrelated identifier elsewhere in their project. It still runs + // inside Acrobat's own JavaScript engine, not wherever the PDF was + // generated, so it can't close over outside variables, and only plain + // function syntax (not arrow functions or other syntax Acrobat's engine + // may not support) should be relied on. + const js = + typeof options.onClick === 'function' + ? `(${options.onClick}).call(this, app, getField, display, event);` + : options.onClick; + pdfObject.AA = pdfObject.AA ?? {}; + pdfObject.AA.U = { + S: 'JavaScript', + JS: new String(js), + }; + } +} + function mapFormat(options, pdfObject) { const f = options.format; if (f?.type) { @@ -148,7 +173,7 @@ function mapFormat(options, pdfObject) { params = String([String(p.nDec), p.sepComma ? '0' : '1'].join(',')); } } - pdfObject.AA = options.AA ?? {}; + pdfObject.AA = pdfObject.AA ?? {}; pdfObject.AA.K = { S: 'JavaScript', JS: new String(`${fnKeystroke}(${params});`), @@ -311,6 +336,7 @@ export default { this._mapFont(options, pdfObject); mapStrings(options, pdfObject); this._mapColors(options, pdfObject); + mapActions(options, pdfObject); mapFormat(options, pdfObject); pdfObject.T = new String(name); diff --git a/package.json b/package.json index dd170a6ec..88788ac3f 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,10 @@ "require": "./js/output.cjs", "default": "./js/output.mjs" }, + "./types/acrobat-js": { + "types": "./types/acrobat-js.d.ts", + "default": "./types/acrobat-js.d.ts" + }, "./standard-fonts/Courier": { "require": "./js/standard-fonts/Courier.cjs", "default": "./js/standard-fonts/Courier.mjs" diff --git a/tests/unit/acroform.spec.js b/tests/unit/acroform.spec.js index ac01fe6a1..e264be4c4 100644 --- a/tests/unit/acroform.spec.js +++ b/tests/unit/acroform.spec.js @@ -139,6 +139,66 @@ describe('acroform', () => { expect(docData[2]).toBe(expected[2]); }); + test('push button with an onClick action', () => { + const expected = [ + '10 0 obj', + '<<\n/FT /Btn\n/Ff 65536\n/AA <<\n/U <<\n/S /JavaScript\n/JS (app.alert\\(1\\);)\n>>\n>>\n' + + '/T (btn1)\n/Subtype /Widget\n/F 4\n/Type /Annot\n/Rect [20 742 120 772]\n/Border [0 0 0]\n/C [0 0 0]\n>>', + 'endobj', + ]; + doc.initForm(); + const docData = logData(doc); + doc.formPushButton('btn1', 20, 20, 100, 30, { onClick: 'app.alert(1);' }); + expect(docData.length).toBe(3); + expect(docData).toContainChunk(expected); + }); + + test('push button with an onClick action given as a function', () => { + doc.initForm(); + const docData = logData(doc); + // Written the way types/acrobat-js.d.ts's AcrobatOnClick expects: app + // and the other Acrobat globals arrive as parameters, not references to + // ambient globals, so nothing here needs pdfkit-specific lint/type setup. + function onClick(app) { + app.alert('clicked'); + } + doc.formPushButton('btn1', 20, 20, 100, 30, { onClick }); + + // The function is stringified and invoked with Acrobat's globals; PDF + // string literals escape parens and newlines, so build the expectation + // the same way rather than hardcoding the exact whitespace + // `Function.prototype.toString()` happens to use (see lib/object.js's + // `escapable` map). + const expectedJs = + `(${onClick}).call(this, app, getField, display, event);`.replace( + /[\n\r\t\b\f()\\]/g, + (char) => ({ '\n': '\\n', '\r': '\\r', '(': '\\(', ')': '\\)' })[char], + ); + expect(docData[1]).toContain('/S /JavaScript'); + expect(docData[1]).toContain(expectedJs); + }); + + test('an onClick action and text formatting combine into one AA dictionary', () => { + doc.initForm(); + const docData = logData(doc); + let opts = { + value: 32.98, + onClick: 'app.alert(1);', + format: { + type: 'number', + nDec: 2, + }, + }; + doc.formText('dollars', 20, 20, 50, 20, opts); + // The onClick action survives... + expect(docData[1]).toContain( + '/U <<\n/S /JavaScript\n/JS (app.alert\\(1\\);)\n>>', + ); + // ...alongside the format-validation actions mapFormat() adds. + expect(docData[1]).toContain('/K <<\n/S /JavaScript'); + expect(docData[1]).toContain('/F <<\n/S /JavaScript'); + }); + test('type flags do not leak implementation markers', () => { doc.initForm(); const docData = logData(doc); diff --git a/types/acrobat-js.d.ts b/types/acrobat-js.d.ts new file mode 100644 index 000000000..b4a560de6 --- /dev/null +++ b/types/acrobat-js.d.ts @@ -0,0 +1,96 @@ +/** + * Minimal, best-effort types for the small subset of Adobe Acrobat's own + * JavaScript API commonly needed to write a form field's `onClick` action + * (see docs/forms.md). This is not a full Acrobat SDK type surface -- only + * the handful of globals most `onClick` handlers reach for. Contributions + * extending it are welcome. + * + * These aren't ambient/global declarations: `app`, `getField`, `display` and + * `event` only exist inside a PDF viewer's own JavaScript engine at the + * moment the action runs, never in the Node or browser code that builds the + * PDF, so declaring them as globals would risk colliding with unrelated + * identifiers elsewhere in a project (a bare global `event`, for example, + * collides with the DOM lib's own deprecated `window.event`). + * + * Instead, write `onClick` as a function that takes them as parameters -- + * pdfkit calls the generated action with the real Acrobat globals in that + * position (and `this` bound to the Document, exactly as Acrobat itself + * binds it in any field action), so this works exactly like referencing + * them as globals would, without ever declaring one: + * + * import type { AcrobatOnClick } from 'pdfkit/types/acrobat-js'; + * + * const onClick: AcrobatOnClick = function (app, getField, display) { + * app.alert('clicked'); + * this.getField('otherField').value = 'updated from btn1'; + * }; + * + * Declare only the leading parameters your handler actually uses -- + * `function (app) {}` or even `function () {}` are both valid AcrobatOnClick + * values. The call always passes all of them (`this`, `app`, `getField`, + * `display`, `event`, in that order); a handler that declares fewer simply + * never sees the rest, the same way `array.map(item => ...)` can ignore the + * `index` and `array` parameters its callback type also offers. + * + * (`this` isn't available in an arrow function, and Acrobat's own JS engine + * may not support arrow function syntax at all -- write `onClick` as a + * plain `function` for the widest viewer support.) + */ + +/** + * Partial: Acrobat's real `app` object has many more methods (`execDialog`, + * `launchURL`, `response`, `thermometer`, ...). Only the ones common enough + * to include here are listed. + */ +export interface AcrobatApp { + alert( + message: string, + icon?: number, + type?: number, + title?: string, + ): number; + execMenuItem(name: string): void; +} + +/** Partial: a real field object has many more properties than these. */ +export interface AcrobatField { + value: string | number; + display: number; + readonly: boolean; + hidden: boolean; +} + +/** Complete: this is Acrobat's full, fixed set of `display` constants. */ +export interface AcrobatDisplay { + visible: 0; + hidden: 1; + noPrint: 2; + noView: 3; +} + +export type AcrobatGetField = (name: string) => AcrobatField; + +/** Partial: a real field-action event object has more properties than these. */ +export interface AcrobatEvent { + target: AcrobatField; + value: string | number; + rc: boolean; + willCommit: boolean; +} + +/** + * Partial: the Document object Acrobat binds `this` to in any field action. + * A real Document has hundreds of members; only these two are declared here. + */ +export interface AcrobatDocument { + getField: AcrobatGetField; + numPages: number; +} + +export type AcrobatOnClick = ( + this: AcrobatDocument, + app: AcrobatApp, + getField: AcrobatGetField, + display: AcrobatDisplay, + event: AcrobatEvent, +) => void;