[Annotations] Add support for printing/saving choice list with multiple selections

- it aims to fix issue #12189.
This commit is contained in:
Calixte Denizet 2022-03-26 22:45:50 +01:00
parent 0dd6bc9a85
commit ad3fb71a02
7 changed files with 355 additions and 33 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5897,10 +5897,7 @@
"value": "Dolor"
},
"62R": {
"value": "Sit"
},
"63R": {
"value": ""
"value": ["Sit", "Adipiscing"]
}
}
},

View File

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