diff --git a/demo/out.pdf b/demo/out.pdf index 0a3e4a1..a46de70 100644 Binary files a/demo/out.pdf and b/demo/out.pdf differ diff --git a/demo/test-acroform.js b/demo/test-acroform.js new file mode 100755 index 0000000..f33b96b --- /dev/null +++ b/demo/test-acroform.js @@ -0,0 +1,85 @@ +var PDFDocument = require('../'); +var fs = require('fs'); + +// Create a new PDFDocument +var doc = new PDFDocument(); + +doc.pipe(fs.createWriteStream('out-acroform.pdf')); + +// Set some meta data +doc.info['Title'] = 'Test Form Document'; +doc.info['Author'] = 'test-acroform.js'; + +doc.font('Helvetica'); // establishes the default font for forms +doc.initForm(); + +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, 20, { + parent: child1Field, + value: '1999-12-31', + format: { + type: 'date', + param: 'yyyy-mm-dd' + }, + align: 'center' +}); + +y += 30; +opts = { + parent: child1Field, + value: 32.98, + format: { + type: 'number', + nDec: 2, + currency: '$', + currencyPrepend: true + }, + align: 'right' +}; +doc.formText('dollar', 10, y, 200, 20, opts); + +y += 30; +doc.formText('leaf2', 10, y, 200, 40, { + parent: child1Field, + multiline: true, + align: 'right' +}); +y += 50; +doc.formText('leaf3', 10, y, 200, 80, { + parent: child2Field, + multiline: true +}); + +y += 90; +var opts = { + backgroundColor: 'yellow', + label: 'Test Button' +}; +doc.formPushButton('btn1', 10, y, 100, 30, opts); + +y += 40; +opts = { + borderColor: 'black', + select: ['Select Option', 'github', 'bitbucket', 'gitlab'], + value: 'Select Option', + defaultValue: 'Select Option', + align: 'center', + edit: true +}; +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 new file mode 100644 index 0000000..4580f04 --- /dev/null +++ b/docs/forms.md @@ -0,0 +1,313 @@ +# Forms in PDFKit + +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. + +- `initForm()` - Must be called before adding a form annotation to the document. + +```javascript +doc.font('Helvetica'); // establishes the default form field font +doc.initForm(); +``` + +## Form Annotation Methods + +Form annotations are added using the following document methods. + +- `formText( name, x, y, width, height, options)` +- `formPushButton( name, x, y, width, height, name, options)` +- `formCombo( name, x, y, width, height, options)` +- `formList( name, x, y, width, height, options)` + +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 + +#### Common Options + +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. +- `readOnly` [_boolean_] - The user may not change the value of the field, and + the field will not respond to mouse clicks. This is useful for fields that have + computed values. +- `value` [_number|string_] - The field's value. +- `defaultValue` [_number|string_] - The default value to which the field + reverts if a reset-form action is executed. + +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 + +#### Text Field Options + +- `align` [_string_] - Sets the alignment to `left`, + `center` or `right`. +- `multiline` [_boolean_] - Allows the field to have multiple lines of text. +- `password` [_boolean_] - The text will be masked (_e.g._ with asterisks). +- `noSpell` [_boolean_] - If set, text entered in the field is not spell-checked +- `format` [_object_] - See the section on **Text Field Formatting** below. + +```js +doc.formText('leaf2', 10, 60, 200, 40, { + multiline: true, + align: 'right', + format: { + type: 'date', + params: 'm/d' + } +}); +``` + +#### Combo and List Field Options + +- `sort` [_boolean_] - The field options will be sorted alphabetically. +- `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 = { + select: ['', 'github', 'bitbucket', 'gitlab'], + value: '', + defaultValue: '', + align: 'left' +}; +doc.formCombo('ch1', 10, y, 100, 20, opts); +``` + +#### Button Field Options + +- `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 = { + backgroundColor: 'yellow', + label: 'Test Button' +}; +doc.formPushButton('btn1', 10, 200, 100, 30, opts); +``` + +### Text Field Formatting + +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 _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) +of the Acrobat SDK documentation for more information. + +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`. + +When `type` is `date`, `time`, `percent` or `number` the format dictionary must +contain additional parameters as described below. + +#### Date format + +- `format.param` (_string_) - specifies the value and display format and can include: + - `d` - single digit day of month + - `dd` - double digit day of month + - `m` - month digit + - `mm` - month double digit + - `mmm` - abbreviated month name + - `mmmm` - full month name + - `yy` - two digit year + - `yyyy` - four digit year + - `hh` - hour for 12 hour clock + - `HH` - hour for 24 hour clock + - `MM` - two digit minute + - `tt` - am or pm + +```js +// Date text field formatting +doc.formText('field.date', 10, 60, 200, 40, { + align: 'center', + format: { + type: 'date', + param: 'mmmm d, yyyy' + } +}); +``` + +#### Time format + +- `format.param` - value must be a number between 0 and 3, representing + the formats "14:30", "2:30 PM", "14:30:15" and "2:30:15 PM". + +```js +// Time text field formatting +doc.formText('field.time', 10, 60, 200, 40, { + align: 'center', + format: { + type: 'time', + param: 2 + } +}); +``` + +#### Number and percent format + +- `format.nDec` [_number_] - the number of places after the decimal point +- `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 +doc.formText('leaf2', 10, 60, 200, 40, { + multiline: true, + align: 'right', + format: { + type: 'number', + nDec: 2, + sepComma: true, + negStyle: 'ParensRed', + currency: '$', + currencyPrepend: true + } +}); +``` + +## Field Names + +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`. + +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. + +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. + +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.initForm(); + +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, + multiline: true +}); +// Add text form annotation 'rootField.child1Field.leaf2' +doc.formText('leaf2', 10, 60, 200, 40, { + parent: child1Field, + multiline: true +}); +// 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' +}; +doc.formPushButton('btn1', 10, 200, 100, 30, opts); +``` + +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 +viewers that you wish to support. + +### Form Field Appearances + +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 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. + +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 Form Annotations that initially have no value. This is not true for +push button widget annotations. Please test + +### Document JavaScript + +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/docs/images/acroforms.png b/docs/images/acroforms.png new file mode 100644 index 0000000..ff929de Binary files /dev/null and b/docs/images/acroforms.png differ diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..eee1ac2 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,185 @@ +// For a detailed explanation regarding each configuration property, visit: +// https://jestjs.io/docs/en/configuration.html + +module.exports = { + // All imported modules in your tests should be mocked automatically + // automock: false, + + // Stop running tests after `n` failures + // bail: 0, + + // Respect "browser" field in package.json when resolving modules + // browser: false, + + // The directory where Jest should store its cached dependency information + // cacheDirectory: "/private/var/folders/h2/_ymt4p095vs7zg32zp2lbtqr0000gn/T/jest_dx", + + // Automatically clear mock calls and instances between every test + // clearMocks: false, + + // Indicates whether the coverage information should be collected while executing the test + // collectCoverage: false, + + // An array of glob patterns indicating a set of files for which coverage information should be collected + // collectCoverageFrom: null, + + // The directory where Jest should output its coverage files + // coverageDirectory: null, + + // An array of regexp pattern strings used to skip coverage collection + // coveragePathIgnorePatterns: [ + // "/node_modules/" + // ], + + // A list of reporter names that Jest uses when writing coverage reports + // coverageReporters: [ + // "json", + // "text", + // "lcov", + // "clover" + // ], + + // An object that configures minimum threshold enforcement for coverage results + // coverageThreshold: null, + + // A path to a custom dependency extractor + // dependencyExtractor: null, + + // Make calling deprecated APIs throw helpful error messages + // errorOnDeprecated: false, + + // Force coverage collection from ignored files using an array of glob patterns + // forceCoverageMatch: [], + + // A path to a module which exports an async function that is triggered once before all test suites + // globalSetup: null, + + // A path to a module which exports an async function that is triggered once after all test suites + // globalTeardown: null, + + // A set of global variables that need to be available in all test environments + // globals: {}, + + // An array of directory names to be searched recursively up from the requiring module's location + // moduleDirectories: [ + // "node_modules" + // ], + + // An array of file extensions your modules use + // moduleFileExtensions: [ + // "js", + // "json", + // "jsx", + // "ts", + // "tsx", + // "node" + // ], + + // A map from regular expressions to module names that allow to stub out resources with a single module + // moduleNameMapper: {}, + + // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader + // modulePathIgnorePatterns: [], + + // Activates notifications for test results + // notify: false, + + // An enum that specifies notification mode. Requires { notify: true } + // notifyMode: "failure-change", + + // A preset that is used as a base for Jest's configuration + // preset: null, + + // Run tests from one or more projects + // projects: null, + + // Use this configuration option to add custom reporters to Jest + // reporters: undefined, + + // Automatically reset mock state between every test + // resetMocks: false, + + // Reset the module registry before running each individual test + // resetModules: false, + + // A path to a custom resolver + // resolver: null, + + // Automatically restore mock state between every test + // restoreMocks: false, + + // The root directory that Jest should scan for tests and modules within + // rootDir: null, + + // A list of paths to directories that Jest should use to search for files in + // roots: [ + // "" + // ], + + // Allows you to use a custom runner instead of Jest's default test runner + // runner: "jest-runner", + + // The paths to modules that run some code to configure or set up the testing environment before each test + // setupFiles: [], + + // A list of paths to modules that run some code to configure or set up the testing framework before each test + // setupFilesAfterEnv: [], + + // A list of paths to snapshot serializer modules Jest should use for snapshot testing + // snapshotSerializers: [], + + // The test environment that will be used for testing + testEnvironment: "node", + + // Options that will be passed to the testEnvironment + // testEnvironmentOptions: {}, + + // Adds a location field to test results + // testLocationInResults: false, + + // The glob patterns Jest uses to detect test files + // testMatch: [ + // "**/__tests__/**/*.[jt]s?(x)", + // "**/?(*.)+(spec|test).[tj]s?(x)" + // ], + + // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped + // testPathIgnorePatterns: [ + // "/node_modules/" + // ], + + // The regexp pattern or array of patterns that Jest uses to detect test files + // testRegex: [], + + // This option allows the use of a custom results processor + // testResultsProcessor: null, + + // This option allows use of a custom test runner + // testRunner: "jasmine2", + + // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href + // testURL: "http://localhost", + + // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" + // timers: "real", + + // A map from regular expressions to paths to transformers + // transform: null, + + // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation + // transformIgnorePatterns: [ + // "/node_modules/" + // ], + + // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them + // unmockedModulePathPatterns: undefined, + + // Indicates whether each individual test should be reported during the run + // verbose: null, + + // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode + // watchPathIgnorePatterns: [], + + // Whether to use watchman for file crawling + // watchman: true, +}; diff --git a/lib/document.js b/lib/document.js index 7143bc2..24eddc2 100644 --- a/lib/document.js +++ b/lib/document.js @@ -17,6 +17,7 @@ import TextMixin from './mixins/text'; import ImagesMixin from './mixins/images'; import AnnotationsMixin from './mixins/annotations'; import OutlineMixin from './mixins/outline'; +import AcroFormMixin from './mixins/acroform'; class PDFDocument extends stream.Readable { constructor(options = {}) { @@ -186,6 +187,17 @@ class PDFDocument extends stream.Readable { this._root.data.Names.data.Dests.add(name, args); } + addNamedJavaScript(name, js) { + if (!this._root.data.Names.data.JavaScript) { + this._root.data.Names.data.JavaScript = new PDFNameTree(); + } + let data = { + JS: new String(js), + S: 'JavaScript' + }; + this._root.data.Names.data.JavaScript.add(name, data); + } + ref(data) { const ref = new PDFReference(this, this._offsets.length + 1, data); this._offsets.push(null); // placeholder for this object's offset once it is finalized @@ -259,6 +271,7 @@ Please pipe the document into a Node stream.\ this._root.end(); this._root.data.Pages.end(); this._root.data.Names.end(); + this.endAcroForm(); if (this._security) { this._security.end(); @@ -321,5 +334,6 @@ mixin(TextMixin); mixin(ImagesMixin); mixin(AnnotationsMixin); mixin(OutlineMixin); +mixin(AcroFormMixin); export default PDFDocument; diff --git a/lib/mixins/acroform.js b/lib/mixins/acroform.js new file mode 100644 index 0000000..755c9d1 --- /dev/null +++ b/lib/mixins/acroform.js @@ -0,0 +1,389 @@ +const FIELD_FLAGS = { + readOnly: 1, + required: 2, + noExport: 4, + multiline: 0x1000, + password: 0x2000, + toggleToOffButton: 0x4000, + radioButton: 0x8000, + pushButton: 0x10000, + combo: 0x20000, + edit: 0x40000, + sort: 0x80000, + multiSelect: 0x200000, + noSpell: 0x400000 +}; +const FIELD_JUSTIFY = { + left: 0, + center: 1, + right: 2 +}; +const VALUE_MAP = { value: 'V', defaultValue: 'DV' }; +const FORMAT_SPECIAL = { + zip: '0', + zipPlus4: '1', + zip4: '1', + phone: '2', + ssn: '3' +}; +const FORMAT_DEFAULT = { + number: { + nDec: 0, + sepComma: false, + negStyle: 'MinusBlack', + currency: '', + currencyPrepend: true + }, + percent: { + nDec: 0, + sepComma: false + } +}; + +export default { + /** + * Must call if adding AcroForms to a document. Must also call font() before + * this method to set the default font. + */ + initForm() { + if (!this._font) { + throw new Error('Must set a font before calling initForm method'); + } + this._acroform = { + fonts: {}, + defaultFont: this._font.name + }; + this._acroform.fonts[this._font.id] = this._font.ref(); + + let data = { + Fields: [], + NeedAppearances: true, + DA: new String(`/${this._font.id} 0 Tf 0 g`), + DR: { + Font: {} + } + }; + data.DR.Font[this._font.id] = this._font.ref(); + const AcroForm = this.ref(data); + this._root.data.AcroForm = AcroForm; + return this; + }, + + /** + * Called automatically by document.js + */ + endAcroForm() { + if (this._root.data.AcroForm) { + 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 => { + fontDict[name] = this._acroform.fonts[name]; + }); + this._root.data.AcroForm.data.Fields.forEach(fieldRef => { + this._endChild(fieldRef); + }); + this._root.data.AcroForm.end(); + } + return this; + }, + + _endChild(ref) { + if (Array.isArray(ref.data.Kids)) { + ref.data.Kids.forEach(childRef => { + this._endChild(childRef); + }); + ref.end(); + } + return this; + }, + + /** + * 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 + */ + formField(name, options = {}) { + let fieldDict = this._fieldDict(name, null, options); + let fieldRef = this.ref(fieldDict); + this._addToParent(fieldRef); + return fieldRef; + }, + + /** + * 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 + */ + 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 + } + + // Add Field annot to page, and get it's ref + this.annotate(x, y, w, h, fieldDict); + let annotRef = this.page.annotations[this.page.annotations.length - 1]; + + return this._addToParent(annotRef); + }, + + formText(name, x, y, w, h, options = {}) { + return this.formAnnotation(name, 'text', x, y, w, h, options); + }, + + formPushButton(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 = {}) { + return this.formAnnotation(name, 'radioButton', x, y, w, h, options); + }, + + formCheckbox(name, x, y, w, h, options = {}) { + return this.formAnnotation(name, 'checkbox', x, y, w, h, options); + }, + + _addToParent(fieldRef) { + let parent = fieldRef.data.Parent; + if (parent) { + if (!parent.data.Kids) { + parent.data.Kids = []; + } + parent.data.Kids.push(fieldRef); + } else { + this._root.data.AcroForm.data.Fields.push(fieldRef); + } + return this; + }, + + _fieldDict(name, type, options = {}) { + if (!this._acroform) { + throw new Error( + '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.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; + }, + + _resolveFormat(opts) { + const f = opts.format; + if (f && f.type) { + let fnKeystroke; + let fnFormat; + let params = ''; + if (FORMAT_SPECIAL[f.type] !== undefined) { + fnKeystroke = `AFSpecial_Keystroke`; + fnFormat = `AFSpecial_Format`; + params = FORMAT_SPECIAL[f.type]; + } else { + let format = f.type.charAt(0).toUpperCase() + f.type.slice(1); + fnKeystroke = `AF${format}_Keystroke`; + fnFormat = `AF${format}_Format`; + + if (f.type === 'date') { + fnKeystroke += 'Ex'; + params = String(f.param); + } else if (f.type === 'time') { + params = String(f.param); + } else if (f.type === 'number') { + let p = Object.assign({}, FORMAT_DEFAULT.number, f); + params = String( + [ + String(p.nDec), + p.sepComma ? '0' : '1', + '"' + p.negStyle + '"', + 'null', + '"' + p.currency + '"', + String(p.currencyPrepend) + ].join(',') + ); + } else if (f.type === 'percent') { + let p = Object.assign({}, FORMAT_DEFAULT.percent, f); + params = String([String(p.nDec), p.sepComma ? '0' : '1'].join(',')); + } + } + opts.AA = opts.AA ? opts.AA : {}; + opts.AA.K = { + S: 'JavaScript', + JS: new String(`${fnKeystroke}(${params});`) + }; + opts.AA.F = { + S: 'JavaScript', + JS: new String(`${fnFormat}(${params});`) + }; + } + delete opts.format; + return opts; + }, + + _resolveColors(opts) { + let color = this._normalizeColor(opts.backgroundColor); + if (color) { + if (!opts.MK) { + opts.MK = {}; + } + opts.MK.BG = color; + } + color = this._normalizeColor(opts.borderColor); + if (color) { + if (!opts.MK) { + opts.MK = {}; + } + opts.MK.BC = color; + } + delete opts.backgroundColor; + delete opts.borderColor; + return opts; + }, + + _resolveFlags(options) { + let result = 0; + Object.keys(options).forEach(key => { + if (FIELD_FLAGS[key]) { + result |= FIELD_FLAGS[key]; + delete options[key]; + } + }); + if (result !== 0) { + options.Ff = options.Ff ? options.Ff : 0; + options.Ff |= result; + } + return options; + }, + + _resolveJustify(options) { + let result = 0; + if (options.align !== undefined) { + if (typeof FIELD_JUSTIFY[options.align] === 'number') { + result = FIELD_JUSTIFY[options.align]; + } + delete options.align; + } + if (result !== 0) { + options.Q = result; // default + } + return options; + }, + + _resolveFont(options) { + // add current font to document-level AcroForm dict if necessary + if (this._acroform.fonts[this._font.id] === null) { + this._acroform.fonts[this._font.id] = this._font.ref(); + } + + // add current font to field's resource dict (RD) if not the default acroform font + 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 select = []; + function appendChoices(a) { + if (Array.isArray(a)) { + for (let idx = 0; idx < a.length; idx++) { + if (typeof a[idx] === 'string') { + select.push(new String(a[idx])); + } else { + select.push(a[idx]); + } + } + } + } + appendChoices(options.Opt); + 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 (options.MK && options.MK.CA) { + options.MK.CA = new String(options.MK.CA); + } + if (options.label) { + options.MK = options.MK ? options.MK : {}; + options.MK.CA = new String(options.label); + delete options.label; + } + return options; + } +}; diff --git a/package.json b/package.json index 678d651..5518b9c 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "website": "node docs/generate_website.js", "docs": "npm run pdf-guide && npm run website && npm run browser-demo", "prettier": "prettier {lib,tests,demo,docs}/**/*.js", - "test": "jest -i", + "test": "jest", "test:integration": "jest integration/ -i", "test:unit": "jest unit/ -i" }, @@ -71,15 +71,5 @@ }, "engine": [ "node >= v6.0.0" - ], - "jest": { - "testPathIgnorePatterns": [ - "/node_modules/", - "/demo/" - ], - "testURL": "http://localhost/", - "setupFilesAfterEnv": [ - "/tests/unit/setupTests.js" - ] - } + ] } diff --git a/tests/unit/acroform.spec.js b/tests/unit/acroform.spec.js new file mode 100644 index 0000000..1afa578 --- /dev/null +++ b/tests/unit/acroform.spec.js @@ -0,0 +1,277 @@ +import PDFDocument from '../../lib/document'; +import PDFSecurity from '../../lib/security'; +import { logData } from './helpers'; +import PDFFontFactory from '../../lib/font_factory'; + +// manual mock for PDFSecurity to ensure stored id will be the same accross different systems +PDFSecurity.generateFileID = () => { + return new Buffer('mocked-pdf-id'); +}; + +function escapeRegExp(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string +} + +function joinTokens(...args) { + let a = args.map(i => escapeRegExp(i)); + let r = new RegExp('^' + a.join('\\s*') + '$'); + return r; +} + +describe('acroform', () => { + let doc; + + beforeEach(() => { + doc = new PDFDocument({ + info: { CreationDate: new Date(Date.UTC(2018, 1, 1)) } + }); + }); + + test('named JavaScript', () => { + const expected = [ + '2 0 obj', + // '<<\n/Dests <<\n/Names []\n>>\n/JavaScript <<\n/Names [\n(name1) <<\n/JS (my javascript goes here)\n/S /JavaScript\n>>\n]\n>>\n>>', + joinTokens( + '<<', + '/Dests', + '<<', + '/Names', + '[', + ']', + '>>', + '/JavaScript', + '<<', + '/Names', + '[', + '(name1)', + '<<', + '/JS', + '(my javascript goes here)', + '/S', + '/JavaScript', + '>>', + ']', + '>>', + '>>' + ), + 'endobj' + ]; + const docData = logData(doc); + doc.addNamedJavaScript('name1', 'my javascript goes here'); + expect(docData.length).toBe(0); + doc.end(); + expect(docData).toContainChunk(expected); + }); + + test('init no fonts', () => { + doc.addPage(); + const docData = logData(doc); + const font = PDFFontFactory.open(doc, 'tests/fonts/Roboto-Regular.ttf'); + doc.initForm(); + expect(docData.length).toBe(0); + }); + + test('init standard fonts', () => { + const expected = [ + '12 0 obj', + joinTokens( + '<<', + '/FT', + '/Tx', + '/Ff', + '4096', + '/DR', + '<<', + '/Font', + '<<', + '/F3', + '10 0 R', + '>>', + '>>', + '/DA', + '(/F3 0 Tf 0 g)', + '/T', + '(file0)', + '/Subtype', + '/Widget', + '/F', + '4', + '/Type', + '/Annot', + '/Rect', + '[10 292 602 692]', + '>>' + ), + 'endobj' + ]; + + const docData = logData(doc); + doc.registerFont('myfont1', 'tests/fonts/Roboto-Regular.ttf'); + + doc.font('Courier-Bold'); // establishes the default font + doc.initForm(); + + doc + .font('myfont1') + .fontSize(25) + .text('Test Doc', 0, 20, { width: 612, align: 'center' }); + doc + .font('Courier') + .fontSize(16) + .text('Courier subheading', 0, 50, { width: 612, align: 'center' }); + + doc + .font('myfont1') + .formText('file0', 10, 100, 592, 400, { multiline: true }); + + expect(docData.length).toBe(3); + expect(docData).toContainChunk(expected); + }); + + test('push button', () => { + const expected = [ + '10 0 obj', + '<<\n/FT /Btn\n/Ff 65536\n/MK <<\n/CA (Test Button)\n/BG [1 1 0]\n>>\n/T (btn1)\n/Subtype /Widget\n/F 4\n/Type /Annot\n/Rect [20 742 120 772]\n>>', + 'endobj' + ]; + doc.initForm(); + const docData = logData(doc); + let opts = { + backgroundColor: 'yellow', + label: 'Test Button' + }; + doc.formPushButton('btn1', 20, 20, 100, 30, opts); + expect(docData.length).toBe(3); + expect(docData[0]).toBe(expected[0]); + expect(docData[1]).toBe(expected[1]); + expect(docData[2]).toBe(expected[2]); + }); + + describe('text format', () => { + test('number', () => { + const expected = [ + '10 0 obj', + '<<\n/FT /Tx\n/V 32.98\n/AA <<\n/K <<\n/S /JavaScript\n' + + '/JS (AFNumber_Keystroke\\(2,1,"MinusBlack",null,"$",true\\);)\n>>\n' + + '/F <<\n/S /JavaScript\n/JS (AFNumber_Format\\(2,1,"MinusBlack",null,"$",true\\);)\n>>\n>>\n' + + '/T (dollars)\n/Subtype /Widget\n/F 4\n/Type /Annot\n/Rect [20 752 70 772]\n>>', + 'endobj' + ]; + doc.initForm(); + const docData = logData(doc); + let opts = { + value: 32.98, + format: { + type: 'number', + nDec: 2, + currency: '$', + currencyPrepend: true + } + }; + doc.formText('dollars', 20, 20, 50, 20, opts); + expect(docData.length).toBe(3); + expect(docData).toContainChunk(expected); + }); + test('date', () => { + const expected = [ + '10 0 obj', + '<<\n/FT /Tx\n/V (1999-12-31)\n/AA <<\n/K <<\n/S /JavaScript\n' + + '/JS (AFDate_KeystrokeEx\\(yyyy-mm-dd\\);)\n>>\n' + + '/F <<\n/S /JavaScript\n/JS (AFDate_Format\\(yyyy-mm-dd\\);)\n>>\n>>\n' + + '/T (date)\n/Subtype /Widget\n/F 4\n/Type /Annot\n/Rect [20 752 70 772]\n>>', + 'endobj' + ]; + doc.initForm(); + const docData = logData(doc); + let opts = { + value: '1999-12-31', + format: { + type: 'date', + param: 'yyyy-mm-dd' + } + }; + doc.formText('date', 20, 20, 50, 20, opts); + expect(docData.length).toBe(3); + expect(docData).toContainChunk(expected); + }); + }); + + test('flags', () => { + const expected = [ + '10 0 obj', + '<<\n/FT /Tx\n' + + '/Ff 4206599\n/Q 1\n' + + '/T (flags)\n/Subtype /Widget\n/F 4\n/Type /Annot\n/Rect [20 752 70 772]\n>>', + 'endobj' + ]; + doc.initForm(); + const docData = logData(doc); + let opts = { + required: true, + noExport: true, + readOnly: true, + align: 'center', + multiline: true, + password: true, + noSpell: true + }; + doc.formText('flags', 20, 20, 50, 20, opts); + expect(docData.length).toBe(3); + expect(docData).toContainChunk(expected); + }); + + test('field heirarchy', () => { + const expected = [ + '13 0 obj', + '<<\n/Parent 11 0 R\n/FT /Tx\n/T (leaf1)\n/Subtype /Widget\n/F 4\n/Type /Annot\n/Rect [10 742 210 782]\n>>', + 'endobj', + '14 0 obj', + '<<\n/Parent 11 0 R\n/FT /Tx\n/T (leaf2)\n/Subtype /Widget\n/F 4\n/Type /Annot\n/Rect [10 692 210 732]\n>>', + 'endobj', + '15 0 obj', + '<<\n/Parent 12 0 R\n/FT /Tx\n/T (leaf3)\n/Subtype /Widget\n/F 4\n/Type /Annot\n/Rect [10 642 210 682]\n>>', + 'endobj' + ]; + const expected2 = [ + '11 0 obj', + '<<\n/Parent 10 0 R\n/T (child1Field)\n/Kids [13 0 R 14 0 R]\n>>', + 'endobj', + '12 0 obj', + '<<\n/Parent 10 0 R\n/T (child2Field)\n/Kids [15 0 R]\n>>', + 'endobj', + '10 0 obj', + '<<\n/T (rootField)\n/Kids [11 0 R 12 0 R]\n>>', + 'endobj', + '9 0 obj', + '<<\n/Fields [10 0 R]\n/NeedAppearances true\n/DA (/F1 0 Tf 0 g)\n/DR <<\n/Font <<\n/F1 8 0 R\n>>\n>>\n>>', + 'endobj' + ]; + + const docData = logData(doc); + + doc.font('Helvetica'); // establishes the default font + doc.initForm(); + + let rootField = doc.formField('rootField'); + let child1Field = doc.formField('child1Field', { Parent: rootField }); + let child2Field = doc.formField('child2Field', { Parent: rootField }); + doc.formText('leaf1', 10, 10, 200, 40, { Parent: child1Field }); + doc.formText('leaf2', 10, 60, 200, 40, { Parent: child1Field }); + doc.formText('leaf3', 10, 110, 200, 40, { Parent: child2Field }); + + expect(docData.length).toBe(expected.length); + for (let idx = 0; idx < expected.length; ++idx) { + expect(docData[idx]).toBe(expected[idx]); + } + + doc.end(); + + for (let idx = 0; idx < docData.length; ++idx) { + if (docData[idx] === expected2[0]) { + for (let jdx = 0; jdx < expected2.length; ++jdx) { + expect(docData[idx + jdx]).toBe(expected2[jdx]); + } + } + } + }); +}); diff --git a/tests/unit/setupTests.js b/tests/unit/setupTests.js index 5df4926..7e82a20 100644 --- a/tests/unit/setupTests.js +++ b/tests/unit/setupTests.js @@ -1,3 +1,4 @@ import matcher from './toContainChunk'; + expect.extend(matcher); diff --git a/tests/unit/toContainChunk/index.js b/tests/unit/toContainChunk/index.js index 8f7d6d1..2fe8707 100644 --- a/tests/unit/toContainChunk/index.js +++ b/tests/unit/toContainChunk/index.js @@ -30,12 +30,16 @@ const failMessage = (utils, data, chunk, headIndex) => () => { }; export default { - toContainChunk(data, chunk) { + toContainChunk (data, chunk) { const headIndex = data.indexOf(chunk[0]); let pass = headIndex !== -1; if (pass) { for (let i = 1; i < chunk.length; ++i) { - pass = pass && this.equals(data[headIndex + i], chunk[i]); + if (chunk[i] instanceof RegExp) { + pass = pass && chunk[i].test(data[headIndex + i]); + } else { + pass = pass && this.equals(data[headIndex + i], chunk[i]); + } } }