From 5ccc29d48381432672f850d1e9539db6d3d7f319 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 JavaScript action options for form fields Add `onClick`, `onMouseDown`, `onMouseEnter`, `onMouseExit`, `onFocus` and `onBlur` to the options every form annotation method accepts, mapping to the widget annotation's /AA entries. Each takes the JavaScript to run, as a string or as a plain function whose source text is written into the action. The `AA` escape hatch keeps working exactly as documented: mapFormat() still seeds the dictionary from it, and the action options are merged in afterwards. --- CHANGELOG.md | 1 + docs/forms.md | 39 ++++++++++++++ lib/mixins/acroform.js | 35 +++++++++++++ tests/unit/acroform.spec.js | 100 ++++++++++++++++++++++++++++++++++++ 4 files changed, 175 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a42a5e82..47a94d8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Add a `hidden` option to form annotation methods, for a field that should start hidden (e.g. one an interactive action reveals later) instead of the usual default of visible and printable - Fix annotations placed under `doc.rotate()` marking the wrong area, because `_convertRect` derived each corner's y from the already transformed x and mapped only two of the four corners, so the rectangle a viewer makes interactive did not follow the rotated content. Fixes #1153 +- Add `onClick`, `onMouseDown`, `onMouseEnter`, `onMouseExit`, `onFocus` and `onBlur` options to form annotation methods, for the JavaScript a field runs on each of those events. Each accepts a string or a plain function, whose source text is written into the action ### [v0.20.2] - 2026-08-29 diff --git a/docs/forms.md b/docs/forms.md index 8b36ec77..db6f4d95 100644 --- a/docs/forms.md +++ b/docs/forms.md @@ -63,6 +63,43 @@ Color options accept an array of RGB values, a hex color, or a named CSS color. Method-specific options listed below are accepted in addition to these common options. +#### Field Actions + +These options are accepted by all form annotation methods. Each takes the +JavaScript to run when the event occurs, given as a string or as a function: + +- `onClick` - The mouse button is released inside the field. +- `onMouseDown` - The mouse button is pressed inside the field. +- `onMouseEnter` - The cursor enters the field. +- `onMouseExit` - The cursor leaves the field. +- `onFocus` - The field receives the input focus. +- `onBlur` - The field loses the input focus. + +```js +doc.formPushButton('btn1', 10, 200, 100, 30, { + label: 'Test Button', + onClick: 'app.alert("clicked");' +}); +``` + +A function is written into the document as its source text and runs in the +viewer, not where the document was generated, so it cannot use variables or +functions from the surrounding program. `this` is the document, and the +viewer's own globals, such as `app`, are in scope: + +```js +doc.formPushButton('btn1', 10, 200, 100, 30, { + label: 'Test Button', + onClick: function () { + app.alert('clicked'); + this.getField('otherField').value = 'updated from btn1'; + } +}); +``` + +Write the handler with `function` syntax; an arrow function cannot take the +`this` binding. + #### Text Field Options These options are accepted by `formText`: @@ -121,6 +158,8 @@ 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. See [Field Actions](#field-actions). ```js var opts = { diff --git a/lib/mixins/acroform.js b/lib/mixins/acroform.js index 6ae9ee56..8178c4a8 100644 --- a/lib/mixins/acroform.js +++ b/lib/mixins/acroform.js @@ -120,6 +120,40 @@ function mapStrings(options, pdfObject) { } } +// Additional-action options, mapped to the entries of the widget annotation's +// /AA dictionary (PDF 32000-1 table 197) that a field can respond to. Each +// option takes the JavaScript to run when that event occurs. +const ANNOTATION_ACTIONS = { + onClick: 'U', // the mouse button is released inside the field + onMouseDown: 'D', // the mouse button is pressed inside the field + onMouseEnter: 'E', // the cursor enters the field's active area + onMouseExit: 'X', // the cursor leaves it + onFocus: 'Fo', // the field receives the input focus + onBlur: 'Bl', // the field loses it +}; + +function mapActions(options, pdfObject) { + for (const [option, key] of Object.entries(ANNOTATION_ACTIONS)) { + const action = options[option]; + if (!action) { + continue; + } + // A function is stringified into the action and called with `this` bound + // to the document, the same binding a viewer gives any field action. The + // action runs in the viewer's own JavaScript engine, so it cannot close + // over anything from the program that generated the document; write it as + // a plain function rather than an arrow function, which cannot take that + // binding. + const js = + typeof action === 'function' ? `(${action}).call(this);` : action; + pdfObject.AA = pdfObject.AA ?? {}; + pdfObject.AA[key] = { + S: 'JavaScript', + JS: new String(js), + }; + } +} + function mapFormat(options, pdfObject) { const f = options.format; if (f?.type) { @@ -325,6 +359,7 @@ export default { mapStrings(options, pdfObject); this._mapColors(options, pdfObject); mapFormat(options, pdfObject); + mapActions(options, pdfObject); pdfObject.T = new String(name); diff --git a/tests/unit/acroform.spec.js b/tests/unit/acroform.spec.js index c610e0f9..81c47e52 100644 --- a/tests/unit/acroform.spec.js +++ b/tests/unit/acroform.spec.js @@ -154,6 +154,106 @@ describe('acroform', () => { expect(docData[1]).toContain('/F 4'); }); + 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.each([ + ['onClick', 'U'], + ['onMouseDown', 'D'], + ['onMouseEnter', 'E'], + ['onMouseExit', 'X'], + ['onFocus', 'Fo'], + ['onBlur', 'Bl'], + ])('%s is written as the /%s action', (option, key) => { + doc.initForm(); + const docData = logData(doc); + doc.formText('txt1', 20, 20, 100, 20, { [option]: 'app.alert(1);' }); + expect(docData[1]).toContain( + `/AA <<\n/${key} <<\n/S /JavaScript\n/JS (app.alert\\(1\\);)\n>>\n>>`, + ); + }); + + test('several actions are written into one AA dictionary', () => { + doc.initForm(); + const docData = logData(doc); + doc.formText('txt1', 20, 20, 100, 20, { + onFocus: 'a();', + onBlur: 'b();', + }); + expect(docData[1]).toContain('/Fo <<\n/S /JavaScript\n/JS (a\\(\\);)\n>>'); + expect(docData[1]).toContain('/Bl <<\n/S /JavaScript\n/JS (b\\(\\);)\n>>'); + }); + + test('an action given as a function is called with the document as this', () => { + doc.initForm(); + const docData = logData(doc); + function onClick() { + this.getField('txt1').value = 'set'; + } + doc.formPushButton('btn1', 20, 20, 100, 30, { onClick }); + + // The function is written into the action as its own source text. 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);`.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 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('an action is added to the AA escape hatch rather than replacing it', () => { + doc.initForm(); + const docData = logData(doc); + doc.formText('dollars', 20, 20, 50, 20, { + value: 32.98, + onClick: 'app.alert(1);', + AA: { E: { S: 'JavaScript', JS: new String('enter();') } }, + format: { type: 'number', nDec: 2 }, + }); + expect(docData[1]).toContain( + '/E <<\n/S /JavaScript\n/JS (enter\\(\\);)\n>>', + ); + expect(docData[1]).toContain( + '/U <<\n/S /JavaScript\n/JS (app.alert\\(1\\);)\n>>', + ); + }); + test('type flags do not leak implementation markers', () => { doc.initForm(); const docData = logData(doc);