From 069b7579980e0a1d441d3eb52c859a79a2159855 Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Tue, 15 Oct 2024 19:22:57 +0200 Subject: [PATCH] Use the checkboxes and radio button appearances as defined in the pdf to render them in the annotation layer (bug 1802506) The idea is to generate two operator lists for the Yes/Off states and render them on a separate canvas. These canvases are then attached the annotation and we modify their display depending on the input state. It fixes #18021. --- src/core/annotation.js | 191 +++++++++++++------- src/display/annotation_layer.js | 56 ++++-- src/display/canvas.js | 31 +++- test/annotation_layer_builder_overrides.css | 22 +++ test/driver.js | 48 +++-- test/integration/annotation_spec.mjs | 8 +- test/integration/scripting_spec.mjs | 43 ----- test/pdfs/.gitignore | 2 + test/pdfs/bug1802506.pdf | Bin 0 -> 9380 bytes test/pdfs/checkbox_no_appearance.pdf | 30 +++ test/test_manifest.json | 18 ++ test/unit/annotation_spec.js | 46 ++++- web/annotation_layer_builder.css | 66 ++++--- web/annotation_layer_builder.js | 4 + web/pdf_page_detail_view.js | 1 + web/pdf_page_view.js | 11 ++ 16 files changed, 403 insertions(+), 174 deletions(-) create mode 100644 test/pdfs/bug1802506.pdf create mode 100644 test/pdfs/checkbox_no_appearance.pdf diff --git a/src/core/annotation.js b/src/core/annotation.js index 6b3d8e642..c397f68e8 100644 --- a/src/core/annotation.js +++ b/src/core/annotation.js @@ -785,6 +785,14 @@ class Annotation { this._needAppearances = false; } + _getOperatorListNoAppearance() { + return { + opList: new OperatorList(), + separateForm: false, + separateCanvas: false, + }; + } + /** * @private */ @@ -1213,28 +1221,32 @@ class Annotation { return resources; } + // Whether the annotation should only be rendered on its own canvas when + // interactive forms are enabled. This is the case for checkbox/radio button + // widgets, whose checked/unchecked appearances are toggled in forms mode; + // other annotations (e.g. push buttons) keep their own canvas in any display + // mode. + get _ownCanvasRequiresForms() { + return false; + } + async getOperatorList(evaluator, task, intent, annotationStorage) { const { hasOwnCanvas, id, rect } = this.data; let appearance = this.appearance; const isUsingOwnCanvas = !!( - hasOwnCanvas && intent & RenderingIntentFlag.DISPLAY + hasOwnCanvas && + intent & RenderingIntentFlag.DISPLAY && + (!this._ownCanvasRequiresForms || + intent & RenderingIntentFlag.ANNOTATIONS_FORMS) ); if (isUsingOwnCanvas && (this.width === 0 || this.height === 0)) { // Empty annotation, don't draw anything. this.data.hasOwnCanvas = false; - return { - opList: new OperatorList(), - separateForm: false, - separateCanvas: false, - }; + return this._getOperatorListNoAppearance(); } if (!appearance) { if (!isUsingOwnCanvas) { - return { - opList: new OperatorList(), - separateForm: false, - separateCanvas: false, - }; + return this._getOperatorListNoAppearance(); } appearance = new StringStream("", new Dict()); } @@ -1244,7 +1256,12 @@ class Annotation { RESOURCES_KEYS_OPERATOR_LIST, appearance ); - const bbox = lookupRect(appearanceDict.getArray("BBox"), [0, 0, 1, 1]); + const bbox = lookupRect(appearanceDict.getArray("BBox"), [ + 0, + 0, + this.width, + this.height, + ]); const matrix = lookupMatrix( appearanceDict.getArray("Matrix"), IDENTITY_MATRIX @@ -2092,11 +2109,9 @@ class WidgetAnnotation extends Annotation { !this.data.noHTML && !this.data.hasOwnCanvas ) { - return { - opList: new OperatorList(), - separateForm: true, - separateCanvas: false, - }; + const list = this._getOperatorListNoAppearance(); + list.separateForm = true; + return list; } if (!this._hasText) { @@ -3148,20 +3163,58 @@ class ButtonWidgetAnnotation extends WidgetAnnotation { this.data.radioButton = isRadio && !isPushButton; this.data.pushButton = isPushButton; this.data.isTooltipOnly = false; + this.data.hasOwnCanvas = true; + this.data.noHTML = false; if (this.data.checkBox) { this._processCheckBox(params); } else if (this.data.radioButton) { this._processRadioButton(params); } else if (this.data.pushButton) { - this.data.hasOwnCanvas = true; - this.data.noHTML = false; this._processPushButton(params); } else { warn("Invalid field flags for button widget annotation"); } } + get _ownCanvasRequiresForms() { + return this.data.checkBox || this.data.radioButton; + } + + #getOperatorListForAppearance( + evaluator, + task, + intent, + annotationStorage, + rotation, + appearance + ) { + if (!appearance) { + return this._getOperatorListNoAppearance(); + } + + const savedAppearance = this.appearance; + const savedMatrix = lookupMatrix( + appearance.dict.getArray("Matrix"), + IDENTITY_MATRIX + ); + + if (rotation) { + appearance.dict.set("Matrix", this.getRotationMatrix(annotationStorage)); + } + + this.appearance = appearance; + const operatorList = super.getOperatorList( + evaluator, + task, + intent, + annotationStorage + ); + this.appearance = savedAppearance; + appearance.dict.set("Matrix", savedMatrix); + return operatorList; + } + async getOperatorList(evaluator, task, intent, annotationStorage) { if (this.data.pushButton) { return super.getOperatorList( @@ -3173,6 +3226,45 @@ class ButtonWidgetAnnotation extends WidgetAnnotation { ); } + if ( + intent & RenderingIntentFlag.DISPLAY && + intent & RenderingIntentFlag.ANNOTATIONS_FORMS && + (this.data.checkBox || this.data.radioButton) + ) { + // Tag the dedicated canvas with the state it represents. The appearance + // may start with other operators (e.g. an optional content marked-content + // sequence), so target the `beginAnnotation` operator directly rather + // than assuming it's the first one. + const setCanvasName = (operatorList, name) => { + const index = operatorList.fnArray.indexOf(OPS.beginAnnotation); + if (index !== -1) { + operatorList.argsArray[index].push(name); + } + }; + const checked = await this.#getOperatorListForAppearance( + evaluator, + task, + intent, + annotationStorage, + null, + this.checkedAppearance + ); + setCanvasName(checked.opList, "checked"); + const unchecked = await this.#getOperatorListForAppearance( + evaluator, + task, + intent, + annotationStorage, + null, + this.uncheckedAppearance + ); + setCanvasName(unchecked.opList, "unchecked"); + checked.opList.addOpList(unchecked.opList); + checked.separateForm ||= unchecked.separateForm; + checked.separateCanvas ||= unchecked.separateCanvas; + return checked; + } + let value = null; let rotation = null; if (annotationStorage) { @@ -3195,41 +3287,14 @@ class ButtonWidgetAnnotation extends WidgetAnnotation { : this.data.fieldValue === this.data.buttonValue; } - const appearance = value - ? this.checkedAppearance - : this.uncheckedAppearance; - if (appearance) { - const savedAppearance = this.appearance; - const savedMatrix = lookupMatrix( - appearance.dict.getArray("Matrix"), - IDENTITY_MATRIX - ); - - if (rotation) { - appearance.dict.set( - "Matrix", - this.getRotationMatrix(annotationStorage) - ); - } - - this.appearance = appearance; - const operatorList = super.getOperatorList( - evaluator, - task, - intent, - annotationStorage - ); - this.appearance = savedAppearance; - appearance.dict.set("Matrix", savedMatrix); - return operatorList; - } - - // No appearance - return { - opList: new OperatorList(), - separateForm: false, - separateCanvas: false, - }; + return this.#getOperatorListForAppearance( + evaluator, + task, + intent, + annotationStorage, + rotation, + value ? this.checkedAppearance : this.uncheckedAppearance + ); } async save(evaluator, task, annotationStorage, changes) { @@ -3422,13 +3487,11 @@ class ButtonWidgetAnnotation extends WidgetAnnotation { _processCheckBox(params) { const customAppearance = params.dict.get("AP"); - if (!(customAppearance instanceof Dict)) { - return; - } - - const normalAppearance = customAppearance.get("N"); + let normalAppearance = + customAppearance instanceof Dict ? customAppearance.get("N") : null; if (!(normalAppearance instanceof Dict)) { - return; + // Synthesize a default appearance below when the field defines none. + normalAppearance = null; } // See https://bugzilla.mozilla.org/show_bug.cgi?id=1722036. @@ -3444,7 +3507,9 @@ class ButtonWidgetAnnotation extends WidgetAnnotation { : "Yes"; // Don't decode the keys which are names. - const exportValues = [...normalAppearance.getKeys()]; + const exportValues = normalAppearance + ? [...normalAppearance.getKeys()] + : []; if (exportValues.length === 0) { exportValues.push("Off", yes); } else if (exportValues.length === 1) { @@ -3470,10 +3535,10 @@ class ButtonWidgetAnnotation extends WidgetAnnotation { this.data.exportValue = exportValues[1]; - const checkedAppearance = normalAppearance.get(this.data.exportValue); + const checkedAppearance = normalAppearance?.get(this.data.exportValue); this.checkedAppearance = checkedAppearance instanceof BaseStream ? checkedAppearance : null; - const uncheckedAppearance = normalAppearance.get("Off"); + const uncheckedAppearance = normalAppearance?.get("Off"); this.uncheckedAppearance = uncheckedAppearance instanceof BaseStream ? uncheckedAppearance : null; diff --git a/src/display/annotation_layer.js b/src/display/annotation_layer.js index 00408d9f0..e10c89f62 100644 --- a/src/display/annotation_layer.js +++ b/src/display/annotation_layer.js @@ -439,9 +439,6 @@ class AnnotationElement { if (horizontalRadius > 0 || verticalRadius > 0) { const radius = `calc(${horizontalRadius}px * var(--total-scale-factor)) / calc(${verticalRadius}px * var(--total-scale-factor))`; style.borderRadius = radius; - } else if (this instanceof RadioButtonWidgetAnnotationElement) { - const radius = `calc(${width}px * var(--total-scale-factor)) / calc(${height}px * var(--total-scale-factor))`; - style.borderRadius = radius; } switch (data.borderStyle.style) { @@ -2009,7 +2006,6 @@ class CheckboxWidgetAnnotationElement extends WidgetAnnotationElement { ); } - this._setBackgroundColor(element); this._setDefaultPropertiesFromJS(element); this.container.append(element); @@ -2028,7 +2024,9 @@ class RadioButtonWidgetAnnotationElement extends WidgetAnnotationElement { const data = this.data; const id = data.id; let value = storage.getValue(id, { - value: data.fieldValue === data.buttonValue, + // A radio without an on-state (`buttonValue === null`, e.g. no /AP) must + // not be checked by default, otherwise `null === null` would select it. + value: data.buttonValue !== null && data.fieldValue === data.buttonValue, }).value; if (typeof value === "string") { // The value has been changed through js and set in annotationStorage. @@ -2115,7 +2113,6 @@ class RadioButtonWidgetAnnotationElement extends WidgetAnnotationElement { ); } - this._setBackgroundColor(element); this._setDefaultPropertiesFromJS(element); this.container.append(element); @@ -4139,18 +4136,44 @@ class AnnotationLayer { if (!element) { continue; } - - canvas.className = "annotationContent"; + if (Array.isArray(canvas)) { + for (const cvs of canvas) { + cvs.className = "annotationContent"; + cvs.ariaHidden = true; + } + } else { + canvas.className = "annotationContent"; + canvas.ariaHidden = true; + } + const toRemove = []; + for (const child of element.children) { + if (child.nodeName === "CANVAS") { + toRemove.push(child); + } + } + for (const child of toRemove) { + child.remove(); + } + const firstCanvas = Array.isArray(canvas) ? canvas[0] : canvas; const { firstChild } = element; if (!firstChild) { - element.append(canvas); - } else if (firstChild.nodeName === "CANVAS") { - firstChild.replaceWith(canvas); + element.append(firstCanvas); } else if (!firstChild.classList.contains("annotationContent")) { - firstChild.before(canvas); + firstChild.before(firstCanvas); } else { - firstChild.after(canvas); + firstChild.after(firstCanvas); } + if (Array.isArray(canvas)) { + let lastCanvas = firstCanvas; + for (let i = 1, ii = canvas.length; i < ii; i++) { + lastCanvas.after(canvas[i]); + lastCanvas = canvas[i]; + } + } + // Drop only the entries we inserted; keep ones whose element isn't in the + // DOM yet so a later refresh can still pick them up instead of losing + // them. + this.#annotationCanvasMap.delete(id); const editableAnnotation = this.#editableAnnotations.get(id); if (!editableAnnotation) { @@ -4168,7 +4191,12 @@ class AnnotationLayer { editableAnnotation.canvas = canvas; } } - this.#annotationCanvasMap.clear(); + } + + // Move any pending annotation canvases (e.g. higher-resolution ones rendered + // by the detail view) into their elements. + refreshCanvases() { + this.#setAnnotationCanvasMap(); } getEditableAnnotations() { diff --git a/src/display/canvas.js b/src/display/canvas.js index 8561a4939..b8d8fecb8 100644 --- a/src/display/canvas.js +++ b/src/display/canvas.js @@ -3630,7 +3630,15 @@ class CanvasGraphics { } } - beginAnnotation(opIdx, id, rect, transform, matrix, hasOwnCanvas) { + beginAnnotation( + opIdx, + id, + rect, + transform, + matrix, + hasOwnCanvas, + canvasName + ) { // The annotations are drawn just after the page content. // The page content drawing can potentially have set a transform, // a clipping path, whatever... @@ -3673,7 +3681,26 @@ class CanvasGraphics { canvasHeight ); const { canvas, context } = this.annotationCanvas; - this.annotationCanvasMap.set(id, canvas); + if (canvasName) { + let canvases = this.annotationCanvasMap.get(id); + if (!canvases) { + canvases = []; + this.annotationCanvasMap.set(id, canvases); + } + canvas.setAttribute("data-canvas-name", canvasName); + // Replace any same-named canvas from a previous render so stale + // low-resolution canvases don't pile up across zooms. + const index = canvases.findIndex( + c => c.getAttribute("data-canvas-name") === canvasName + ); + if (index === -1) { + canvases.push(canvas); + } else { + canvases[index] = canvas; + } + } else { + this.annotationCanvasMap.set(id, canvas); + } this.annotationCanvas.savedCtx = this.ctx; this.ctx = context; this.ctx.save(); diff --git a/test/annotation_layer_builder_overrides.css b/test/annotation_layer_builder_overrides.css index 8bcf91d0c..59d88e6f5 100644 --- a/test/annotation_layer_builder_overrides.css +++ b/test/annotation_layer_builder_overrides.css @@ -68,4 +68,26 @@ color: red; font-size: 10px; } + + .buttonWidgetAnnotation:is(.checkBox, .radioButton) { + img[data-canvas-name="checked"] { + &:has(~ input:checked) { + display: block; + } + + &:has(~ input:not(:checked)) { + display: none; + } + } + + img[data-canvas-name="unchecked"] { + &:has(~ input:checked) { + display: none; + } + + &:has(~ input:not(:checked)) { + display: block; + } + } + } } diff --git a/test/driver.js b/test/driver.js index 0ac1b7374..473f1ef81 100644 --- a/test/driver.js +++ b/test/driver.js @@ -94,6 +94,7 @@ async function writeSVG(svgElement, ctx) { setTimeout(resolve, 10); }); } + return loadImage(svg_xml, ctx); } @@ -150,21 +151,40 @@ async function inlineImages(node, silentErrors = false) { async function convertCanvasesToImages(annotationCanvasMap, outputScale) { const results = new Map(); const promises = []; + const canvasToImage = (canvas, key) => { + const { promise, resolve } = Promise.withResolvers(); + promises.push(promise); + canvas.toBlob(blob => { + const image = document.createElement("img"); + image.classList.add("wasCanvas"); + image.onload = function () { + image.style.width = Math.floor(image.width / outputScale) + "px"; + resolve(); + }; + const canvasName = canvas.getAttribute("data-canvas-name"); + if (canvasName) { + image.setAttribute("data-canvas-name", canvasName); + let images = results.get(key); + if (!images) { + images = []; + results.set(key, images); + } + images.push(image); + } else { + results.set(key, image); + } + image.src = URL.createObjectURL(blob); + }); + }; + for (const [key, canvas] of annotationCanvasMap) { - promises.push( - new Promise(resolve => { - canvas.toBlob(blob => { - const image = document.createElement("img"); - image.classList.add("wasCanvas"); - image.onload = function () { - image.style.width = Math.floor(image.width / outputScale) + "px"; - resolve(); - }; - results.set(key, image); - image.src = URL.createObjectURL(blob); - }); - }) - ); + if (Array.isArray(canvas)) { + for (const canvasItem of canvas) { + canvasToImage(canvasItem, key); + } + } else { + canvasToImage(canvas, key); + } } await Promise.all(promises); return results; diff --git a/test/integration/annotation_spec.mjs b/test/integration/annotation_spec.mjs index 535f68c6a..e448e864c 100644 --- a/test/integration/annotation_spec.mjs +++ b/test/integration/annotation_spec.mjs @@ -122,12 +122,12 @@ describe("Checkbox annotation", () => { for (const selector of selectors) { await page.click(selector); await page.waitForFunction( - `document.querySelector('${selector} > :first-child').checked` + `document.querySelector('${selector} input').checked` ); for (const otherSelector of selectors) { const checked = await page.$eval( - `${otherSelector} > :first-child`, + `${otherSelector} input`, el => el.checked ); expect(checked) @@ -157,7 +157,7 @@ describe("Checkbox annotation", () => { const selector = getAnnotationSelector("7R"); await page.click(selector); await page.waitForFunction( - `document.querySelector('${selector} > :first-child').checked` + `document.querySelector('${selector} input').checked` ); expect(true).withContext(`In ${browserName}`).toEqual(true); }) @@ -185,7 +185,7 @@ describe("Checkbox annotation", () => { for (const selector of selectors) { await page.click(selector); await page.waitForFunction( - `document.querySelector('${selector} > :first-child').checked` + `document.querySelector('${selector} input').checked` ); } }) diff --git a/test/integration/scripting_spec.mjs b/test/integration/scripting_spec.mjs index 82d38af8e..9ed732450 100644 --- a/test/integration/scripting_spec.mjs +++ b/test/integration/scripting_spec.mjs @@ -2413,49 +2413,6 @@ describe("Interaction", () => { }); }); - describe("Change radio property", () => { - let pages; - - beforeEach(async () => { - pages = await loadAndWait("bug1922766.pdf", getAnnotationSelector("44R")); - }); - - afterEach(async () => { - await closePages(pages); - }); - - it("must check that a change on a radio implies the change on all the radio in the group", async () => { - await Promise.all( - pages.map(async ([browserName, page]) => { - await waitForScripting(page); - - const checkColor = async color => { - await waitForSandboxTrip(page); - for (const i of [40, 41, 42, 43]) { - const bgColor = await page.$eval( - `[data-element-id='${i}R']`, - el => getComputedStyle(el).backgroundColor - ); - expect(bgColor) - .withContext(`In ${browserName}`) - .toEqual(`rgb(${color.join(", ")})`); - } - }; - await checkColor([255, 0, 0]); - await page.click(getAnnotationSelector("44R")); - await checkColor([0, 0, 255]); - await page.click(getAnnotationSelector("44R")); - await checkColor([255, 0, 0]); - - await page.click(getAnnotationSelector("43R")); - await waitForSandboxTrip(page); - await page.click(getAnnotationSelector("44R")); - await checkColor([0, 0, 255]); - }) - ); - }); - }); - describe("Date creation must be timezone consistent", () => { let pages; diff --git a/test/pdfs/.gitignore b/test/pdfs/.gitignore index 4d2c9f005..bf14c7859 100644 --- a/test/pdfs/.gitignore +++ b/test/pdfs/.gitignore @@ -932,3 +932,5 @@ !cidfont_cmap_overflow.pdf !jbig2_file_header.pdf !text_field_own_canvas_calc.pdf +!bug1802506.pdf +!checkbox_no_appearance.pdf diff --git a/test/pdfs/bug1802506.pdf b/test/pdfs/bug1802506.pdf new file mode 100644 index 0000000000000000000000000000000000000000..aa2355bbfeda8b8c2b0eeb6c08d775d1194db7cf GIT binary patch literal 9380 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 literal 0 HcmV?d00001 diff --git a/test/pdfs/checkbox_no_appearance.pdf b/test/pdfs/checkbox_no_appearance.pdf new file mode 100644 index 000000000..416e6aaef --- /dev/null +++ b/test/pdfs/checkbox_no_appearance.pdf @@ -0,0 +1,30 @@ +%PDF-1.7 +%ÿÿÿÿ +1 0 obj +<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] /NeedAppearances true >> >> +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 200 200] /Annots [4 0 R 5 0 R] /Resources << >> >> +endobj +4 0 obj +<< /Type /Annot /Subtype /Widget /FT /Btn /T (cbOn) /Rect [30 140 70 180] /V /Yes /AS /Yes >> +endobj +5 0 obj +<< /Type /Annot /Subtype /Widget /FT /Btn /T (cbOff) /Rect [90 140 130 180] /V /Off /AS /Off >> +endobj +xref +0 6 +0000000000 65535 f +0000000015 00000 n +0000000124 00000 n +0000000181 00000 n +0000000291 00000 n +0000000400 00000 n +trailer +<< /Size 6 /Root 1 0 R >> +startxref +511 +%%EOF diff --git a/test/test_manifest.json b/test/test_manifest.json index aef2038d3..ec78fd257 100644 --- a/test/test_manifest.json +++ b/test/test_manifest.json @@ -14380,5 +14380,23 @@ "rounds": 1, "link": true, "type": "eq" + }, + { + "id": "bug1802506", + "file": "pdfs/bug1802506.pdf", + "md5": "ed56da1780b8480262c7329c4419fbb5", + "rounds": 1, + "type": "eq", + "annotations": true, + "forms": true + }, + { + "id": "checkbox_no_appearance", + "file": "pdfs/checkbox_no_appearance.pdf", + "md5": "216ff8ea24c470421ff1c528d805caea", + "rounds": 1, + "type": "eq", + "annotations": true, + "forms": true } ] diff --git a/test/unit/annotation_spec.js b/test/unit/annotation_spec.js index cf622848e..7e3ca6658 100644 --- a/test/unit/annotation_spec.js +++ b/test/unit/annotation_spec.js @@ -1776,7 +1776,7 @@ describe("annotation", function () { expect(opList.argsArray[0]).toEqual([ "271R", [0, 0, 32, 10], - [32, 0, 0, 10, 0, 0], + [1, 0, 0, 1, 0, 0], [1, 0, 0, 1, 0, 0], false, ]); @@ -2607,6 +2607,44 @@ describe("annotation", function () { expect(opList.argsArray[3][0][0].unicode).toEqual("4"); }); + it("should synthesize a checked appearance for checkboxes without an /AP", async function () { + buttonWidgetDict.set("V", Name.get("Checked")); + buttonWidgetDict.set("Rect", [0, 0, 20, 20]); + // Note: no /AP entry, so a default appearance must be synthesized. + + const buttonWidgetRef = Ref.get(124, 0); + const xref = new XRefMock([ + { ref: buttonWidgetRef, data: buttonWidgetDict }, + ]); + const task = new WorkerTask("test checkbox without /AP"); + const checkboxEvaluator = partialEvaluator.clone({ ignoreErrors: true }); + const annotation = await AnnotationFactory.create( + xref, + buttonWidgetRef, + annotationGlobalsMock, + idFactoryMock + ); + + expect(annotation.data.checkBox).toEqual(true); + expect(annotation.data.exportValue).toEqual("Checked"); + + const { opList } = await annotation.getOperatorList( + checkboxEvaluator, + task, + RenderingIntentFlag.DISPLAY | RenderingIntentFlag.ANNOTATIONS_FORMS, + new Map() + ); + + // A checkmark is drawn on its own dedicated "checked" canvas. + expect(opList.fnArray[0]).toEqual(OPS.beginAnnotation); + expect(opList.fnArray.at(-1)).toEqual(OPS.endAnnotation); + expect(opList.fnArray).toContain(OPS.showText); + const [id, , , , isUsingOwnCanvas, canvasName] = opList.argsArray[0]; + expect(id).toEqual("124R"); + expect(isUsingOwnCanvas).toEqual(true); + expect(canvasName).toEqual("checked"); + }); + it("should render checkboxes for printing", async function () { const appearanceStatesDict = new Dict(); const normalAppearanceDict = new Dict(); @@ -2685,7 +2723,7 @@ describe("annotation", function () { expect(opList2.argsArray[0]).toEqual([ "124R", [0, 0, 0, 0], - [0, 0, 0, 0, 0, 0], + [1, 0, 0, 1, 0, 0], [1, 0, 0, 1, 0, 0], false, ]); @@ -3084,7 +3122,7 @@ describe("annotation", function () { expect(opList2.argsArray[0]).toEqual([ "124R", [0, 0, 0, 0], - [0, 0, 0, 0, 0, 0], + [1, 0, 0, 1, 0, 0], [1, 0, 0, 1, 0, 0], false, ]); @@ -3147,7 +3185,7 @@ describe("annotation", function () { expect(opList.argsArray[0]).toEqual([ "124R", [0, 0, 0, 0], - [0, 0, 0, 0, 0, 0], + [1, 0, 0, 1, 0, 0], [1, 0, 0, 1, 0, 0], false, ]); diff --git a/web/annotation_layer_builder.css b/web/annotation_layer_builder.css index 833dc173e..bf4826a60 100644 --- a/web/annotation_layer_builder.css +++ b/web/annotation_layer_builder.css @@ -17,6 +17,7 @@ color-scheme: only light; --annotation-unfocused-field-background: url("data:image/svg+xml;charset=UTF-8,"); + --annotation-unfocused-field-filter: url("data:image/svg+xml;charset=UTF-8,#pdfjsFillableField"); --input-focus-border-color: Highlight; --input-focus-outline: 1px solid Canvas; --input-unfocused-border-color: transparent; @@ -208,10 +209,6 @@ padding: 0; } - .buttonWidgetAnnotation.radioButton input { - border-radius: 50%; - } - .textWidgetAnnotation textarea { resize: none; } @@ -259,36 +256,34 @@ outline: var(--input-focus-outline); } - .buttonWidgetAnnotation.checkBox input:checked::before, - .buttonWidgetAnnotation.checkBox input:checked::after, - .buttonWidgetAnnotation.radioButton input:checked::before { - background-color: CanvasText; - content: ""; - display: block; - position: absolute; - } + .buttonWidgetAnnotation:is(.checkBox, .radioButton) { + [data-canvas-name] { + filter: var(--annotation-unfocused-field-filter); + } - .buttonWidgetAnnotation.checkBox input:checked::before, - .buttonWidgetAnnotation.checkBox input:checked::after { - height: 80%; - left: 45%; - width: 1px; - } + &:focus-within [data-canvas-name] { + filter: none; + } - .buttonWidgetAnnotation.checkBox input:checked::before { - transform: rotate(45deg); - } + [data-canvas-name="checked"] { + &:has(~ input:checked) { + display: block; + } - .buttonWidgetAnnotation.checkBox input:checked::after { - transform: rotate(-45deg); - } + &:has(~ input:not(:checked)) { + display: none; + } + } - .buttonWidgetAnnotation.radioButton input:checked::before { - border-radius: 50%; - height: 50%; - left: 25%; - top: 25%; - width: 50%; + [data-canvas-name="unchecked"] { + &:has(~ input:checked) { + display: none; + } + + &:has(~ input:not(:checked)) { + display: block; + } + } } .textWidgetAnnotation input.comb { @@ -317,6 +312,17 @@ appearance: none; } + .buttonWidgetAnnotation:is(.checkBox, .radioButton):has( + [data-canvas-name="checked"] + ) + input:checked, + .buttonWidgetAnnotation:is(.checkBox, .radioButton):has( + [data-canvas-name="unchecked"] + ) + input:not(:checked) { + background-image: none; + } + .fileAttachmentAnnotation .popupTriggerArea { height: 100%; width: 100%; diff --git a/web/annotation_layer_builder.js b/web/annotation_layer_builder.js index bb30dd6bb..4139210fa 100644 --- a/web/annotation_layer_builder.js +++ b/web/annotation_layer_builder.js @@ -224,6 +224,10 @@ class AnnotationLayerBuilder { this.#eventAC = null; } + refreshCanvases() { + this.annotationLayer?.refreshCanvases(); + } + hide() { if (!this.div) { return; diff --git a/web/pdf_page_detail_view.js b/web/pdf_page_detail_view.js index eda8d5cea..3b975f383 100644 --- a/web/pdf_page_detail_view.js +++ b/web/pdf_page_detail_view.js @@ -297,6 +297,7 @@ class PDFPageDetailView extends BasePDFPageView { this.canvas = prevCanvas; }, () => { + this.pageView._refreshAnnotationLayer(); this.dispatchPageRendered( /* cssTransform */ false, /* isDetailView */ true diff --git a/web/pdf_page_view.js b/web/pdf_page_view.js index 58958f3d1..3ee59520c 100644 --- a/web/pdf_page_view.js +++ b/web/pdf_page_view.js @@ -484,6 +484,17 @@ class PDFPageView extends BasePDFPageView { } } + // The detail view re-renders checkbox/radio appearances at a higher + // resolution into the shared `annotationCanvasMap`; move them into the + // annotation layer so they replace the lower-resolution ones. This only + // consumes pending canvases (no re-render), and is a no-op until their + // elements exist, so it's safe to call while the layer is still rendering. + _refreshAnnotationLayer() { + if (this._annotationCanvasMap?.size) { + this.annotationLayer?.refreshCanvases(); + } + } + async #renderAnnotationEditorLayer() { let error = null; try {