diff --git a/src/core/annotation.js b/src/core/annotation.js index 557673058..78624d6bf 100644 --- a/src/core/annotation.js +++ b/src/core/annotation.js @@ -3346,7 +3346,7 @@ class ButtonWidgetAnnotation extends WidgetAnnotation { value: value ? this.data.exportValue : "", }; - const name = Name.get(value ? this.data.exportValue : "Off"); + const name = Name.get(value ? this._onStateName : "Off"); this.setValue(dict, name, evaluator.xref, changes); dict.set("AS", name); @@ -3406,7 +3406,7 @@ class ButtonWidgetAnnotation extends WidgetAnnotation { value: value ? this.data.buttonValue : "", }; - const name = Name.get(value ? this.data.buttonValue : "Off"); + const name = Name.get(value ? this._onStateName : "Off"); if (value) { this.setValue(dict, name, evaluator.xref, changes); } @@ -3485,6 +3485,107 @@ class ButtonWidgetAnnotation extends WidgetAnnotation { this._streams.push(this.checkedAppearance); } + _getOnStateName(dict) { + const appearanceStates = dict.get("AP"); + if (!(appearanceStates instanceof Dict)) { + return null; + } + const normalAppearance = appearanceStates.get("N"); + if (!(normalAppearance instanceof Dict)) { + return null; + } + for (const key of normalAppearance.getKeys()) { + if (key !== "Off") { + return key; + } + } + return null; + } + + _getExportValueForOptIndex(index, opt, xref) { + if (Number.isInteger(index) && index >= 0 && index < opt.length) { + const value = this._decodeFormValue(xref.fetchIfRef(opt[index])); + if (typeof value === "string") { + return value; + } + } + return null; + } + + _getOptInfo(dict, onState, opt, xref) { + if (!Array.isArray(opt)) { + return null; + } + const stateToIndex = new Map(); + let currentIndex = null; + + const fieldParent = dict.get("Parent"); + const kids = fieldParent instanceof Dict ? fieldParent.get("Kids") : null; + if (Array.isArray(kids)) { + for (let i = 0, ii = Math.min(kids.length, opt.length); i < ii; i++) { + const kid = kids[i]; + if (kid instanceof Ref && isRefsEqual(kid, this.ref)) { + currentIndex = i; + } + + const kidDict = xref.fetchIfRef(kid); + if (!(kidDict instanceof Dict)) { + continue; + } + if (kidDict === dict) { + currentIndex = i; + } + + const kidOnState = this._getOnStateName(kidDict); + if (typeof kidOnState === "string" && !stateToIndex.has(kidOnState)) { + stateToIndex.set(kidOnState, i); + } + } + } else if (opt.length === 1 && typeof onState === "string") { + // A single widget is sometimes used as its own field dictionary. + currentIndex = 0; + stateToIndex.set(onState, 0); + } + + return { currentIndex, opt, stateToIndex }; + } + + // The appearance state is a Name; its real export value can be overridden by + // the "Opt" array, whose entries are ordered like the field's "Kids". + _getExportValue(state, optInfo, xref) { + if (!optInfo || typeof state !== "string" || state === "Off") { + return state; + } + + if (state === this._onStateName) { + const exportValue = this._getExportValueForOptIndex( + optInfo.currentIndex, + optInfo.opt, + xref + ); + if (exportValue !== null) { + return exportValue; + } + } + + if (optInfo.stateToIndex.has(state)) { + const exportValue = this._getExportValueForOptIndex( + optInfo.stateToIndex.get(state), + optInfo.opt, + xref + ); + if (exportValue !== null) { + return exportValue; + } + } + + const index = parseInt(state, 10); + if (Number.isInteger(index) && String(index) === state) { + return this._getExportValueForOptIndex(index, optInfo.opt, xref) || state; + } + return state; + } + _processCheckBox(params) { const customAppearance = params.dict.get("AP"); let normalAppearance = @@ -3527,15 +3628,33 @@ class ButtonWidgetAnnotation extends WidgetAnnotation { exportValues.push("Off", otherYes); } + const onState = exportValues[1]; + this._onStateName = onState; + + const opt = getInheritableProperty({ dict: params.dict, key: "Opt" }); + const optInfo = this._getOptInfo(params.dict, onState, opt, params.xref); + this.data.exportValue = this._getExportValue(onState, optInfo, params.xref); + // Don't use a "V" entry pointing to a non-existent appearance state, // see e.g. bug1720411.pdf where it's an *empty* Name-instance. - if (!exportValues.includes(this.data.fieldValue)) { + if ( + !exportValues.includes(this.data.fieldValue) && + this.data.fieldValue !== this.data.exportValue + ) { this.data.fieldValue = "Off"; } + this.data.fieldValue = this._getExportValue( + this.data.fieldValue, + optInfo, + params.xref + ); + this.data.defaultFieldValue = this._getExportValue( + this.data.defaultFieldValue, + optInfo, + params.xref + ); - this.data.exportValue = exportValues[1]; - - const checkedAppearance = normalAppearance?.get(this.data.exportValue); + const checkedAppearance = normalAppearance?.get(onState); this.checkedAppearance = checkedAppearance instanceof BaseStream ? checkedAppearance : null; const uncheckedAppearance = normalAppearance?.get("Off"); @@ -3579,14 +3698,30 @@ class ButtonWidgetAnnotation extends WidgetAnnotation { if (!(normalAppearance instanceof Dict)) { return; } + let onState = null; for (const key of normalAppearance.getKeys()) { if (key !== "Off") { - this.data.buttonValue = key; + onState = key; break; } } + this._onStateName = onState; - const checkedAppearance = normalAppearance.get(this.data.buttonValue); + const opt = getInheritableProperty({ dict: params.dict, key: "Opt" }); + const optInfo = this._getOptInfo(params.dict, onState, opt, params.xref); + this.data.buttonValue = this._getExportValue(onState, optInfo, params.xref); + this.data.fieldValue = this._getExportValue( + this.data.fieldValue, + optInfo, + params.xref + ); + this.data.defaultFieldValue = this._getExportValue( + this.data.defaultFieldValue, + optInfo, + params.xref + ); + + const checkedAppearance = normalAppearance.get(onState); this.checkedAppearance = checkedAppearance instanceof BaseStream ? checkedAppearance : null; const uncheckedAppearance = normalAppearance.get("Off"); diff --git a/test/integration/scripting_spec.mjs b/test/integration/scripting_spec.mjs index 9ed732450..e372d36e1 100644 --- a/test/integration/scripting_spec.mjs +++ b/test/integration/scripting_spec.mjs @@ -2745,4 +2745,60 @@ describe("Interaction", () => { ); }); }); + + describe("in opt_demo.pdf", () => { + let pages; + + beforeEach(async () => { + pages = await loadAndWait("opt_demo.pdf", getSelector("19R")); + }); + + afterEach(async () => { + await closePages(pages); + }); + + it("must expose the Opt export value of checkboxes and radio buttons", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await waitForScripting(page); + + // Selecting a button runs a script that writes the field's value into + // the read-only "result" field (19R). The appearance states are + // indices, so without the "Opt" mapping we'd see the index here. + const cases = [ + ["8R", "fruit = [Cherry]"], + ["6R", "fruit = [りんご]"], + ["10R", "shared = [same]"], + ["12R", "agree = [I Agree to terms]"], + ]; + for (const [id, expected] of cases) { + await page.click(getSelector(id)); + await page.waitForFunction( + `${getQuerySelector("19R")}.value === ${JSON.stringify(expected)}` + ); + } + }) + ); + }); + + it("must expose the Opt export value when the parent has no Kids", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await waitForScripting(page); + + // The "veg" parent carries "Opt" but no "Kids", so its export value + // is resolved from the numeric appearance-state name. + await page.click(getSelector("22R")); + await page.waitForFunction( + `${getQuerySelector("19R")}.value === "veg = [Carrot]"` + ); + + await page.click(getSelector("23R")); + await page.waitForFunction( + `${getQuerySelector("19R")}.value === "veg = [Potato]"` + ); + }) + ); + }); + }); }); diff --git a/test/pdfs/.gitignore b/test/pdfs/.gitignore index 6070988ff..9a2a95fe0 100644 --- a/test/pdfs/.gitignore +++ b/test/pdfs/.gitignore @@ -935,3 +935,4 @@ !text_field_own_canvas_calc.pdf !bug1802506.pdf !checkbox_no_appearance.pdf +!opt_demo.pdf diff --git a/test/pdfs/opt_demo.pdf b/test/pdfs/opt_demo.pdf new file mode 100644 index 000000000..b9484913d --- /dev/null +++ b/test/pdfs/opt_demo.pdf @@ -0,0 +1,133 @@ +%PDF-1.7 +% +1 0 obj +<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R /Names << /JavaScript << /Names [(doc) 20 0 R] >> >> >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 540 360] /Contents 17 0 R /Resources << /Font << /Helv 18 0 R >> >> /Annots [6 0 R 7 0 R 8 0 R 10 0 R 11 0 R 12 0 R 19 0 R 22 0 R 23 0 R] >> +endobj +4 0 obj +<< /Fields [5 0 R 9 0 R 12 0 R 19 0 R 22 0 R 23 0 R] /DR << /Font << /Helv 18 0 R >> >> /DA (/Helv 0 Tf 0 g) /NeedAppearances false >> +endobj +5 0 obj +<< /FT /Btn /Ff 49152 /T (fruit) /V /1 /DV /1 /Kids [6 0 R 7 0 R 8 0 R] /Opt [ (Banane) (Cherry)] >> +endobj +6 0 obj +<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [50 300 70 320] /AP << /N << /0 13 0 R /Off 14 0 R >> >> /AS /Off /A << /S /JavaScript /JS (this.getField("result").value = "fruit = [" + this.getField("fruit").value + "]";) >> /MK << /BC [0] /BG [1] >> /F 4 >> +endobj +7 0 obj +<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [50 270 70 290] /AP << /N << /1 13 0 R /Off 14 0 R >> >> /AS /1 /A << /S /JavaScript /JS (this.getField("result").value = "fruit = [" + this.getField("fruit").value + "]";) >> /MK << /BC [0] /BG [1] >> /F 4 >> +endobj +8 0 obj +<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [50 240 70 260] /AP << /N << /2 13 0 R /Off 14 0 R >> >> /AS /Off /A << /S /JavaScript /JS (this.getField("result").value = "fruit = [" + this.getField("fruit").value + "]";) >> /MK << /BC [0] /BG [1] >> /F 4 >> +endobj +9 0 obj +<< /FT /Btn /Ff 49152 /T (shared) /V /Off /Kids [10 0 R 11 0 R] /Opt [(same) (same)] >> +endobj +10 0 obj +<< /Type /Annot /Subtype /Widget /Parent 9 0 R /Rect [50 178 70 198] /AP << /N << /0 13 0 R /Off 14 0 R >> >> /AS /Off /A << /S /JavaScript /JS (this.getField("result").value = "shared = [" + this.getField("shared").value + "]";) >> /MK << /BC [0] /BG [1] >> /F 4 >> +endobj +11 0 obj +<< /Type /Annot /Subtype /Widget /Parent 9 0 R /Rect [50 148 70 168] /AP << /N << /1 13 0 R /Off 14 0 R >> >> /AS /Off /A << /S /JavaScript /JS (this.getField("result").value = "shared = [" + this.getField("shared").value + "]";) >> /MK << /BC [0] /BG [1] >> /F 4 >> +endobj +12 0 obj +<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /Rect [50 86 70 106] /AP << /N << /0 15 0 R /Off 16 0 R >> >> /AS /Off /Opt [(I Agree to terms)] /A << /S /JavaScript /JS (this.getField("result").value = "agree = [" + this.getField("agree").value + "]";) >> /MK << /BC [0] /BG [1] >> /F 4 >> +endobj +13 0 obj +<< /Type /XObject /Subtype /Form /FormType 1 /BBox [0 0 20 20] /Resources << >> /Length 112 >> +stream +0 0 0 rg 16 10 m 16 13.31 13.31 16 10 16 c 6.69 16 4 13.31 4 10 c 4 6.69 6.69 4 10 4 c 13.31 4 16 6.69 16 10 c f +endstream +endobj +14 0 obj +<< /Type /XObject /Subtype /Form /FormType 1 /BBox [0 0 20 20] /Resources << >> /Length 0 >> +stream + +endstream +endobj +15 0 obj +<< /Type /XObject /Subtype /Form /FormType 1 /BBox [0 0 20 20] /Resources << >> /Length 35 >> +stream +0 0 0 RG 2 w 4 10 m 9 5 l 16 16 l S +endstream +endobj +16 0 obj +<< /Type /XObject /Subtype /Form /FormType 1 /BBox [0 0 20 20] /Resources << >> /Length 0 >> +stream + +endstream +endobj +17 0 obj +<< /Length 929 >> +stream +BT +/Helv 9 Tf 0 g +1 0 0 1 50 332 Tm (Radio "fruit": AP states = kid indices; Opt = real export values) Tj +1 0 0 1 75 306 Tm (state 0 -> Opt[0] = JP ringo [U+308A U+3093 U+3054]) Tj +1 0 0 1 75 276 Tm (state 1 -> Opt[1] = Banane [selected]) Tj +1 0 0 1 75 246 Tm (state 2 -> Opt[2] = Cherry) Tj +1 0 0 1 50 210 Tm (Radio "shared": both buttons share Opt = "same") Tj +1 0 0 1 75 184 Tm (state 0 -> "same") Tj +1 0 0 1 75 154 Tm (state 1 -> "same") Tj +1 0 0 1 50 118 Tm (Checkbox "agree": AP state "0", Opt = "I Agree to terms") Tj +1 0 0 1 75 92 Tm (agree) Tj +1 0 0 1 330 250 Tm (Last changed field -> value:) Tj +1 0 0 1 330 182 Tm (Radio "veg" \(parent has Opt but no /Kids\)) Tj +1 0 0 1 355 154 Tm (state 0 -> Opt[0] = Carrot) Tj +1 0 0 1 355 124 Tm (state 1 -> Opt[1] = Potato [selected]) Tj +1 0 0 1 50 26 Tm (Change any option; the box shows that field's value. pdf.js shows the index, Acrobat the Opt value.) Tj +ET +endstream +endobj +18 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> +endobj +19 0 obj +<< /Type /Annot /Subtype /Widget /FT /Tx /Ff 1 /T (result) /Rect [330 218 530 243] /DA (/Helv 11 Tf 0 g) /V () /MK << /BC [0] /BG [0.95 0.95 0.95] >> /F 4 >> +endobj +20 0 obj +<< /S /JavaScript /JS (0;) >> +endobj +21 0 obj +<< /FT /Btn /Ff 49152 /T (veg) /V /1 /DV /1 /Opt [(Carrot) (Potato)] >> +endobj +22 0 obj +<< /Type /Annot /Subtype /Widget /Parent 21 0 R /Rect [330 148 350 168] /AP << /N << /0 13 0 R /Off 14 0 R >> >> /AS /Off /A << /S /JavaScript /JS (this.getField("result").value = "veg = [" + this.getField("veg").value + "]";) >> /MK << /BC [0] /BG [1] >> /F 4 >> +endobj +23 0 obj +<< /Type /Annot /Subtype /Widget /Parent 21 0 R /Rect [330 118 350 138] /AP << /N << /1 13 0 R /Off 14 0 R >> >> /AS /1 /A << /S /JavaScript /JS (this.getField("result").value = "veg = [" + this.getField("veg").value + "]";) >> /MK << /BC [0] /BG [1] >> /F 4 >> +endobj +xref +0 24 +0000000000 65535 f +0000000015 00000 n +0000000133 00000 n +0000000190 00000 n +0000000390 00000 n +0000000540 00000 n +0000000674 00000 n +0000000954 00000 n +0000001232 00000 n +0000001512 00000 n +0000001615 00000 n +0000001898 00000 n +0000002181 00000 n +0000002493 00000 n +0000002734 00000 n +0000002861 00000 n +0000003024 00000 n +0000003151 00000 n +0000004132 00000 n +0000004230 00000 n +0000004404 00000 n +0000004450 00000 n +0000004538 00000 n +0000004818 00000 n +trailer +<< /Size 24 /Root 1 0 R >> +startxref +5096 +%%EOF diff --git a/test/unit/annotation_spec.js b/test/unit/annotation_spec.js index 7e3ca6658..2744ca915 100644 --- a/test/unit/annotation_spec.js +++ b/test/unit/annotation_spec.js @@ -2494,6 +2494,80 @@ describe("annotation", function () { expect(data.exportValue).toEqual("Checked"); }); + it("should handle checkboxes with an Opt export value", async function () { + // The appearance state is the index "0"; the real export value lives in + // the "Opt" array (e.g. for values that aren't valid Name objects). + buttonWidgetDict.set("V", Name.get("0")); + buttonWidgetDict.set("DV", Name.get("0")); + buttonWidgetDict.set("Opt", ["I Agree to terms"]); + + const appearanceStatesDict = new Dict(); + const normalAppearanceDict = new Dict(); + + normalAppearanceDict.set("Off", 0); + normalAppearanceDict.set("0", 1); + appearanceStatesDict.set("N", normalAppearanceDict); + buttonWidgetDict.set("AP", appearanceStatesDict); + + const buttonWidgetRef = Ref.get(124, 0); + const xref = new XRefMock([ + { ref: buttonWidgetRef, data: buttonWidgetDict }, + ]); + + const { data } = await AnnotationFactory.create( + xref, + buttonWidgetRef, + annotationGlobalsMock, + idFactoryMock + ); + expect(data.annotationType).toEqual(AnnotationType.WIDGET); + expect(data.checkBox).toEqual(true); + expect(data.radioButton).toEqual(false); + expect(data.exportValue).toEqual("I Agree to terms"); + expect(data.fieldValue).toEqual("I Agree to terms"); + expect(data.defaultFieldValue).toEqual("I Agree to terms"); + }); + + it("should handle checkboxes with an Opt export value in the parent", async function () { + const buttonWidgetRef = Ref.get(124, 0); + const parentRef = Ref.get(125, 0); + + const parentDict = new Dict(); + parentDict.set("V", Name.get("CheckedState")); + parentDict.set("DV", Name.get("CheckedState")); + parentDict.set("Kids", [buttonWidgetRef]); + parentDict.set("T", "CheckboxGroup"); + parentDict.set("Opt", ["I Agree to terms"]); + + const appearanceStatesDict = new Dict(); + const normalAppearanceDict = new Dict(); + + normalAppearanceDict.set("Off", 0); + normalAppearanceDict.set("CheckedState", 1); + appearanceStatesDict.set("N", normalAppearanceDict); + buttonWidgetDict.set("AP", appearanceStatesDict); + buttonWidgetDict.set("Parent", parentRef); + + const xref = new XRefMock([ + { ref: buttonWidgetRef, data: buttonWidgetDict }, + { ref: parentRef, data: parentDict }, + ]); + buttonWidgetDict.xref = parentDict.xref = xref; + + const { data } = await AnnotationFactory.create( + xref, + buttonWidgetRef, + annotationGlobalsMock, + idFactoryMock + ); + expect(data.annotationType).toEqual(AnnotationType.WIDGET); + expect(data.checkBox).toEqual(true); + expect(data.radioButton).toEqual(false); + expect(data.exportValue).toEqual("I Agree to terms"); + expect(data.fieldValue).toEqual("I Agree to terms"); + expect(data.defaultFieldValue).toEqual("I Agree to terms"); + }); + it("should handle checkboxes without export value", async function () { buttonWidgetDict.set("V", Name.get("Checked")); buttonWidgetDict.set("DV", Name.get("Off")); @@ -2982,6 +3056,126 @@ describe("annotation", function () { expect(data.buttonValue).toEqual("2"); }); + it("should handle radio buttons with an Opt export value", async function () { + const parentDict = new Dict(); + parentDict.set("V", Name.get("1")); + parentDict.set("Opt", ["Apple", "Banane", "Cherry"]); + + const normalAppearanceStateDict = new Dict(); + normalAppearanceStateDict.set("2", null); + + const appearanceStatesDict = new Dict(); + appearanceStatesDict.set("N", normalAppearanceStateDict); + + buttonWidgetDict.set("Ff", AnnotationFieldFlag.RADIO); + buttonWidgetDict.set("Parent", parentDict); + buttonWidgetDict.set("AP", appearanceStatesDict); + + const buttonWidgetRef = Ref.get(124, 0); + const xref = new XRefMock([ + { ref: buttonWidgetRef, data: buttonWidgetDict }, + ]); + + const { data } = await AnnotationFactory.create( + xref, + buttonWidgetRef, + annotationGlobalsMock, + idFactoryMock + ); + expect(data.annotationType).toEqual(AnnotationType.WIDGET); + expect(data.radioButton).toEqual(true); + // The field value (parent "V" = "1") and this widget's own on-state ("2") + // are both mapped through "Opt" to their real export values. + expect(data.fieldValue).toEqual("Banane"); + expect(data.buttonValue).toEqual("Cherry"); + }); + + it("should not map non-canonical numeric radio states through Opt", async function () { + const parentDict = new Dict(); + parentDict.set("V", Name.get("02")); + parentDict.set("Opt", ["Apple", "Banane", "Cherry"]); + + const normalAppearanceStateDict = new Dict(); + normalAppearanceStateDict.set("02", null); + + const appearanceStatesDict = new Dict(); + appearanceStatesDict.set("N", normalAppearanceStateDict); + + buttonWidgetDict.set("Ff", AnnotationFieldFlag.RADIO); + buttonWidgetDict.set("Parent", parentDict); + buttonWidgetDict.set("AP", appearanceStatesDict); + + const buttonWidgetRef = Ref.get(124, 0); + const xref = new XRefMock([ + { ref: buttonWidgetRef, data: buttonWidgetDict }, + ]); + + const { data } = await AnnotationFactory.create( + xref, + buttonWidgetRef, + annotationGlobalsMock, + idFactoryMock + ); + expect(data.annotationType).toEqual(AnnotationType.WIDGET); + expect(data.radioButton).toEqual(true); + expect(data.fieldValue).toEqual("02"); + expect(data.buttonValue).toEqual("02"); + }); + + it("should handle radio buttons with non-numeric Opt export values", async function () { + const appleRef = Ref.get(122, 0); + const bananaRef = Ref.get(123, 0); + const cherryRef = Ref.get(124, 0); + const parentRef = Ref.get(125, 0); + + const parentDict = new Dict(); + parentDict.set("V", Name.get("BananaState")); + parentDict.set("Kids", [appleRef, bananaRef, cherryRef]); + parentDict.set("T", "Fruits"); + parentDict.set("Opt", ["Apple", "Banane", "Cherry"]); + + const createRadioDict = state => { + const radioDict = buttonWidgetDict.clone(); + const normalAppearanceStateDict = new Dict(); + normalAppearanceStateDict.set(state, null); + + const appearanceStatesDict = new Dict(); + appearanceStatesDict.set("N", normalAppearanceStateDict); + + radioDict.set("Ff", AnnotationFieldFlag.RADIO); + radioDict.set("Parent", parentRef); + radioDict.set("AP", appearanceStatesDict); + return radioDict; + }; + + const appleDict = createRadioDict("AppleState"); + const bananaDict = createRadioDict("BananaState"); + const cherryDict = createRadioDict("CherryState"); + + const xref = new XRefMock([ + { ref: appleRef, data: appleDict }, + { ref: bananaRef, data: bananaDict }, + { ref: cherryRef, data: cherryDict }, + { ref: parentRef, data: parentDict }, + ]); + appleDict.xref = + bananaDict.xref = + cherryDict.xref = + parentDict.xref = + xref; + + const { data } = await AnnotationFactory.create( + xref, + cherryRef, + annotationGlobalsMock, + idFactoryMock + ); + expect(data.annotationType).toEqual(AnnotationType.WIDGET); + expect(data.radioButton).toEqual(true); + expect(data.fieldValue).toEqual("Banane"); + expect(data.buttonValue).toEqual("Cherry"); + }); + it("should handle radio buttons with a field value that's not an ASCII string", async function () { const parentDict = new Dict(); const name = "\x91I=\x91\xf0\x93\xe0\x97e3"; @@ -3256,6 +3450,113 @@ describe("annotation", function () { expect(changes.size).toEqual(0); }); + it("should save radio buttons with Opt export values", async function () { + const appearanceStatesDict = new Dict(); + const normalAppearanceDict = new Dict(); + + normalAppearanceDict.set("CheckedState", Ref.get(314, 0)); + normalAppearanceDict.set("Off", Ref.get(271, 0)); + appearanceStatesDict.set("N", normalAppearanceDict); + + buttonWidgetDict.set("Ff", AnnotationFieldFlag.RADIO); + buttonWidgetDict.set("AP", appearanceStatesDict); + + const buttonWidgetRef = Ref.get(123, 0); + const parentRef = Ref.get(456, 0); + + const parentDict = new Dict(); + parentDict.set("V", Name.get("Off")); + parentDict.set("Kids", [buttonWidgetRef]); + parentDict.set("T", "RadioGroup"); + parentDict.set("Opt", ["I Agree to terms"]); + buttonWidgetDict.set("Parent", parentRef); + + const xref = new XRefMock([ + { ref: buttonWidgetRef, data: buttonWidgetDict }, + { ref: parentRef, data: parentDict }, + ]); + + parentDict.xref = xref; + buttonWidgetDict.xref = xref; + partialEvaluator.xref = xref; + const task = new WorkerTask("test save"); + + const annotation = await AnnotationFactory.create( + xref, + buttonWidgetRef, + annotationGlobalsMock, + idFactoryMock + ); + expect(annotation.data.buttonValue).toEqual("I Agree to terms"); + + const annotationStorage = new Map(); + annotationStorage.set(annotation.data.id, { value: true }); + const changes = new RefSetCache(); + + await annotation.save(partialEvaluator, task, annotationStorage, changes); + const data = await writeChanges(changes, xref); + expect(data.length).toEqual(2); + const [radioData, parentData] = data; + radioData.data = radioData.data.replace(/\(D:\d+\)/, "(date)"); + expect(radioData.ref).toEqual(Ref.get(123, 0)); + expect(radioData.data).toEqual( + "123 0 obj\n" + + "<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 32768 " + + "/AP << /N << /CheckedState 314 0 R /Off 271 0 R>>>> " + + "/Parent 456 0 R /AS /CheckedState /M (date)>>\nendobj\n" + ); + expect(parentData.ref).toEqual(Ref.get(456, 0)); + expect(parentData.data).toEqual( + "456 0 obj\n<< /V /CheckedState /Kids [123 0 R] /T (RadioGroup) " + + "/Opt [(I Agree to terms)]>>\nendobj\n" + ); + }); + + it("should save checkboxes with Opt export values", async function () { + const appearanceStatesDict = new Dict(); + const normalAppearanceDict = new Dict(); + + normalAppearanceDict.set("CheckedState", Ref.get(314, 0)); + normalAppearanceDict.set("Off", Ref.get(271, 0)); + appearanceStatesDict.set("N", normalAppearanceDict); + + buttonWidgetDict.set("AP", appearanceStatesDict); + buttonWidgetDict.set("V", Name.get("Off")); + buttonWidgetDict.set("Opt", ["I Agree to terms"]); + + const buttonWidgetRef = Ref.get(123, 0); + const xref = new XRefMock([ + { ref: buttonWidgetRef, data: buttonWidgetDict }, + ]); + buttonWidgetDict.xref = xref; + partialEvaluator.xref = xref; + const task = new WorkerTask("test save"); + + const annotation = await AnnotationFactory.create( + xref, + buttonWidgetRef, + annotationGlobalsMock, + idFactoryMock + ); + expect(annotation.data.exportValue).toEqual("I Agree to terms"); + + const annotationStorage = new Map(); + annotationStorage.set(annotation.data.id, { value: true }); + const changes = new RefSetCache(); + + await annotation.save(partialEvaluator, task, annotationStorage, changes); + const [data] = await writeChanges(changes, xref); + data.data = data.data.replace(/\(D:\d+\)/, "(date)"); + expect(data.ref).toEqual(Ref.get(123, 0)); + expect(data.data).toEqual( + "123 0 obj\n" + + "<< /Type /Annot /Subtype /Widget /FT /Btn " + + "/AP << /N << /CheckedState 314 0 R /Off 271 0 R>>>> " + + "/V /CheckedState /Opt [(I Agree to terms)] " + + "/AS /CheckedState /M (date)>>\nendobj\n" + ); + }); + it("should save radio buttons without a field value", async function () { const appearanceStatesDict = new Dict(); const normalAppearanceDict = new Dict();