From b9b6b20c8f3f619bd1508d31de2148d3ea76adce 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 | 144 +++++++++++++------- src/display/annotation_layer.js | 39 ++++-- src/display/canvas.js | 14 +- test/annotation_layer_builder_overrides.css | 22 +++ test/driver.js | 48 +++++-- test/pdfs/.gitignore | 1 + test/pdfs/bug1802506.pdf | Bin 0 -> 9380 bytes test/test_manifest.json | 10 ++ web/annotation_layer_builder.css | 48 +++---- 9 files changed, 216 insertions(+), 110 deletions(-) create mode 100755 test/pdfs/bug1802506.pdf diff --git a/src/core/annotation.js b/src/core/annotation.js index fe48f13872d52..da001504dc6e3 100644 --- a/src/core/annotation.js +++ b/src/core/annotation.js @@ -725,6 +725,14 @@ class Annotation { this._needAppearances = false; } + _getOperatorListNoAppearance() { + return { + opList: new OperatorList(), + separateForm: false, + separateCanvas: false, + }; + } + /** * @private */ @@ -1155,24 +1163,18 @@ class Annotation { const { hasOwnCanvas, id, rect } = this.data; let appearance = this.appearance; const isUsingOwnCanvas = !!( - hasOwnCanvas && intent & RenderingIntentFlag.DISPLAY + hasOwnCanvas && + intent & RenderingIntentFlag.DISPLAY && + intent & RenderingIntentFlag.ANNOTATIONS_FORMS ); if (isUsingOwnCanvas && (rect[0] === rect[2] || rect[1] === rect[3])) { // 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(""); appearance.dict = new Dict(); @@ -2020,11 +2022,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) { @@ -2994,20 +2994,54 @@ class ButtonWidgetAnnotation extends WidgetAnnotation { !this.hasFieldFlag(AnnotationFieldFlag.PUSHBUTTON); this.data.pushButton = this.hasFieldFlag(AnnotationFieldFlag.PUSHBUTTON); 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"); } } + #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( @@ -3019,6 +3053,37 @@ class ButtonWidgetAnnotation extends WidgetAnnotation { ); } + if ( + intent & RenderingIntentFlag.DISPLAY && + intent & RenderingIntentFlag.ANNOTATIONS_FORMS && + (this.data.checkBox || this.data.radioButton) + ) { + const checked = await this.#getOperatorListForAppearance( + evaluator, + task, + intent, + annotationStorage, + null, + this.checkedAppearance + ); + if (checked.opList.argsArray?.[0]) { + checked.opList.argsArray[0].push("checked"); + } + const unchecked = await this.#getOperatorListForAppearance( + evaluator, + task, + intent, + annotationStorage, + null, + this.uncheckedAppearance + ); + if (unchecked.opList.argsArray?.[0]) { + unchecked.opList.argsArray[0].push("unchecked"); + } + checked.opList.addOpList(unchecked.opList); + return checked; + } + let value = null; let rotation = null; if (annotationStorage) { @@ -3041,41 +3106,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) { diff --git a/src/display/annotation_layer.js b/src/display/annotation_layer.js index 955b16f363303..0f659e91a8136 100644 --- a/src/display/annotation_layer.js +++ b/src/display/annotation_layer.js @@ -311,9 +311,6 @@ class AnnotationElement { if (horizontalRadius > 0 || verticalRadius > 0) { const radius = `calc(${horizontalRadius}px * var(--scale-factor)) / calc(${verticalRadius}px * var(--scale-factor))`; style.borderRadius = radius; - } else if (this instanceof RadioButtonWidgetAnnotationElement) { - const radius = `calc(${width}px * var(--scale-factor)) / calc(${height}px * var(--scale-factor))`; - style.borderRadius = radius; } switch (data.borderStyle.style) { @@ -3240,17 +3237,39 @@ 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]; + } } } this.#annotationCanvasMap.clear(); diff --git a/src/display/canvas.js b/src/display/canvas.js index 13f0790a9123b..1d77405aa801f 100644 --- a/src/display/canvas.js +++ b/src/display/canvas.js @@ -2646,7 +2646,7 @@ class CanvasGraphics { } } - beginAnnotation(id, rect, transform, matrix, hasOwnCanvas) { + beginAnnotation(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... @@ -2691,7 +2691,17 @@ 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); + canvases.push(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 8bcf91d0c95ee..59d88e6f58103 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 8ba30b0bfd3ce..5869c1f4e3dd0 100644 --- a/test/driver.js +++ b/test/driver.js @@ -88,6 +88,7 @@ async function writeSVG(svgElement, ctx) { setTimeout(resolve, 10); }); } + return loadImage(svg_xml, ctx); } @@ -144,21 +145,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/pdfs/.gitignore b/test/pdfs/.gitignore index fa61a789c7d19..89f40b441fe66 100644 --- a/test/pdfs/.gitignore +++ b/test/pdfs/.gitignore @@ -676,3 +676,4 @@ !issue15096.pdf !issue18036.pdf !issue18894.pdf +!bug1802506.pdf diff --git a/test/pdfs/bug1802506.pdf b/test/pdfs/bug1802506.pdf new file mode 100755 index 0000000000000000000000000000000000000000..aa2355bbfeda8b8c2b0eeb6c08d775d1194db7cf GIT binary patch literal 9380 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 literal 0 HcmV?d00001 diff --git a/test/test_manifest.json b/test/test_manifest.json index dcf9307fbf03c..cfe7bed66a30b 100644 --- a/test/test_manifest.json +++ b/test/test_manifest.json @@ -10709,5 +10709,15 @@ "type": "eq", "link": true, "talos": false + }, + { + "id": "bug1802506", + "file": "pdfs/bug1802506.pdf", + "md5": "ed56da1780b8480262c7329c4419fbb5", + "rounds": 1, + "type": "eq", + "annotations": true, + "forms": true, + "talos": false } ] diff --git a/web/annotation_layer_builder.css b/web/annotation_layer_builder.css index 3047adbb2ecfd..46e5d889b6f04 100644 --- a/web/annotation_layer_builder.css +++ b/web/annotation_layer_builder.css @@ -186,10 +186,6 @@ padding: 0; } - .buttonWidgetAnnotation.radioButton input { - border-radius: 50%; - } - .textWidgetAnnotation textarea { resize: none; } @@ -237,36 +233,26 @@ 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.checkBox input:checked::before, - .buttonWidgetAnnotation.checkBox input:checked::after { - height: 80%; - left: 45%; - width: 1px; - } + .buttonWidgetAnnotation:is(.checkBox, .radioButton) { + canvas[data-canvas-name="checked"] { + &:has(~ input:checked) { + display: block; + } - .buttonWidgetAnnotation.checkBox input:checked::before { - transform: rotate(45deg); - } + &:has(~ input:not(:checked)) { + display: none; + } + } - .buttonWidgetAnnotation.checkBox input:checked::after { - transform: rotate(-45deg); - } + canvas[data-canvas-name="unchecked"] { + &:has(~ input:checked) { + display: none; + } - .buttonWidgetAnnotation.radioButton input:checked::before { - border-radius: 50%; - height: 50%; - left: 25%; - top: 25%; - width: 50%; + &:has(~ input:not(:checked)) { + display: block; + } + } } .textWidgetAnnotation input.comb {