diff --git a/CHANGELOG.md b/CHANGELOG.md index ad6b9729..a4e665b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### Unreleased +- Add `alternateName` and `structParent` options to form annotations so tagged form widgets can have an accessible name and belong to a `Form` structure element. Retain the annotation's page when its structure is finalized after a page change. Honor an explicit `fontSize` when the field uses the default form font. Fixes #1803 - Fix `doc.table()` parsing a font passed as binary data (e.g. registered with `registerFont(name, uint8Array)`) again for every cell, when it restores the font after measuring and drawing the cell, which made tables with such fonts slower. A font loaded from the same `Uint8Array` is now reused without parsing it again - Add `lineBreak: 'whitespace'` to `doc.text()`, which breaks a line only at whitespace, a soft hyphen or a zero-width space. The Unicode line breaking rules the wrapper follows also allow a break after a hyphen or a slash, so `design-system` or `https://github.com/foliojs/pdfkit` could be cut at a line end, and a text extractor that joins the lines, such as a résumé parser, reads two words - Add an `objectStreams` option, which packs the objects that are not streams into compressed object streams and writes a cross-reference stream instead of the xref table, making documents with many such objects, e.g. tagged ones, much smaller. It requires a `pdfVersion` of `1.5` or higher, and cannot be used with encryption or PDF/A-1. Fixes #1810 diff --git a/docs/forms.md b/docs/forms.md index ab024eb9..e48b965e 100644 --- a/docs/forms.md +++ b/docs/forms.md @@ -43,6 +43,9 @@ information on `name` in the **Field Names** section below. The following `options` are accepted by all form annotation methods: - `parent` [_PDFReference_] - Parent field returned by `formField`. +- `structParent` [_PDFStructureElement_] - Structure element returned by `doc.struct()`. + Use a separate `Form` element for each widget in a tagged PDF. This is distinct + from `parent`, which controls the form field hierarchy. - `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 @@ -51,10 +54,14 @@ The following `options` are accepted by all form annotation methods: - `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. +- `alternateName` [_string_] - Alternate field name (`TU`), used as the field's + accessible name or tooltip. Use a meaningful description, not just the + internal field name. - `backgroundColor` - Field background color. - `borderColor` - Field border color. - `fontSize` [_number_] - Sets the font size used in the field appearance string. - The default, `0`, means auto sizing. + The default, `0`, means auto sizing. An explicit size applies even when the + field uses the default form font. - `hidden` [_boolean_] - Builds the field starting hidden, instead of the usual default of visible and printable, for a field an interactive action (see `onClick` below) will reveal later. diff --git a/lib/mixins/acroform.js b/lib/mixins/acroform.js index 5fb55570..64949283 100644 --- a/lib/mixins/acroform.js +++ b/lib/mixins/acroform.js @@ -18,7 +18,7 @@ const FIELD_JUSTIFY = { center: 1, right: 2, }; -const VALUE_MAP = { value: 'V', defaultValue: 'DV' }; +const VALUE_MAP = { value: 'V', defaultValue: 'DV', alternateName: 'TU' }; const FORMAT_SPECIAL = { zip: '0', zipPlus4: '1', @@ -320,7 +320,10 @@ export default { } // Add Field annot to page, and get it's ref - this.annotate(x, y, w, h, fieldDict); + this.annotate(x, y, w, h, { + ...fieldDict, + structParent: options.structParent, + }); let annotRef = this.page.annotations[this.page.annotations.length - 1]; return this._addToParent(annotRef); @@ -411,14 +414,17 @@ export default { _acroform.fonts[_font.id] = formFontRef(this, _font); } - // add current font to field's resource dict (RD) if not the default acroform font - if (_acroform.defaultFont !== _font.name) { + // add current font to field's resource dict (DR) if not the default acroform font + const isDefaultFont = _acroform.defaultFont === _font.name; + if (!isDefaultFont) { pdfObject.DR = { Font: {} }; + pdfObject.DR.Font[_font.id] = formFontRef(this, _font); + } + if (!isDefaultFont || options.fontSize != null) { // Get the fontSize option. If not set use auto sizing const fontSize = options.fontSize || 0; - pdfObject.DR.Font[_font.id] = formFontRef(this, _font); pdfObject.DA = new String(`/${_font.id} ${fontSize} Tf 0 g`); } }, diff --git a/lib/mixins/annotations.js b/lib/mixins/annotations.js index ee82ab2b..d1c145ee 100644 --- a/lib/mixins/annotations.js +++ b/lib/mixins/annotations.js @@ -28,7 +28,7 @@ export default { this.page.annotations.push(ref); if (typeof structParent?.add === 'function') { - const annotRef = new PDFAnnotationReference(ref); + const annotRef = new PDFAnnotationReference(ref, this.page.dictionary); structParent.add(annotRef); } diff --git a/lib/structure_annotation.js b/lib/structure_annotation.js index fe5ddbfd..2df274eb 100644 --- a/lib/structure_annotation.js +++ b/lib/structure_annotation.js @@ -1,6 +1,7 @@ class PDFAnnotationReference { - constructor(annotationRef) { + constructor(annotationRef, pageRef = annotationRef.document.page.dictionary) { this.annotationRef = annotationRef; + this.pageRef = pageRef; } } diff --git a/lib/structure_element.js b/lib/structure_element.js index 04e16f13..d5b2478c 100644 --- a/lib/structure_element.js +++ b/lib/structure_element.js @@ -262,11 +262,10 @@ class PDFStructureElement { } if (child instanceof PDFAnnotationReference) { - const pageRef = this.document.page.dictionary; const objr = { Type: 'OBJR', Obj: child.annotationRef, - Pg: pageRef, + Pg: child.pageRef, }; this.dictionary.data.K.push(objr); } diff --git a/tests/unit/acroform.spec.js b/tests/unit/acroform.spec.js index c2c60ed1..8fd184f3 100644 --- a/tests/unit/acroform.spec.js +++ b/tests/unit/acroform.spec.js @@ -30,6 +30,142 @@ describe('acroform', () => { }); }); + test.each(['Request number', 'Référence №', ''])( + 'alternateName writes a text string: %s', + (alternateName) => { + doc.initForm(); + const options = Object.freeze({ alternateName }); + const docData = logData(doc); + doc.formText('request', 10, 20, 100, 24, options); + const widget = doc.page.annotations.at(-1); + expect(widget.data.TU).toBeInstanceOf(String); + expect(String(widget.data.TU)).toBe(alternateName); + expect(objectBody(docData, widget.id)).toContain('/TU ('); + expect(objectBody(docData, widget.id)).not.toContain('/alternateName'); + doc.end(); + }, + ); + + test('omitted alternateName does not invent a tooltip', () => { + doc.initForm(); + doc.formText('request', 10, 20, 100, 24); + expect(doc.page.annotations.at(-1).data).not.toHaveProperty('TU'); + doc.end(); + }); + + test.each([0, 9])( + 'explicit fontSize %s applies to the default form font', + (fontSize) => { + doc.font('Helvetica').initForm(); + const docData = logData(doc); + doc.formText('request', 10, 20, 100, 24, Object.freeze({ fontSize })); + const widget = doc.page.annotations.at(-1); + expect(objectBody(docData, widget.id)).toContain( + `/DA (/F1 ${fontSize} Tf 0 g)`, + ); + expect(widget.data).not.toHaveProperty('DR'); + doc.end(); + }, + ); + + test('omitted size inherits the default appearance', () => { + doc.font('Helvetica').initForm(); + doc.formText('request', 10, 20, 100, 24); + expect(doc.page.annotations.at(-1).data).not.toHaveProperty('DA'); + doc.end(); + }); + + test.each([undefined, 0, 9])( + 'non-default font retains resources and size %s', + (fontSize) => { + doc.font('Helvetica').initForm(); + doc.font('Courier'); + doc.formText('request', 10, 20, 100, 24, { fontSize }); + const widget = doc.page.annotations.at(-1); + expect(String(widget.data.DA)).toBe(`/F2 ${fontSize ?? 0} Tf 0 g`); + doc.end(); + expect(widget.data.DR.Font.F2).toBe( + doc._root.data.AcroForm.data.DR.Font.F2, + ); + }, + ); + + test('form widgets are attached to their own structure elements and parent tree', () => { + doc = new PDFDocument({ tagged: true, compress: false }); + doc.initForm(); + const docData = logData(doc); + const page = doc.page.dictionary; + const forms = []; + const widgets = []; + for (const name of ['request', 'department']) { + const form = doc.struct('Form'); + doc.addStructure(form); + doc.formText( + name, + 10, + 20 + forms.length * 30, + 100, + 24, + Object.freeze({ structParent: form }), + ); + forms.push(form); + widgets.push(doc.page.annotations.at(-1)); + } + expect(widgets[0].data.StructParent).not.toBe(widgets[1].data.StructParent); + for (let i = 0; i < widgets.length; i++) { + expect(doc.getStructParentTree().get(widgets[i].data.StructParent)).toBe( + forms[i].dictionary, + ); + expect(doc._root.data.AcroForm.data.Fields).toContain(widgets[i]); + } + doc.end(); + for (let i = 0; i < widgets.length; i++) { + const formBody = objectBody(docData, forms[i].dictionary.id); + expect(formBody).toContain('/S /Form'); + expect(formBody).toContain('/Type /OBJR'); + expect(formBody).toContain(`/Obj ${widgets[i].id} 0 R`); + expect(formBody).toContain(`/Pg ${page.id} 0 R`); + const widgetBody = objectBody(docData, widgets[i].id); + expect(widgetBody).toContain( + `/StructParent ${widgets[i].data.StructParent}`, + ); + expect(widgetBody).not.toContain('/structParent'); + } + }); + + test('delayed form structure finalization retains each widget page and field parent', () => { + doc = new PDFDocument({ tagged: true, compress: false }); + doc.initForm(); + const docData = logData(doc); + const fieldParent = doc.formField('request'); + const entries = []; + for (const name of ['number', 'department']) { + const form = doc.struct('Form'); + doc.addStructure(form); + const page = doc.page.dictionary; + doc.formText(name, 10, 20, 100, 24, { + parent: fieldParent, + structParent: form, + }); + entries.push({ form, page, widget: doc.page.annotations.at(-1) }); + doc.addPage(); + } + doc.end(); + for (const { form, page, widget } of entries) { + expect(objectBody(docData, form.dictionary.id)).toContain( + `/Pg ${page.id} 0 R`, + ); + expect(objectBody(docData, form.dictionary.id)).toContain( + `/Obj ${widget.id} 0 R`, + ); + expect(widget.data.Parent).toBe(fieldParent); + expect(fieldParent.data.Kids).toContain(widget); + expect(doc.getStructParentTree().get(widget.data.StructParent)).toBe( + form.dictionary, + ); + } + }); + test('named JavaScript', () => { const expected = [ '2 0 obj',