Merge pull request #1002 from jpravetz/master

Add AcroForms output support
This commit is contained in:
Luiz Américo 2019-12-02 21:58:52 -03:00 committed by GitHub
commit 05bc0f3e09
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 1272 additions and 14 deletions

Binary file not shown.

85
demo/test-acroform.js Executable file
View File

@ -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();

313
docs/forms.md Normal file
View File

@ -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.
---

BIN
docs/images/acroforms.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

185
jest.config.js Normal file
View File

@ -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: [
// "<rootDir>"
// ],
// 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,
};

View File

@ -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;

389
lib/mixins/acroform.js Normal file
View File

@ -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;
}
};

View File

@ -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/",
"<rootDir>/demo/"
],
"testURL": "http://localhost/",
"setupFilesAfterEnv": [
"<rootDir>/tests/unit/setupTests.js"
]
}
]
}

277
tests/unit/acroform.spec.js Normal file
View File

@ -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]);
}
}
}
});
});

View File

@ -1,3 +1,4 @@
import matcher from './toContainChunk';
expect.extend(matcher);

View File

@ -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]);
}
}
}