From ad3fb71a02508a7a8f28fe2ca0bcd16441bd3fe4 Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Sat, 26 Mar 2022 22:45:50 +0100 Subject: [PATCH] [Annotations] Add support for printing/saving choice list with multiple selections - it aims to fix issue #12189. --- src/core/annotation.js | 156 +++++++++++++++++++++++-- src/core/writer.js | 6 +- src/display/annotation_layer.js | 16 +-- src/scripting_api/event.js | 3 + src/scripting_api/field.js | 6 +- test/test_manifest.json | 5 +- test/unit/annotation_spec.js | 196 ++++++++++++++++++++++++++++++-- 7 files changed, 355 insertions(+), 33 deletions(-) diff --git a/src/core/annotation.js b/src/core/annotation.js index 4d3370855..6d902be97 100644 --- a/src/core/annotation.js +++ b/src/core/annotation.js @@ -50,6 +50,11 @@ import { StringStream } from "./stream.js"; import { writeDict } from "./writer.js"; import { XFAFactory } from "./xfa/factory.js"; +// Represent the percentage of the height of a single-line field over +// the font size. +// Acrobat seems to use this value. +const LINE_FACTOR = 1.35; + class AnnotationFactory { /** * Create an `Annotation` object of the correct type for the given reference @@ -1405,6 +1410,16 @@ class WidgetAnnotation extends Annotation { return null; } + // Value can be an array (with choice list and multiple selections) + if ( + Array.isArray(value) && + Array.isArray(this.data.fieldValue) && + value.length === this.data.fieldValue.length && + value.every((x, i) => x === this.data.fieldValue[i]) + ) { + return null; + } + let appearance = await this._getAppearance( evaluator, task, @@ -1448,7 +1463,8 @@ class WidgetAnnotation extends Annotation { appearance = newTransform.encryptString(appearance); } - dict.set("V", isAscii(value) ? value : stringToUTF16BEString(value)); + const encoder = val => (isAscii(val) ? val : stringToUTF16BEString(val)); + dict.set("V", Array.isArray(value) ? value.map(encoder) : encoder(value)); dict.set("AP", AP); dict.set("M", `D:${getModificationDate()}`); @@ -1629,11 +1645,6 @@ class WidgetAnnotation extends Annotation { const roundWithTwoDigits = x => Math.floor(x * 100) / 100; - // Represent the percentage of the height of a single-line field over - // the font size. - // Acrobat seems to use this value. - const LINE_FACTOR = 1.35; - if (lineCount === -1) { const textWidth = this._getTextWidth(text, font); fontSize = roundWithTwoDigits( @@ -1703,14 +1714,14 @@ class WidgetAnnotation extends Annotation { } _renderText(text, font, fontSize, totalWidth, alignment, hPadding, vPadding) { - // We need to get the width of the text in order to align it correctly - const width = this._getTextWidth(text, font) * fontSize; let shift; if (alignment === 1) { // Center + const width = this._getTextWidth(text, font) * fontSize; shift = (totalWidth - width) / 2; } else if (alignment === 2) { // Right + const width = this._getTextWidth(text, font) * fontSize; shift = totalWidth - width - hPadding; } else { shift = hPadding; @@ -2483,6 +2494,135 @@ class ChoiceWidgetAnnotation extends WidgetAnnotation { type, }; } + + async _getAppearance(evaluator, task, annotationStorage) { + if (this.data.combo) { + return super._getAppearance(evaluator, task, annotationStorage); + } + + if (!annotationStorage) { + return null; + } + const storageEntry = annotationStorage.get(this.data.id); + let exportedValue = storageEntry && storageEntry.value; + if (exportedValue === undefined) { + // The annotation hasn't been rendered so use the appearance + return null; + } + + if (!Array.isArray(exportedValue)) { + exportedValue = [exportedValue]; + } + + const defaultPadding = 2; + const hPadding = defaultPadding; + const totalHeight = this.data.rect[3] - this.data.rect[1]; + const totalWidth = this.data.rect[2] - this.data.rect[0]; + const lineCount = this.data.options.length; + const valueIndices = []; + for (let i = 0; i < lineCount; i++) { + const { exportValue } = this.data.options[i]; + if (exportedValue.includes(exportValue)) { + valueIndices.push(i); + } + } + + if (!this._defaultAppearance) { + // The DA is required and must be a string. + // If there is no font named Helvetica in the resource dictionary, + // the evaluator will fall back to a default font. + // Doing so prevents exceptions and allows saving/printing + // the file as expected. + this.data.defaultAppearanceData = parseDefaultAppearance( + (this._defaultAppearance = "/Helvetica 0 Tf 0 g") + ); + } + + const font = await this._getFontData(evaluator, task); + + let defaultAppearance; + let { fontSize } = this.data.defaultAppearanceData; + if (!fontSize) { + const lineHeight = (totalHeight - defaultPadding) / lineCount; + let lineWidth = -1; + let value; + for (const { displayValue } of this.data.options) { + const width = this._getTextWidth(displayValue); + if (width > lineWidth) { + lineWidth = width; + value = displayValue; + } + } + + [defaultAppearance, fontSize] = this._computeFontSize( + lineHeight, + totalWidth - 2 * hPadding, + value, + font, + -1 + ); + } else { + defaultAppearance = this._defaultAppearance; + } + + const lineHeight = fontSize * LINE_FACTOR; + const vPadding = (lineHeight - fontSize) / 2; + const numberOfVisibleLines = Math.floor(totalHeight / lineHeight); + + let firstIndex; + if (valueIndices.length === 1) { + const valuePosition = valueIndices[0]; + const indexInPage = valuePosition % numberOfVisibleLines; + firstIndex = valuePosition - indexInPage; + } else { + // If nothing is selected (valueIndice.length === 0), we render + // from the first element. + firstIndex = valueIndices.length ? valueIndices[0] : 0; + } + const end = Math.min(firstIndex + numberOfVisibleLines + 1, lineCount); + + const buf = ["/Tx BMC q", `1 1 ${totalWidth} ${totalHeight} re W n`]; + + if (valueIndices.length) { + // This value has been copied/pasted from annotation-choice-widget.pdf. + // It corresponds to rgb(153, 193, 218). + buf.push("0.600006 0.756866 0.854904 rg"); + + // Highlight the lines in filling a blue rectangle at the selected + // positions. + for (const index of valueIndices) { + if (firstIndex <= index && index < end) { + buf.push( + `1 ${ + totalHeight - (index - firstIndex + 1) * lineHeight + } ${totalWidth} ${lineHeight} re f` + ); + } + } + } + buf.push("BT", defaultAppearance, `1 0 0 1 0 ${totalHeight} Tm`); + + for (let i = firstIndex; i < end; i++) { + const { displayValue } = this.data.options[i]; + const hpadding = i === firstIndex ? hPadding : 0; + const vpadding = i === firstIndex ? vPadding : 0; + buf.push( + this._renderText( + displayValue, + font, + fontSize, + totalWidth, + 0, + hpadding, + -lineHeight + vpadding + ) + ); + } + + buf.push("ET Q EMC"); + + return buf.join("\n"); + } } class SignatureWidgetAnnotation extends WidgetAnnotation { diff --git a/src/core/writer.js b/src/core/writer.js index 5d525c961..406d8304d 100644 --- a/src/core/writer.js +++ b/src/core/writer.js @@ -143,7 +143,11 @@ function writeXFADataForAcroform(str, newRefs) { } const node = xml.documentElement.searchNode(parseXFAPath(path), 0); if (node) { - node.childNodes = [new SimpleDOMNode("#text", value)]; + if (Array.isArray(value)) { + node.childNodes = value.map(val => new SimpleDOMNode("value", val)); + } else { + node.childNodes = [new SimpleDOMNode("#text", value)]; + } } else { warn(`Node not found for path: ${path}`); } diff --git a/src/display/annotation_layer.js b/src/display/annotation_layer.js index f7a5d6d27..31edf3580 100644 --- a/src/display/annotation_layer.js +++ b/src/display/annotation_layer.js @@ -1336,16 +1336,8 @@ class ChoiceWidgetAnnotationElement extends WidgetAnnotationElement { const storage = this.annotationStorage; const id = this.data.id; - // For printing/saving we currently only support choice widgets with one - // option selection. Therefore, listboxes (#12189) and comboboxes (#12224) - // are not properly printed/saved yet, so we only store the first item in - // the field value array instead of the entire array. Once support for those - // two field types is implemented, we should use the same pattern as the - // other interactive widgets where the return value of `getValue` - // is used and the full array of field values is stored. - storage.getValue(id, { - value: - this.data.fieldValue.length > 0 ? this.data.fieldValue[0] : undefined, + const storedData = storage.getValue(id, { + value: this.data.fieldValue, }); let { fontSize } = this.data.defaultAppearanceData; @@ -1386,7 +1378,7 @@ class ChoiceWidgetAnnotationElement extends WidgetAnnotationElement { if (this.data.combo) { optionElement.style.fontSize = fontSizeStyle; } - if (this.data.fieldValue.includes(option.exportValue)) { + if (storedData.value.includes(option.exportValue)) { optionElement.setAttribute("selected", true); } selectElement.appendChild(optionElement); @@ -1537,7 +1529,7 @@ class ChoiceWidgetAnnotationElement extends WidgetAnnotationElement { ); } else { selectElement.addEventListener("input", function (event) { - storage.setValue(id, { value: getValue(event) }); + storage.setValue(id, { value: getValue(event, /* isExport */ true) }); }); } diff --git a/src/scripting_api/event.js b/src/scripting_api/event.js index f4fc873ab..71591b6f3 100644 --- a/src/scripting_api/event.js +++ b/src/scripting_api/event.js @@ -49,6 +49,9 @@ class EventDispatcher { mergeChange(event) { let value = event.value; + if (Array.isArray(value)) { + return value; + } if (typeof value !== "string") { value = value.toString(); } diff --git a/src/scripting_api/field.js b/src/scripting_api/field.js index 7fa2b2da9..1351054ad 100644 --- a/src/scripting_api/field.js +++ b/src/scripting_api/field.js @@ -233,7 +233,11 @@ class Field extends PDFObject { if (this._isChoice) { if (this.multipleSelection) { const values = new Set(value); - this._currentValueIndices.length = 0; + if (Array.isArray(this._currentValueIndices)) { + this._currentValueIndices.length = 0; + } else { + this._currentValueIndices = []; + } this._items.forEach(({ displayValue }, i) => { if (values.has(displayValue)) { this._currentValueIndices.push(i); diff --git a/test/test_manifest.json b/test/test_manifest.json index ddd2ab8e9..bb891b552 100644 --- a/test/test_manifest.json +++ b/test/test_manifest.json @@ -5897,10 +5897,7 @@ "value": "Dolor" }, "62R": { - "value": "Sit" - }, - "63R": { - "value": "" + "value": ["Sit", "Adipiscing"] } } }, diff --git a/test/unit/annotation_spec.js b/test/unit/annotation_spec.js index 39d126b52..c7effe3f1 100644 --- a/test/unit/annotation_spec.js +++ b/test/unit/annotation_spec.js @@ -3376,8 +3376,111 @@ describe("annotation", function () { annotationStorage ); expect(appearance).toEqual( - "/Tx BMC q BT /Helv 5 Tf 1 0 0 1 0 0 Tm" + - " 2.00 3.04 Td (a value) Tj ET Q EMC" + [ + "/Tx BMC q", + "1 1 32 10 re W n", + "BT", + "/Helv 5 Tf", + "1 0 0 1 0 10 Tm", + "ET Q EMC", + ].join("\n") + ); + }); + + it("should render choice with multiple selections but one is visible for printing", async function () { + choiceWidgetDict.set("Ff", AnnotationFieldFlag.MULTISELECT); + choiceWidgetDict.set("Opt", [ + ["A", "a"], + ["B", "b"], + ["C", "c"], + ["D", "d"], + ]); + choiceWidgetDict.set("V", ["A"]); + + const choiceWidgetRef = Ref.get(271, 0); + const xref = new XRefMock([ + { ref: choiceWidgetRef, data: choiceWidgetDict }, + fontRefObj, + ]); + const task = new WorkerTask("test print"); + partialEvaluator.xref = xref; + + const annotation = await AnnotationFactory.create( + xref, + choiceWidgetRef, + pdfManagerMock, + idFactoryMock + ); + const annotationStorage = new Map(); + annotationStorage.set(annotation.data.id, { value: ["A", "C"] }); + + const appearance = await annotation._getAppearance( + partialEvaluator, + task, + annotationStorage + ); + expect(appearance).toEqual( + [ + "/Tx BMC q", + "1 1 32 10 re W n", + "0.600006 0.756866 0.854904 rg", + "1 3.25 32 6.75 re f", + "BT", + "/Helv 5 Tf", + "1 0 0 1 0 10 Tm", + "2.00 -5.88 Td (a) Tj", + "0.00 -6.75 Td (b) Tj", + "ET Q EMC", + ].join("\n") + ); + }); + + it("should render choice with multiple selections for printing", async function () { + choiceWidgetDict.set("Ff", AnnotationFieldFlag.MULTISELECT); + choiceWidgetDict.set("Opt", [ + ["A", "a"], + ["B", "b"], + ["C", "c"], + ["D", "d"], + ]); + choiceWidgetDict.set("V", ["A"]); + + const choiceWidgetRef = Ref.get(271, 0); + const xref = new XRefMock([ + { ref: choiceWidgetRef, data: choiceWidgetDict }, + fontRefObj, + ]); + const task = new WorkerTask("test print"); + partialEvaluator.xref = xref; + + const annotation = await AnnotationFactory.create( + xref, + choiceWidgetRef, + pdfManagerMock, + idFactoryMock + ); + const annotationStorage = new Map(); + annotationStorage.set(annotation.data.id, { value: ["B", "C"] }); + + const appearance = await annotation._getAppearance( + partialEvaluator, + task, + annotationStorage + ); + expect(appearance).toEqual( + [ + "/Tx BMC q", + "1 1 32 10 re W n", + "0.600006 0.756866 0.854904 rg", + "1 3.25 32 6.75 re f", + "1 -3.5 32 6.75 re f", + "BT", + "/Helv 5 Tf", + "1 0 0 1 0 10 Tm", + "2.00 -5.88 Td (b) Tj", + "0.00 -6.75 Td (c) Tj", + "ET Q EMC", + ].join("\n") ); }); @@ -3421,11 +3524,90 @@ describe("annotation", function () { "/AP << /N 2 0 R>> /M (date)>>\nendobj\n" ); expect(newData.data).toEqual( - "2 0 obj\n" + - "<< /Length 67 /Subtype /Form /Resources << /Font << /Helv 314 0 R>>>> " + - "/BBox [0 0 32 10]>> stream\n" + - "/Tx BMC q BT /Helv 5 Tf 1 0 0 1 0 0 Tm 2.00 3.04 Td (C) Tj ET Q EMC\n" + - "endstream\nendobj\n" + [ + "2 0 obj", + "<< /Length 136 /Subtype /Form /Resources << /Font << /Helv 314 0 R>>>> " + + "/BBox [0 0 32 10]>> stream", + "/Tx BMC q", + "1 1 32 10 re W n", + "0.600006 0.756866 0.854904 rg", + "1 3.25 32 6.75 re f", + "BT", + "/Helv 5 Tf", + "1 0 0 1 0 10 Tm", + "2.00 -5.88 Td (C) Tj", + "ET Q EMC", + "endstream", + "endobj\n", + ].join("\n") + ); + }); + + it("should save choice with multiple selections", async function () { + choiceWidgetDict.set("Ff", AnnotationFieldFlag.MULTISELECT); + choiceWidgetDict.set("Opt", [ + ["A", "a"], + ["B", "b"], + ["C", "c"], + ["D", "d"], + ]); + choiceWidgetDict.set("V", ["A"]); + + const choiceWidgetRef = Ref.get(123, 0); + const xref = new XRefMock([ + { ref: choiceWidgetRef, data: choiceWidgetDict }, + fontRefObj, + ]); + const task = new WorkerTask("test save"); + partialEvaluator.xref = xref; + + const annotation = await AnnotationFactory.create( + xref, + choiceWidgetRef, + pdfManagerMock, + idFactoryMock + ); + const annotationStorage = new Map(); + annotationStorage.set(annotation.data.id, { value: ["B", "C"] }); + + const data = await annotation.save( + partialEvaluator, + task, + annotationStorage + ); + + expect(data.length).toEqual(2); + const [oldData, newData] = data; + expect(oldData.ref).toEqual(Ref.get(123, 0)); + expect(newData.ref).toEqual(Ref.get(2, 0)); + + oldData.data = oldData.data.replace(/\(D:\d+\)/, "(date)"); + expect(oldData.data).toEqual( + "123 0 obj\n" + + "<< /Type /Annot /Subtype /Widget /FT /Ch /DA (/Helv 5 Tf) /DR " + + "<< /Font << /Helv 314 0 R>>>> /Rect [0 0 32 10] /Ff 2097152 /Opt " + + "[[(A) (a)] [(B) (b)] [(C) (c)] [(D) (d)]] /V [(B) (C)] /AP " + + "<< /N 2 0 R>> /M (date)>>\nendobj\n" + ); + expect(newData.data).toEqual( + [ + "2 0 obj", + "<< /Length 177 /Subtype /Form /Resources << /Font << /Helv 314 0 R>>>> " + + "/BBox [0 0 32 10]>> stream", + "/Tx BMC q", + "1 1 32 10 re W n", + "0.600006 0.756866 0.854904 rg", + "1 3.25 32 6.75 re f", + "1 -3.5 32 6.75 re f", + "BT", + "/Helv 5 Tf", + "1 0 0 1 0 10 Tm", + "2.00 -5.88 Td (b) Tj", + "0.00 -6.75 Td (c) Tj", + "ET Q EMC", + "endstream", + "endobj\n", + ].join("\n") ); }); });