From 5a2fdd71eb968aefea4d3b3003472ff2352da941 Mon Sep 17 00:00:00 2001 From: Jim Pravetz Date: Mon, 29 Jul 2019 15:46:44 -0700 Subject: [PATCH] Removed direct support for radio and checkboxes. Added support for options.select, options.value, options.defaultValue. Fixed font implementation so that individual form fields can have their own font setting. Renamed methods, reducing reference to PDF langauge syntax. Added 'type' specifier to formAnnotation method. --- demo/test-acroform.js | 48 +++++----- docs/forms.md | 197 +++++++++++++++++++++++------------------ lib/mixins/acroform.js | 142 ++++++++++++++++++----------- 3 files changed, 226 insertions(+), 161 deletions(-) diff --git a/demo/test-acroform.js b/demo/test-acroform.js index e852e01..70dbfe0 100755 --- a/demo/test-acroform.js +++ b/demo/test-acroform.js @@ -7,51 +7,59 @@ var doc = new PDFDocument(); doc.pipe(fs.createWriteStream('out-acroform.pdf')); // Set some meta data -doc.info['Title'] = 'Test AcroForm Document'; -doc.info['Author'] = 'Jim Pravetz'; +doc.info['Title'] = 'Test Form Document'; +doc.info['Author'] = 'test-acroform.js'; -doc.registerFont('Courier', 'Courier'); doc.font('Helvetica'); // establishes the default font for forms -doc.initForms(); +doc.initForm(); -let rootField = doc.field('rootField'); -let child1Field = doc.field('child1Field', { Parent: rootField }); -let child2Field = doc.field('child2Field', { Parent: rootField }); +let rootField = doc.formField('rootField'); +doc.font('Courier'); +let child1Field = doc.formField('child1Field', { parent: rootField }); +doc.font('Helvetica'); +let child2Field = doc.formField('child2Field', { parent: rootField }); let y = 10; doc.formText('leaf1', 10, y, 200, 40, { - Parent: child1Field, + parent: child1Field, multiline: true }); y += 50; -doc.font('Courier'); doc.formText('leaf2', 10, y, 200, 40, { - Parent: child1Field, + parent: child1Field, multiline: true, align: 'right' }); y += 50; doc.formText('leaf3', 10, y, 200, 80, { - Parent: child2Field, + parent: child2Field, multiline: true }); -y += 100; +y += 90; var opts = { backgroundColor: 'yellow', label: 'Test Button' }; doc.formPushButton('btn1', 10, y, 100, 30, opts); -y += 50; +y += 40; opts = { - backgroundColor: 'yellow', - Opt: ['', 'github', 'bitbucket', 'gitlab'], - V: '', - DV: '', - align: 'left', - combo: true + borderColor: 'black', + select: ['Select Option', 'github', 'bitbucket', 'gitlab'], + value: 'Select Option', + defaultValue: 'Select Option', + align: 'center', + edit: true }; -doc.formChoice('ch1', 10, y, 100, 20, opts); +doc.formCombo('ch1', 10, y, 100, 20, opts); + +y += 30; +opts = { + borderColor: '#808080', + select: ['github', 'bitbucket', 'gitlab', 'sourcesafe', 'perforce'], + sort: true +}; +doc.formList('ch2', 10, y, 100, 45, opts); doc.end(); diff --git a/docs/forms.md b/docs/forms.md index 3a8f56a..42832a5 100644 --- a/docs/forms.md +++ b/docs/forms.md @@ -1,42 +1,43 @@ # Forms in PDFKit -Forms are interactive features of the PDF format. They make it possible to -add annotations such as text fields, combo boxes, buttons and actions. To -include Forms in the PDF that you are generating you must call the document -`initForms()` method. +Forms are an interactive feature of the PDF format. Forms make it possible to +add form annotations such as text fields, combo boxes, buttons and actions. +Before addings forms to a PDF you must call the document `initForm()` method. -- `initForms()` - Must be called before adding any forms or fields to the - document. +- `initForm()` - Must be called before adding a form annotation to the document. ```javascript doc.font('Helvetica'); // establishes the default form field font -doc.initForms(); +doc.initForm(); ``` -## Forms Methods +## Form Annotation Methods -Form elements are _Widget Annotations_ and are added using the document -`widgetAnnotation` method. Additional methods listed below are shortcut methods -that call the `widgetAnnotation` method. The list of the available _Widget -Annotation_ document methods is: +Form annotations are added using the following document methods. -- `widgetAnnotation( name, x, y, width, height, options)` - `formText( name, x, y, width, height, options)` - `formPushButton( name, x, y, width, height, name, options)` -- `formRadioButton( name, x, y, width, height, options)` -- `formChoice( name, x, y, width, height, options)` +- `formCombo( name, x, y, width, height, options)` +- `formList( name, x, y, width, height, options)` -Each field is given a `name` that is used to identify the form field. Field +The above methods call the `formAnnotation` method with +type set to one of `text`, `pushButton`, `radioButton`, `combo` or `list`. + +- `formAnnotation( name, type, x, y, width, height, options)` + +### `name` Parameter + +Form annotations are each given a `name` that is used for identification. Field names are hierarchical using a period ('.') as a separaror (_e.g._ _shipping.address.street_). More than one form field can have the same name. When this happens, the fields will have the same value. There is more information on `name` in the **Field Names** section below. -### Options Parameter +### `options` Parameter #### Common Options -Widget Annotation `options` that are common across all field types are: +Form Annotation `options` that are common across all form annotation types are: - `required` [_boolean_] - The field must have a value by the time the form is submitted. - `noExport` [_boolean_] - The field will not be exported if a form is submitted. @@ -47,8 +48,8 @@ Widget Annotation `options` that are common across all field types are: - `defaultValue` [_number|string_] - The default value to which the field reverts if a reset-form action is executed. -Some Widget Annotations have a `color` option that can be specified. You can use -an array of RGB values, a hex color, or a named CSS color value for that option. +Some form annotations have `color` options. You can use an array of RGB values, +a hex color, or a named CSS color value for that option. - `backgroundColor` - field background color - `borderColor` - field border color @@ -73,35 +74,28 @@ doc.formText('leaf2', 10, 60, 200, 40, { }); ``` -#### Choice Field Options +#### Combo and List Field Options -- `combo` [_boolean_] - If set, the field is a combo box, otherwise it is a list box. -- `edit` [_boolean_] - For combo boxes, allows for the user to enter a value in the field. - `sort` [_boolean_] - The field options will be sorted alphabetically. -- `noSpell` [_boolean_] - If set, text entered in the field is not spell-checked. -- `choices` [_array_] - Array of choices to display in the combo box. +- `edit` [_boolean_] - (combo only) Allow the user to enter a value in the field. +- `multiSelect` [_boolean_] - Allow more than one choice to be selected. +- `noSpell` [_boolean_] - (combo only) If set and `edit` is true, text entered in the field is not spell-checked. +- `select` [_array_] - Array of choices to display in the combo or list form field. ```js opts = { - choices: ['', 'github', 'bitbucket', 'gitlab'], + select: ['', 'github', 'bitbucket', 'gitlab'], value: '', defaultValue: '', - align: 'left', - combo: true + align: 'left' }; -doc.formChoice('ch1', 10, y, 100, 20, opts); +doc.formCombo('ch1', 10, y, 100, 20, opts); ``` #### Button Field Options -- `label` [_string_] - For buttons, sets the label text. -- `pushButton` [_boolean_] - If set, the field is a pushbutton that does not - retain a permanent value. -- `radioButton` [_boolean_] - If set, the field is a set of radio buttons; if - clear, the field is a checkbox. This flag is meaningful only if the `pushbutton` - flag is not true. -- `toggleToOffButton` [_boolean_] - For Radio Buttons, if set, then exactly one radio - button will be selected at one time. +- `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. ```js var opts = { @@ -113,19 +107,20 @@ doc.formPushButton('btn1', 10, 200, 100, 30, opts); ### Text Field Formatting -When needing to format the text value of a Widget Annotation, the following +When needing to format the text value of a Form Annotation, the following `options` are available. This will cause predefined document JavaScript actions -to automatically format the text. Refer to the section on **Formatting scripts** -in the Acrobat SDK documentation for the [Acrobat Forms +to automatically format the text. Refer to the section _Formatting scripts_ in +[Acrobat Forms Plugin](https://help.adobe.com/en_US/acrobat/acrobat_dc_sdk/2015/HTMLHelp/#t=Acro12_MasterBook%2FIAC_API_FormsIntro%2FMethods1.htm) -for more information. +of the Acrobat SDK documentation for more information. -Add a format dictionary to `options`. The dictionary will contain a type and optional additional formatting. +Add a format dictionary to `options`. The dictionary must contain a `type` attribute. - `format` - generic object - `format.type` - value must be one of `date`, `time`, `percent`, `number`, `zip`, `zipPlus4`, `phone` or `ssn`. -The types `date`, `time`, `percent` and `number` have additional formatting. +When `type` is `date`, `time`, `percent` or `number` the format dictionary must +contain additional parameters as described below. #### Date format @@ -164,10 +159,10 @@ doc.formText('field.time', 10, 60, 200, 40, { #### Number and percent format - `format.nDec` [_number_] - the number of places after the decimal point -- `format.sepComma` [_boolean_] - display a comma separator -- `format.negStyle` [_string_] (`number` only) - the value must be one of `MinusBlack` , `Red`, `ParensBlack`, `ParensRed` -- `format.currency` [_string_] (`number` only) - the currency symbol -- `format.currencyPrepend` [_boolean_] (`number` only) - set to true to prepend the currency symbol +- `format.sepComma` [_boolean_] - display a comma separator, otherwise do not display a separator. +- `format.negStyle` [_string_] (number only) - the value must be one of `MinusBlack` , `Red`, `ParensBlack`, `ParensRed` +- `format.currency` [_string_] (number only) - a currency symbol to display +- `format.currencyPrepend` [_boolean_] (number only) - set to true to prepend the currency symbol ```js // Currency text field formatting @@ -185,63 +180,57 @@ doc.formText('leaf2', 10, 60, 200, 40, { }); ``` -### Advanced Form Field Use - -Forms can be quite complicated and your needs will likely grow to sometimes need -to directly specify the attributes that will go into the Widget Annotation or -Field dictionaries. Consult the **PDF Reference** and set these attributes in -the `options` object. Any options that are not listed above will be added -directly to the corresponding PDF Object. - -## Other Methods - -The font used for a Widget Annotation is set using the `document.font` method. -Yes that's the same method as is used when setting the text font. - -In support of Widget Annotations that execute JavaScript in PDF, you can use the -following document method: - -- `addNamedJavaScript( name, string )` - ## Field Names -Widget Annotations are, by default, added to the Catalog's `Form` dictionary -as an array of `Fields`. Fields are organized in a name heirarchy, for example -_shipping.address.street_. You can either set the `name` of each Widget -Annotation with the full name (e.g. _shipping.address.street_) or you can create -parent Fields. In this example you might have a _shipping_ field that is added -to the Form Fields array, an _address_ field that refers to the _shipping_ -Field as it's parent, and a _street_ Widget Annotation that would refer to the -_address_ field as it's parent. To create a field use the document method: +Form Annotations are, by default, added to the root of the PDF document. A PDF +form is organized in a name heirarchy, for example _shipping.address.street_. +Capture this heirarchy either by setting the `name` of each _form annotation_ with +the full hierarchical name (e.g. _shipping.address.street_) or by creating a +hierarchy of _form fields_ and _form annotations_ and refering to a form field or form +annotations parent using `options.parent`. -- `field( name, options )` - returns a reference to the field +A _form field_ is an invisible node in the PDF form and is created using the +document `formField` method. A form field must include the node's `name` (e.g. +_shipping_) and may include other information such as the default font that is +to be used by all child form annotations. -To specify the parent of a _Field_ or _Widget Annotation_, set the `parent` -options to the field reference. +Using the `formField` method you might create a _shipping_ field that is added +to the root of the document, an _address_ field that refers to the _shipping_ +field as it's parent, and a _street_ Form Annotation that would refer to the +_address_ field as it's parent. -Example PDF using field hierarchy, three text fields and a push -button. +Create form fields using the document method: + +- `formField( name, options )` - returns a reference to the field + +-- _Example PDF using field hierarchy, three text fields and a push +button_ -- ```javascript doc.font('Helvetica'); // establishes the default font -doc.initForms(); +doc.initForm(); -let rootField = doc.field('rootField'); -let child1Field = doc.field('child1Field', { Parent: rootField }); -let child2Field = doc.field('child2Field', { Parent: rootField }); +let rootField = doc.formField('rootField'); +let child1Field = doc.formField('child1Field', { parent: rootField }); +let child2Field = doc.formField('child2Field', { parent: rootField }); + +// Add text form annotation 'rootField.child1Field.leaf1' doc.formText('leaf1', 10, 10, 200, 40, { - Parent: child1Field, + parent: child1Field, multiline: true }); +// Add text form annotation 'rootField.child1Field.leaf2' doc.formText('leaf2', 10, 60, 200, 40, { - Parent: child1Field, + parent: child1Field, multiline: true }); -doc.formText('leaf3', 10, 110, 200, 80, { - Parent: child2Field, +// Add text form annotation 'rootField.child2Field.leaf1' +doc.formText('leaf1', 10, 110, 200, 80, { + parent: child2Field, multiline: true }); +// Add push button form annotation 'btn1' var opts = { backgroundColor: 'yellow', label: 'Test Button' @@ -253,6 +242,33 @@ The output of this example looks like this. ![0](images/Forms.png) +### Advanced Form Field Use + +Forms can be quite complicated and your needs will likely grow to sometimes need +to directly specify the attributes that will go into the Form Annotation or +Field dictionaries. Consult the **PDF Reference** and set these attributes in +the `options` object. Any options that are not listed above will be added +directly to the corresponding PDF Object. + +## Font + +The font used for a Form Annotation is set using the `document.font` method. +Yes that's the same method as is used when setting the text font. + +The `font` method must be called before `initForm` and may be called before `formField` or any of the form annotation methods. + +```js +doc.setFont('Courier'); +doc.formText('myfield', 10, 10, 200, 20); +``` + +## Named JavaScript + +In support of Form Annotations that execute JavaScript in PDF, you may use the +following document method: + +- `addNamedJavaScript( name, string )` + ## Limitations It is recommended that you test your PDF form documents across all platforms and @@ -263,7 +279,7 @@ viewers that you wish to support. Form elements must each have an _appearance_ set using the `AP` attribute of the annotation. If this attribute is not set, the form element's value _may_ not be visible. Because appearances can be complex to generate, Adobe Acrobat has an -option to build these apperances from form values and Widget Annotation +option to build these apperances from form values and Form Annotation attributes when a PDF is first opened. To do this PDFKit always sets the Form dictionary's `NeedAppearances` attribute to true. This could mean that the PDF will be _dirty_ upon open, meaning it will need to be saved. @@ -271,7 +287,7 @@ _dirty_ upon open, meaning it will need to be saved. The `NeedAppearances` flag may not be honored by all PDF viewers. Some form documents may not need to generate appearances. This may be the case -for text Widget Annotations that initially have no value. This is not true for +for text Form Annotations that initially have no value. This is not true for push button widget annotations. Please test ### Document JavaScript @@ -280,4 +296,9 @@ Many PDF Viewers, aside from Adobe Acrobat Reader, do not implement document JavaScript. Even Adobe Readers may not implement document JavaScript where it is not permitted by a device's app store terms of service (e.g. iOS devices). +### Radio and Checkboxes + +Support for radio and checkboxes requires a more advanced attention to their +rendered appearances and are not supported in this initial forms release. + --- diff --git a/lib/mixins/acroform.js b/lib/mixins/acroform.js index b53cc53..c0965b8 100644 --- a/lib/mixins/acroform.js +++ b/lib/mixins/acroform.js @@ -10,6 +10,7 @@ const FIELD_FLAGS = { combo: 0x20000, edit: 0x40000, sort: 0x80000, + multiSelect: 0x200000, noSpell: 0x400000 }; const FIELD_JUSTIFY = { @@ -17,6 +18,7 @@ const FIELD_JUSTIFY = { center: 1, right: 2 }; +const VALUE_MAP = { value: 'V', defaultValue: 'DV' }; const FORMAT_SPECIAL = { zip: '0', zipPlus4: '1', @@ -43,10 +45,15 @@ export default { * Must call if adding AcroForms to a document. Must also call font() before * this method to set the default font. */ - initForms() { + initForm() { + if (!this._font) { + throw new Error('Must set a font before calling initForm method'); + } this._acroform = { - fonts: {} + fonts: {}, + defaultFont: this._font.name }; + this._acroform.fonts[this._font.id] = this._font.ref(); let data = { Fields: [], @@ -67,8 +74,11 @@ export default { */ endAcroForm() { if (this._root.data.AcroForm) { - if (!Object.keys(this._acroform.fonts).length) { - throw new Error('No fonts specified for AcroForm Fields'); + if ( + !Object.keys(this._acroform.fonts).length && + !this._acroform.defaultFont + ) { + throw new Error('No fonts specified for PDF form'); } let fontDict = this._root.data.AcroForm.data.DR.Font; Object.keys(this._acroform.fonts).forEach(name => { @@ -93,29 +103,32 @@ export default { }, /** - * Creates and adds a field to the document. + * Creates and adds a form field to the document. Form fields are intermediate + * nodes in a PDF form that are used to specify form name heirarchy and form + * value defaults. * @param {string} name - field name (T attribute in field dictionary) * @param {object} options - other attributes to include in field dictionary */ - field(name, options = {}) { - let fieldDict = this._fieldDict(name, options); + formField(name, options = {}) { + let fieldDict = this._fieldDict(name, null, options); let fieldRef = this.ref(fieldDict); this._addToParent(fieldRef); return fieldRef; }, /** - * Creates and adds a Widget Annotation to the document. Widget annotations - * are form fields. - * @param {string} name - form field name (T attribute of widget annotation dictionary) + * Creates and adds a Form Annotation to the document. Form annotations are + * called Widget annotations internally within a PDF file. + * @param {string} name - form field name (T attribute of widget annotation + * dictionary) * @param {number} x * @param {number} y * @param {number} w * @param {number} h * @param {object} options */ - widgetAnnotation(name, x, y, w, h, options = {}) { - let fieldDict = this._fieldDict(name, options); + formAnnotation(name, type, x, y, w, h, options = {}) { + let fieldDict = this._fieldDict(name, type, options); fieldDict.Subtype = 'Widget'; if (fieldDict.F === undefined) { fieldDict.F = 4; // print the annotation @@ -129,33 +142,27 @@ export default { }, formText(name, x, y, w, h, options = {}) { - options.FT = 'Tx'; - return this.widgetAnnotation(name, x, y, w, h, options); - }, - - formButton(name, x, y, w, h, options = {}) { - options.FT = 'Btn'; - return this.widgetAnnotation(name, x, y, w, h, options); + return this.formAnnotation(name, 'text', x, y, w, h, options); }, formPushButton(name, x, y, w, h, options = {}) { - options.pushButton = true; - return this.formButton(name, x, y, w, h, options); + return this.formAnnotation(name, 'pushButton', x, y, w, h, options); + }, + + formCombo(name, x, y, w, h, options = {}) { + return this.formAnnotation(name, 'combo', x, y, w, h, options); + }, + + formList(name, x, y, w, h, options = {}) { + return this.formAnnotation(name, 'list', x, y, w, h, options); }, formRadioButton(name, x, y, w, h, options = {}) { - options.radioButton = true; - return this.formButton(name, x, y, w, h, options); + return this.formAnnotation(name, 'radioButton', x, y, w, h, options); }, - // formNoToggleToOffButton(name, x, y, w, h, options = {}) { - // options.toggleToOffButton = true; - // return this.formButton(name, x, y, w, h, options); - // }, - - formChoice(name, x, y, w, h, options = {}) { - options.FT = 'Ch'; - return this.widgetAnnotation(name, x, y, w, h, options); + formCheckbox(name, x, y, w, h, options = {}) { + return this.formAnnotation(name, 'checkbox', x, y, w, h, options); }, _addToParent(fieldRef) { @@ -171,22 +178,49 @@ export default { return this; }, - _fieldDict(name, options = {}) { + _fieldDict(name, type, options = {}) { if (!this._acroform) { throw new Error( - 'Call document.initForms() method before adding AcroForm Fields to document' + 'Call document.initForms() method before adding form elements to document' ); } let opts = Object.assign({}, options); + if (type !== null) { + opts = this._resolveType(type, options); + } opts = this._resolveFlags(opts); opts = this._resolveJustify(opts); opts = this._resolveFont(opts); opts = this._resolveStrings(opts); opts = this._resolveColors(opts); opts = this._resolveFormat(opts); - opts = Object.assign(opts, { - T: new String(name) - }); + opts.T = new String(name); + if (opts.parent) { + opts.Parent = opts.parent; + delete opts.parent; + } + return opts; + }, + + _resolveType(type, opts) { + if (type === 'text') { + opts.FT = 'Tx'; + } else if (type === 'pushButton') { + opts.FT = 'Btn'; + opts.pushButton = true; + } else if (type === 'radioButton') { + opts.FT = 'Btn'; + opts.radioButton = true; + } else if (type === 'checkbox') { + opts.FT = 'Btn'; + } else if (type === 'combo') { + opts.FT = 'Ch'; + opts.combo = true; + } else if (type === 'list') { + opts.FT = 'Ch'; + } else { + throw new Error(`Invalid form annotation type '${type}'`); + } return opts; }, @@ -300,45 +334,47 @@ export default { if (this._acroform.defaultFont !== this._font.name) { options.DR = { Font: {} }; options.DR.Font[this._font.id] = this._font.ref(); + options.DA = new String(`/${this._font.id} 0 Tf 0 g`); } return options; }, _resolveStrings(options) { - let choices = []; + let select = []; function appendChoices(a) { if (Array.isArray(a)) { for (let idx = 0; idx < a.length; idx++) { if (typeof a[idx] === 'string') { - choices.push(new String(a[idx])); + select.push(new String(a[idx])); } else { - choices.push(a[idx]); + select.push(a[idx]); } } } } - appendChoices(options.Opt); - appendChoices(options.choices); - if (choices.length) { - options.Opt = choices; + if (options.select) { + appendChoices(options.select); + delete options.select; + } + if (select.length) { + options.Opt = select; } + if (options.value || options.defaultValue) { + let x = 0; + } + Object.keys(VALUE_MAP).forEach(key => { + if (options[key] !== undefined) { + options[VALUE_MAP[key]] = options[key]; + delete options[key]; + } + }); ['V', 'DV'].forEach(key => { if (typeof options[key] === 'string') { options[key] = new String(options[key]); } }); - if (typeof options.value === 'string') { - options.V = new String(options.value); - } else if (typeof options.value === 'number') { - options.V = options.value; - } - if (typeof options.defaultValue === 'string') { - options.DV = new String(options.defaultValue); - } else if (typeof options.defaultValue === 'number') { - options.DV = options.defaultValue; - } if (options.MK && options.MK.CA) { options.MK.CA = new String(options.MK.CA);