diff --git a/src/display/annotation_layer.js b/src/display/annotation_layer.js index 1a1d317762d75..33e9038abaa68 100644 --- a/src/display/annotation_layer.js +++ b/src/display/annotation_layer.js @@ -34,6 +34,7 @@ import { AnnotationStorage } from "./annotation_storage.js"; import { ColorConverters } from "../shared/scripting_utils.js"; const DEFAULT_TAB_INDEX = 1000; +const GetElementsByNameSet = new WeakSet(); /** * @typedef {Object} AnnotationElementParameters @@ -50,6 +51,7 @@ const DEFAULT_TAB_INDEX = 1000; * @property {Object} svgFactory * @property {boolean} [enableScripting] * @property {boolean} [hasJSActions] + * @property {Object} [fieldObjects] * @property {Object} [mouseState] */ @@ -159,6 +161,7 @@ class AnnotationElement { this.annotationStorage = parameters.annotationStorage; this.enableScripting = parameters.enableScripting; this.hasJSActions = parameters.hasJSActions; + this._fieldObjects = parameters.fieldObjects; this._mouseState = parameters.mouseState; if (isRenderable) { @@ -363,6 +366,51 @@ class AnnotationElement { unreachable("Abstract method `AnnotationElement.render` called"); } + /** + * @private + * @returns {Array} + */ + _getElementsByName(name, skipId = null) { + const fields = []; + + if (this._fieldObjects) { + const fieldObj = this._fieldObjects[name]; + if (fieldObj) { + for (const { page, id, exportValues } of fieldObj) { + if (page === -1) { + continue; + } + if (id === skipId) { + continue; + } + const exportValue = + typeof exportValues === "string" ? exportValues : null; + + const domElement = document.getElementById(id); + if (domElement && !GetElementsByNameSet.has(domElement)) { + warn(`_getElementsByName - element not allowed: ${id}`); + continue; + } + fields.push({ id, exportValue, domElement }); + } + } + return fields; + } + // Fallback to a regular DOM lookup, to ensure that the standalone + // viewer components won't break. + for (const domElement of document.getElementsByName(name)) { + const { id, exportValue } = domElement; + if (id === skipId) { + continue; + } + if (!GetElementsByNameSet.has(domElement)) { + continue; + } + fields.push({ id, exportValue, domElement }); + } + return fields; + } + static get platform() { const platform = typeof navigator !== "undefined" ? navigator.platform : ""; @@ -687,13 +735,14 @@ class TextWidgetAnnotationElement extends WidgetAnnotationElement { setPropertyOnSiblings(base, key, value, keyInStorage) { const storage = this.annotationStorage; - for (const element of document.getElementsByName(base.name)) { - if (element !== base) { - element[key] = value; - const data = Object.create(null); - data[keyInStorage] = value; - storage.setValue(element.getAttribute("id"), data); + for (const element of this._getElementsByName( + base.name, + /* skipId = */ base.id + )) { + if (element.domElement) { + element.domElement[key] = value; } + storage.setValue(element.id, { [keyInStorage]: value }); } } @@ -728,6 +777,9 @@ class TextWidgetAnnotationElement extends WidgetAnnotationElement { element.type = "text"; element.setAttribute("value", textContent); } + GetElementsByNameSet.add(element); + element.disabled = this.data.readOnly; + element.name = this.data.fieldName; element.tabIndex = DEFAULT_TAB_INDEX; elementData.userValue = textContent; @@ -900,9 +952,6 @@ class TextWidgetAnnotationElement extends WidgetAnnotationElement { element.addEventListener("blur", blurListener); } - element.disabled = this.data.readOnly; - element.name = this.data.fieldName; - if (this.data.maxLen !== null) { element.maxLength = this.data.maxLen; } @@ -978,6 +1027,7 @@ class CheckboxWidgetAnnotationElement extends WidgetAnnotationElement { this.container.className = "buttonWidgetAnnotation checkBox"; const element = document.createElement("input"); + GetElementsByNameSet.add(element); element.disabled = data.readOnly; element.type = "checkbox"; element.name = data.fieldName; @@ -988,19 +1038,14 @@ class CheckboxWidgetAnnotationElement extends WidgetAnnotationElement { element.setAttribute("exportValue", data.exportValue); element.tabIndex = DEFAULT_TAB_INDEX; - element.addEventListener("change", function (event) { - const name = event.target.name; - const checked = event.target.checked; - for (const checkbox of document.getElementsByName(name)) { - if (checkbox !== event.target) { - checkbox.checked = - checked && - checkbox.getAttribute("exportValue") === data.exportValue; - storage.setValue( - checkbox.parentNode.getAttribute("data-annotation-id"), - { value: false } - ); + element.addEventListener("change", event => { + const { name, checked } = event.target; + for (const checkbox of this._getElementsByName(name, /* skipId = */ id)) { + const curChecked = checked && checkbox.exportValue === data.exportValue; + if (checkbox.domElement) { + checkbox.domElement.checked = curChecked; } + storage.setValue(checkbox.id, { value: curChecked }); } storage.setValue(id, { value: checked }); }); @@ -1057,6 +1102,7 @@ class RadioButtonWidgetAnnotationElement extends WidgetAnnotationElement { } const element = document.createElement("input"); + GetElementsByNameSet.add(element); element.disabled = data.readOnly; element.type = "radio"; element.name = data.fieldName; @@ -1066,26 +1112,26 @@ class RadioButtonWidgetAnnotationElement extends WidgetAnnotationElement { element.setAttribute("id", id); element.tabIndex = DEFAULT_TAB_INDEX; - element.addEventListener("change", function (event) { - const { target } = event; - for (const radio of document.getElementsByName(target.name)) { - if (radio !== target) { - storage.setValue(radio.getAttribute("id"), { value: false }); - } + element.addEventListener("change", event => { + const { name, checked } = event.target; + for (const radio of this._getElementsByName(name, /* skipId = */ id)) { + storage.setValue(radio.id, { value: false }); } - storage.setValue(id, { value: target.checked }); + storage.setValue(id, { value: checked }); }); if (this.enableScripting && this.hasJSActions) { const pdfButtonValue = data.buttonValue; element.addEventListener("updatefromsandbox", jsEvent => { const actions = { - value(event) { + value: event => { const checked = pdfButtonValue === event.detail.value; - for (const radio of document.getElementsByName(event.target.name)) { - const radioId = radio.getAttribute("id"); - radio.checked = radioId === id && checked; - storage.setValue(radioId, { value: radio.checked }); + for (const radio of this._getElementsByName(event.target.name)) { + const curChecked = checked && radio.id === id; + if (radio.domElement) { + radio.domElement.checked = curChecked; + } + storage.setValue(radio.id, { value: curChecked }); } }, }; @@ -1158,6 +1204,7 @@ class ChoiceWidgetAnnotationElement extends WidgetAnnotationElement { const fontSizeStyle = `calc(${fontSize}px * var(--zoom-factor))`; const selectElement = document.createElement("select"); + GetElementsByNameSet.add(selectElement); selectElement.disabled = this.data.readOnly; selectElement.name = this.data.fieldName; selectElement.setAttribute("id", id); @@ -2090,6 +2137,7 @@ class AnnotationLayer { parameters.annotationStorage || new AnnotationStorage(), enableScripting: parameters.enableScripting, hasJSActions: parameters.hasJSActions, + fieldObjects: parameters.fieldObjects, mouseState: parameters.mouseState || { isDown: false }, }); if (element.isRenderable) { diff --git a/src/display/api.js b/src/display/api.js index 0ae803035cc6d..61a1b850016a3 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -1017,9 +1017,9 @@ class PDFDocumentProxy { } /** - * @returns {Promise | null>} A promise that is resolved with an - * {Array} containing /AcroForm field data for the JS sandbox, - * or `null` when no field data is present in the PDF file. + * @returns {Promise> | null>} A promise that is + * resolved with an {Object} containing /AcroForm field data for the JS + * sandbox, or `null` when no field data is present in the PDF file. */ getFieldObjects() { return this._transport.getFieldObjects(); @@ -2480,6 +2480,7 @@ class WorkerTransport { Promise.all(waitOn).then(() => { this.commonObjs.clear(); this.fontLoader.clear(); + this._getFieldObjectsPromise = null; this._hasJSActionsPromise = null; if (this._networkStream) { @@ -2921,7 +2922,8 @@ class WorkerTransport { } getFieldObjects() { - return this.messageHandler.sendWithPromise("GetFieldObjects", null); + return (this._getFieldObjectsPromise ||= + this.messageHandler.sendWithPromise("GetFieldObjects", null)); } hasJSActions() { @@ -3050,6 +3052,7 @@ class WorkerTransport { if (!keepLoadedFonts) { this.fontLoader.clear(); } + this._getFieldObjectsPromise = null; this._hasJSActionsPromise = null; } diff --git a/test/integration/annotation_spec.js b/test/integration/annotation_spec.js index a2444ea01820b..5fcd7901e0987 100644 --- a/test/integration/annotation_spec.js +++ b/test/integration/annotation_spec.js @@ -126,3 +126,98 @@ describe("Text widget", () => { }); }); }); + +describe("Annotation and storage", () => { + describe("issue14023.pdf", () => { + let pages; + + beforeAll(async () => { + pages = await loadAndWait("issue14023.pdf", "[data-annotation-id='64R']"); + }); + + afterAll(async () => { + await closePages(pages); + }); + + it("must let checkboxes with the same name behave like radio buttons", async () => { + const text1 = "hello world!"; + const text2 = "!dlrow olleh"; + await Promise.all( + pages.map(async ([browserName, page]) => { + // Text field. + await page.type("#\\36 4R", text1); + // Checkbox. + await page.click("[data-annotation-id='65R']"); + // Radio. + await page.click("[data-annotation-id='67R']"); + + for (const [pageNumber, textId, checkId, radio1Id, radio2Id] of [ + [2, "#\\31 8R", "#\\31 9R", "#\\32 1R", "#\\32 0R"], + [5, "#\\32 3R", "#\\32 4R", "#\\32 2R", "#\\32 5R"], + ]) { + await page.evaluate(n => { + window.document + .querySelectorAll(`[data-page-number="${n}"][class="page"]`)[0] + .scrollIntoView(); + }, pageNumber); + + // Need to wait to have a displayed text input. + await page.waitForSelector(textId, { + timeout: 0, + }); + + const text = await page.$eval(textId, el => el.value); + expect(text).withContext(`In ${browserName}`).toEqual(text1); + + let checked = await page.$eval(checkId, el => el.checked); + expect(checked).toEqual(true); + + checked = await page.$eval(radio1Id, el => el.checked); + expect(checked).toEqual(false); + + checked = await page.$eval(radio2Id, el => el.checked); + expect(checked).toEqual(false); + } + + // Change data on page 5 and check that other pages changed. + // Text field. + await page.type("#\\32 3R", text2); + // Checkbox. + await page.click("[data-annotation-id='24R']"); + // Radio. + await page.click("[data-annotation-id='25R']"); + + for (const [pageNumber, textId, checkId, radio1Id, radio2Id] of [ + [1, "#\\36 4R", "#\\36 5R", "#\\36 7R", "#\\36 8R"], + [2, "#\\31 8R", "#\\31 9R", "#\\32 1R", "#\\32 0R"], + ]) { + await page.evaluate(n => { + window.document + .querySelectorAll(`[data-page-number="${n}"][class="page"]`)[0] + .scrollIntoView(); + }, pageNumber); + + // Need to wait to have a displayed text input. + await page.waitForSelector(textId, { + timeout: 0, + }); + + const text = await page.$eval(textId, el => el.value); + expect(text) + .withContext(`In ${browserName}`) + .toEqual(text2 + text1); + + let checked = await page.$eval(checkId, el => el.checked); + expect(checked).toEqual(false); + + checked = await page.$eval(radio1Id, el => el.checked); + expect(checked).toEqual(false); + + checked = await page.$eval(radio2Id, el => el.checked); + expect(checked).toEqual(false); + } + }) + ); + }); + }); +}); diff --git a/test/pdfs/.gitignore b/test/pdfs/.gitignore index 3790b351477b1..a53250667d29f 100644 --- a/test/pdfs/.gitignore +++ b/test/pdfs/.gitignore @@ -112,6 +112,7 @@ !issue11651.pdf !issue11878.pdf !issue13916.pdf +!issue14023.pdf !bad-PageLabels.pdf !decodeACSuccessive.pdf !filled-background.pdf diff --git a/test/pdfs/issue14023.pdf b/test/pdfs/issue14023.pdf new file mode 100644 index 0000000000000..03d3ca8ec3814 Binary files /dev/null and b/test/pdfs/issue14023.pdf differ diff --git a/web/annotation_layer_builder.js b/web/annotation_layer_builder.js index b191c53f2a10c..843b07b2cc4fe 100644 --- a/web/annotation_layer_builder.js +++ b/web/annotation_layer_builder.js @@ -33,6 +33,8 @@ import { SimpleLinkService } from "./pdf_link_service.js"; * @property {IL10n} l10n - Localization service. * @property {boolean} [enableScripting] * @property {Promise} [hasJSActionsPromise] + * @property {Promise> | null>} + * [fieldObjectsPromise] * @property {Object} [mouseState] */ @@ -51,6 +53,7 @@ class AnnotationLayerBuilder { l10n = NullL10n, enableScripting = false, hasJSActionsPromise = null, + fieldObjectsPromise = null, mouseState = null, }) { this.pageDiv = pageDiv; @@ -63,6 +66,7 @@ class AnnotationLayerBuilder { this.annotationStorage = annotationStorage; this.enableScripting = enableScripting; this._hasJSActionsPromise = hasJSActionsPromise; + this._fieldObjectsPromise = fieldObjectsPromise; this._mouseState = mouseState; this.div = null; @@ -75,46 +79,49 @@ class AnnotationLayerBuilder { * @returns {Promise} A promise that is resolved when rendering of the * annotations is complete. */ - render(viewport, intent = "display") { - return Promise.all([ - this.pdfPage.getAnnotations({ intent }), - this._hasJSActionsPromise, - ]).then(([annotations, hasJSActions = false]) => { - if (this._cancelled || annotations.length === 0) { - return; - } + async render(viewport, intent = "display") { + const [annotations, hasJSActions = false, fieldObjects = null] = + await Promise.all([ + this.pdfPage.getAnnotations({ intent }), + this._hasJSActionsPromise, + this._fieldObjectsPromise, + ]); - const parameters = { - viewport: viewport.clone({ dontFlip: true }), - div: this.div, - annotations, - page: this.pdfPage, - imageResourcesPath: this.imageResourcesPath, - renderForms: this.renderForms, - linkService: this.linkService, - downloadManager: this.downloadManager, - annotationStorage: this.annotationStorage, - enableScripting: this.enableScripting, - hasJSActions, - mouseState: this._mouseState, - }; + if (this._cancelled || annotations.length === 0) { + return; + } - if (this.div) { - // If an annotationLayer already exists, refresh its children's - // transformation matrices. - AnnotationLayer.update(parameters); - } else { - // Create an annotation layer div and render the annotations - // if there is at least one annotation. - this.div = document.createElement("div"); - this.div.className = "annotationLayer"; - this.pageDiv.appendChild(this.div); - parameters.div = this.div; + const parameters = { + viewport: viewport.clone({ dontFlip: true }), + div: this.div, + annotations, + page: this.pdfPage, + imageResourcesPath: this.imageResourcesPath, + renderForms: this.renderForms, + linkService: this.linkService, + downloadManager: this.downloadManager, + annotationStorage: this.annotationStorage, + enableScripting: this.enableScripting, + hasJSActions, + fieldObjects, + mouseState: this._mouseState, + }; - AnnotationLayer.render(parameters); - this.l10n.translate(this.div); - } - }); + if (this.div) { + // If an annotationLayer already exists, refresh its children's + // transformation matrices. + AnnotationLayer.update(parameters); + } else { + // Create an annotation layer div and render the annotations + // if there is at least one annotation. + this.div = document.createElement("div"); + this.div.className = "annotationLayer"; + this.pageDiv.appendChild(this.div); + parameters.div = this.div; + + AnnotationLayer.render(parameters); + this.l10n.translate(this.div); + } } cancel() { @@ -144,6 +151,8 @@ class DefaultAnnotationLayerFactory { * @param {boolean} [enableScripting] * @param {Promise} [hasJSActionsPromise] * @param {Object} [mouseState] + * @param {Promise> | null>} + * [fieldObjectsPromise] * @returns {AnnotationLayerBuilder} */ createAnnotationLayerBuilder( @@ -155,7 +164,8 @@ class DefaultAnnotationLayerFactory { l10n = NullL10n, enableScripting = false, hasJSActionsPromise = null, - mouseState = null + mouseState = null, + fieldObjectsPromise = null ) { return new AnnotationLayerBuilder({ pageDiv, @@ -167,6 +177,7 @@ class DefaultAnnotationLayerFactory { annotationStorage, enableScripting, hasJSActionsPromise, + fieldObjectsPromise, mouseState, }); } diff --git a/web/base_viewer.js b/web/base_viewer.js index 24c0c062f157a..b1b1086da7ce4 100644 --- a/web/base_viewer.js +++ b/web/base_viewer.js @@ -1318,6 +1318,8 @@ class BaseViewer { * @param {boolean} [enableScripting] * @param {Promise} [hasJSActionsPromise] * @param {Object} [mouseState] + * @param {Promise> | null>} + * [fieldObjectsPromise] * @returns {AnnotationLayerBuilder} */ createAnnotationLayerBuilder( @@ -1329,7 +1331,8 @@ class BaseViewer { l10n = NullL10n, enableScripting = null, hasJSActionsPromise = null, - mouseState = null + mouseState = null, + fieldObjectsPromise = null ) { return new AnnotationLayerBuilder({ pageDiv, @@ -1344,6 +1347,8 @@ class BaseViewer { enableScripting: enableScripting ?? this.enableScripting, hasJSActionsPromise: hasJSActionsPromise || this.pdfDocument?.hasJSActions(), + fieldObjectsPromise: + fieldObjectsPromise || this.pdfDocument?.getFieldObjects(), mouseState: mouseState || this._scriptingManager?.mouseState, }); } diff --git a/web/interfaces.js b/web/interfaces.js index b3315eee413ef..efc66145d45b8 100644 --- a/web/interfaces.js +++ b/web/interfaces.js @@ -166,6 +166,8 @@ class IPDFAnnotationLayerFactory { * @param {boolean} [enableScripting] * @param {Promise} [hasJSActionsPromise] * @param {Object} [mouseState] + * @param {Promise> | null>} + * [fieldObjectsPromise] * @returns {AnnotationLayerBuilder} */ createAnnotationLayerBuilder( @@ -177,7 +179,8 @@ class IPDFAnnotationLayerFactory { l10n = undefined, enableScripting = false, hasJSActionsPromise = null, - mouseState = null + mouseState = null, + fieldObjectsPromise = null ) {} } diff --git a/web/pdf_page_view.js b/web/pdf_page_view.js index 00951f653f0d9..aac1ab72970df 100644 --- a/web/pdf_page_view.js +++ b/web/pdf_page_view.js @@ -675,7 +675,8 @@ class PDFPageView { this.l10n, /* enableScripting = */ null, /* hasJSActionsPromise = */ null, - /* mouseState = */ null + /* mouseState = */ null, + /* fieldObjectsPromise = */ null ); } this._renderAnnotationLayer();