Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
39 changes: 39 additions & 0 deletions docs/forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down Expand Up @@ -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 = {
Expand Down
35 changes: 35 additions & 0 deletions lib/mixins/acroform.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 =

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keep it simple, pass the base minimum arguments to get it working

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — it passes no arguments now, just (fn).call(this);. Only the this binding remains, which is the binding a viewer already gives any field action, and it is one token rather than a parameter list.

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) {
Expand Down Expand Up @@ -325,6 +359,7 @@ export default {
mapStrings(options, pdfObject);
this._mapColors(options, pdfObject);
mapFormat(options, pdfObject);
mapActions(options, pdfObject);

pdfObject.T = new String(name);

Expand Down
100 changes: 100 additions & 0 deletions tests/unit/acroform.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading