diff --git a/src/core/annotation.js b/src/core/annotation.js index 2befa96e9..292fb10c5 100644 --- a/src/core/annotation.js +++ b/src/core/annotation.js @@ -76,6 +76,7 @@ import { FileSpec } from "./file_spec.js"; import { JpegStream } from "./jpeg_stream.js"; import { ObjectLoader } from "./object_loader.js"; import { OperatorList } from "./operator_list.js"; +import { parseMarkedContentProps } from "./evaluator_utils.js"; import { XFAFactory } from "./xfa/factory.js"; class AnnotationFactory { @@ -663,6 +664,8 @@ function getTransformMatrix(rect, bbox, matrix) { } class Annotation { + _oc = undefined; + constructor(params) { const { annotationGlobals, dict, orphanFields, ref, subtype, xref } = params; @@ -679,7 +682,7 @@ class Annotation { this.setColor(dict.getArray("C")); this.setBorderStyle(dict); this.setAppearance(dict); - this.setOptionalContent(dict); + this.#setOptionalContent(xref, dict); const MK = dict.get("MK"); this.setBorderAndBackgroundColors(MK); @@ -710,6 +713,7 @@ class Annotation { hasAppearance: !!this.appearance, id: params.id, modificationDate: this.modificationDate, + oc: this._oc, rect: this.rectangle, subtype, hasOwnCanvas: false, @@ -1169,14 +1173,17 @@ class Annotation { } } - setOptionalContent(dict) { - this.oc = null; - - const oc = dict.get("OC"); - if (oc instanceof Name) { - warn("setOptionalContent: Support for /Name-entry is not implemented."); - } else if (oc instanceof Dict) { - this.oc = oc; + #setOptionalContent(xref, dict) { + if (dict.has("OC")) { + try { + this._oc = parseMarkedContentProps( + xref, + dict.get("OC"), + /* resources = */ null + ); + } catch (ex) { + warn(`#setOptionalContent: ${ex}`); + } } } @@ -1229,13 +1236,7 @@ class Annotation { const opList = new OperatorList(); - let optionalContent; - if (this.oc) { - optionalContent = await evaluator.parseMarkedContentProps( - this.oc, - /* resources = */ null - ); - } + const optionalContent = this._oc; if (optionalContent !== undefined) { opList.addOp(OPS.beginMarkedContentProps, ["OC", optionalContent]); } @@ -2110,13 +2111,7 @@ class WidgetAnnotation extends Annotation { const bbox = [0, 0, this.width, this.height]; const transform = getTransformMatrix(this.data.rect, bbox, matrix); - let optionalContent; - if (this.oc) { - optionalContent = await evaluator.parseMarkedContentProps( - this.oc, - /* resources = */ null - ); - } + const optionalContent = this._oc; if (optionalContent !== undefined) { opList.addOp(OPS.beginMarkedContentProps, ["OC", optionalContent]); } diff --git a/src/core/evaluator.js b/src/core/evaluator.js index e1badcec3..1c9c229bc 100644 --- a/src/core/evaluator.js +++ b/src/core/evaluator.js @@ -87,6 +87,7 @@ import { getGlyphsUnicode } from "./glyphlist.js"; import { getMetrics } from "./metrics.js"; import { getUnicodeForGlyph } from "./unicode.js"; import { MurmurHash3_64 } from "../shared/murmurhash3.js"; +import { parseMarkedContentProps } from "./evaluator_utils.js"; import { PDFImage } from "./image.js"; import { Stream } from "./stream.js"; import { stringToPDFString } from "./string_utils.js"; @@ -1651,105 +1652,8 @@ class PartialEvaluator { throw new FormatError(`Unknown PatternName: ${patternName}`); } - _parseVisibilityExpression(array, nestingCounter, currentResult) { - const MAX_NESTING = 10; - if (++nestingCounter > MAX_NESTING) { - warn("Visibility expression is too deeply nested"); - return; - } - const length = array.length; - const operator = this.xref.fetchIfRef(array[0]); - if (length < 2 || !(operator instanceof Name)) { - warn("Invalid visibility expression"); - return; - } - switch (operator.name) { - case "And": - case "Or": - case "Not": - currentResult.push(operator.name); - break; - default: - warn(`Invalid operator ${operator.name} in visibility expression`); - return; - } - for (let i = 1; i < length; i++) { - const raw = array[i]; - const object = this.xref.fetchIfRef(raw); - if (Array.isArray(object)) { - const nestedResult = []; - currentResult.push(nestedResult); - // Recursively parse a subarray. - this._parseVisibilityExpression(object, nestingCounter, nestedResult); - } else if (raw instanceof Ref) { - // Reference to an OCG dictionary. - currentResult.push(raw.toString()); - } - } - } - async parseMarkedContentProps(contentProperties, resources) { - let optionalContent; - if (contentProperties instanceof Name) { - const properties = resources.get("Properties"); - optionalContent = properties.get(contentProperties.name); - } else if (contentProperties instanceof Dict) { - optionalContent = contentProperties; - } else { - throw new FormatError("Optional content properties malformed."); - } - - const optionalContentType = optionalContent.get("Type")?.name; - if (optionalContentType === "OCG") { - return { - type: optionalContentType, - id: optionalContent.objId, - }; - } else if (optionalContentType === "OCMD") { - const expression = optionalContent.get("VE"); - if (Array.isArray(expression)) { - const result = []; - this._parseVisibilityExpression(expression, 0, result); - if (result.length > 0) { - return { - type: "OCMD", - expression: result, - }; - } - } - - const optionalContentGroups = optionalContent.get("OCGs"); - if ( - Array.isArray(optionalContentGroups) || - optionalContentGroups instanceof Dict - ) { - const groupIds = []; - if (Array.isArray(optionalContentGroups)) { - for (const ocg of optionalContentGroups) { - groupIds.push(ocg.toString()); - } - } else { - // Dictionary, just use the obj id. - groupIds.push(optionalContentGroups.objId); - } - - return { - type: optionalContentType, - ids: groupIds, - policy: - optionalContent.get("P") instanceof Name - ? optionalContent.get("P").name - : null, - expression: null, - }; - } else if (optionalContentGroups instanceof Ref) { - return { - type: optionalContentType, - id: optionalContentGroups.toString(), - }; - } - } - return null; + return parseMarkedContentProps(this.xref, contentProperties, resources); } async getOperatorList({ diff --git a/src/core/evaluator_utils.js b/src/core/evaluator_utils.js new file mode 100644 index 000000000..b871bfa93 --- /dev/null +++ b/src/core/evaluator_utils.js @@ -0,0 +1,123 @@ +/* Copyright 2012 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Dict, Name, Ref } from "./primitives.js"; +import { FormatError, warn } from "../shared/util.js"; + +function _parseVisibilityExpression( + xref, + array, + nestingCounter, + currentResult +) { + const MAX_NESTING = 10; + if (++nestingCounter > MAX_NESTING) { + warn("Visibility expression is too deeply nested"); + return; + } + const length = array.length; + const operator = xref.fetchIfRef(array[0]); + if (length < 2 || !(operator instanceof Name)) { + warn("Invalid visibility expression"); + return; + } + switch (operator.name) { + case "And": + case "Or": + case "Not": + currentResult.push(operator.name); + break; + default: + warn(`Invalid operator ${operator.name} in visibility expression`); + return; + } + for (let i = 1; i < length; i++) { + const raw = array[i]; + const object = xref.fetchIfRef(raw); + if (Array.isArray(object)) { + const nestedResult = []; + currentResult.push(nestedResult); + // Recursively parse a subarray. + _parseVisibilityExpression(xref, object, nestingCounter, nestedResult); + } else if (raw instanceof Ref) { + // Reference to an OCG dictionary. + currentResult.push(raw.toString()); + } + } +} + +function parseMarkedContentProps(xref, contentProperties, resources) { + let optionalContent; + if (contentProperties instanceof Name) { + const properties = resources.get("Properties"); + optionalContent = properties.get(contentProperties.name); + } else if (contentProperties instanceof Dict) { + optionalContent = contentProperties; + } else { + throw new FormatError("Optional content properties malformed."); + } + + const optionalContentType = optionalContent.get("Type")?.name; + if (optionalContentType === "OCG") { + return { + type: optionalContentType, + id: optionalContent.objId, + }; + } else if (optionalContentType === "OCMD") { + const expression = optionalContent.get("VE"); + if (Array.isArray(expression)) { + const result = []; + _parseVisibilityExpression(xref, expression, 0, result); + if (result.length > 0) { + return { + type: "OCMD", + expression: result, + }; + } + } + + const optionalContentGroups = optionalContent.get("OCGs"); + if ( + Array.isArray(optionalContentGroups) || + optionalContentGroups instanceof Dict + ) { + const groupIds = []; + if (Array.isArray(optionalContentGroups)) { + for (const ocg of optionalContentGroups) { + groupIds.push(ocg.toString()); + } + } else { + // Dictionary, just use the obj id. + groupIds.push(optionalContentGroups.objId); + } + const p = optionalContent.get("P"); + + return { + type: optionalContentType, + ids: groupIds, + policy: p instanceof Name ? p.name : null, + expression: null, + }; + } else if (optionalContentGroups instanceof Ref) { + return { + type: optionalContentType, + id: optionalContentGroups.toString(), + }; + } + } + return null; +} + +export { parseMarkedContentProps }; diff --git a/src/display/annotation_layer.js b/src/display/annotation_layer.js index 4e0c840aa..ec2999b3b 100644 --- a/src/display/annotation_layer.js +++ b/src/display/annotation_layer.js @@ -16,6 +16,8 @@ /** @typedef {import("./api").PDFPageProxy} PDFPageProxy */ /** @typedef {import("./page_viewport").PageViewport} PageViewport */ // eslint-disable-next-line max-len +/** @typedef {import("../src/display/optional_content_config").OptionalContentConfig} OptionalContentConfig */ +// eslint-disable-next-line max-len /** @typedef {import("../../web/text_accessibility.js").TextAccessibilityManager} TextAccessibilityManager */ // eslint-disable-next-line max-len /** @typedef {import("../src/display/editor/tools.js").AnnotationEditorUIManager} AnnotationEditorUIManager */ @@ -886,6 +888,18 @@ class AnnotationElement { }); } + updateOC(optionalContentConfig) { + if (!this.data.oc || !optionalContentConfig) { + return; + } + const isVisible = optionalContentConfig.isVisible(this.data.oc); + if (isVisible) { + this.show(); + } else { + this.hide(); + } + } + get width() { return this.data.rect[2] - this.data.rect[0]; } @@ -3755,7 +3769,7 @@ class FileAttachmentAnnotationElement extends AnnotationElement { * @property {TextAccessibilityManager} [accessibilityManager] * @property {AnnotationEditorUIManager} [annotationEditorUIManager] * @property {StructTreeLayerBuilder} [structTreeLayer] - * @property {CommentManager} [commentManager] - The comment manager instance. + * @property {OptionalContentConfig} [optionalContentConfig] */ /** @@ -3827,7 +3841,7 @@ class AnnotationLayer { * @memberof AnnotationLayer */ async render(params) { - const { annotations } = params; + const { annotations, optionalContentConfig } = params; const layer = this.div; setLayerDimensions(layer, this.viewport); @@ -3892,6 +3906,7 @@ class AnnotationLayer { if (data.hidden) { rendered.style.visibility = "hidden"; } + element.updateOC(optionalContentConfig); if (element._isEditable) { this.#editableAnnotations.set(element.data.id, element); @@ -4052,11 +4067,14 @@ class AnnotationLayer { * @param {AnnotationLayerParameters} viewport * @memberof AnnotationLayer */ - update({ viewport }) { + update({ viewport, optionalContentConfig }) { const layer = this.div; this.viewport = viewport; setLayerDimensions(layer, { rotation: viewport.rotation }); + for (const element of this.#elements) { + element.updateOC(optionalContentConfig); + } this.#setAnnotationCanvasMap(); layer.hidden = false; } diff --git a/test/driver.js b/test/driver.js index 7e098f7e0..0ac1b7374 100644 --- a/test/driver.js +++ b/test/driver.js @@ -242,7 +242,8 @@ class Rasterize { fieldObjects, page, imageResourcesPath, - renderForms = false + renderForms = false, + optionalContentConfigPromise = null ) { try { const { svg, foreignObject, style, div } = this.createContainer(viewport); @@ -263,6 +264,7 @@ class Rasterize { imageResourcesPath, renderForms, fieldObjects, + optionalContentConfig: await optionalContentConfigPromise, }; // Ensure that the annotationLayer gets translated. @@ -1355,7 +1357,8 @@ class Driver { task.fieldObjects, page, IMAGE_RESOURCES_PATH, - renderForms + renderForms, + task.optionalContentConfigPromise ).then(() => { completeRender(false); }); diff --git a/test/pdfs/issue20433.pdf.link b/test/pdfs/issue20433.pdf.link new file mode 100644 index 000000000..91de8cc65 --- /dev/null +++ b/test/pdfs/issue20433.pdf.link @@ -0,0 +1 @@ +https://web.archive.org/web/20251107005559/https://octopdf.com/octopdf-sample.pdf diff --git a/test/test_manifest.json b/test/test_manifest.json index adc5211c6..2f553d42b 100644 --- a/test/test_manifest.json +++ b/test/test_manifest.json @@ -1956,6 +1956,42 @@ "rounds": 1, "type": "eq" }, + { + "id": "issue20433-initial", + "file": "pdfs/issue20433.pdf", + "md5": "3a550da7807540982ed457397667db79", + "link": true, + "rounds": 1, + "firstPage": 2, + "type": "eq", + "forms": true + }, + { + "id": "issue20433-no-form", + "file": "pdfs/issue20433.pdf", + "md5": "3a550da7807540982ed457397667db79", + "link": true, + "rounds": 1, + "firstPage": 2, + "type": "eq", + "forms": true, + "optionalContent": { + "73R": false + } + }, + { + "id": "issue20433-no-mathml", + "file": "pdfs/issue20433.pdf", + "md5": "3a550da7807540982ed457397667db79", + "link": true, + "rounds": 1, + "firstPage": 2, + "type": "eq", + "forms": true, + "optionalContent": { + "115R": false + } + }, { "id": "issue13845", "file": "pdfs/issue13845.pdf", diff --git a/web/annotation_layer_builder.js b/web/annotation_layer_builder.js index 8c48a2bdb..2a30043ee 100644 --- a/web/annotation_layer_builder.js +++ b/web/annotation_layer_builder.js @@ -63,6 +63,7 @@ import { PresentationModeState } from "./ui_utils.js"; * @property {PageViewport} viewport * @property {string} [intent] - The default value is "display". * @property {StructTreeLayerBuilder} [structTreeLayer] + * @property {Promise} [optionalContentConfigPromise] */ class AnnotationLayerBuilder { @@ -125,8 +126,15 @@ class AnnotationLayerBuilder { * @returns {Promise} A promise that is resolved when rendering of the * annotations is complete. */ - async render({ viewport, intent = "display", structTreeLayer = null }) { + async render({ + viewport, + intent = "display", + structTreeLayer = null, + optionalContentConfigPromise = null, + }) { if (this.div) { + const optionalContentConfig = await optionalContentConfigPromise; + if (this._cancelled || !this.annotationLayer) { return; } @@ -134,15 +142,18 @@ class AnnotationLayerBuilder { // transformation matrices. this.annotationLayer.update({ viewport: viewport.clone({ dontFlip: true }), + optionalContentConfig, }); return; } - const [annotations, hasJSActions, fieldObjects] = await Promise.all([ - this.pdfPage.getAnnotations({ intent }), - this._hasJSActionsPromise, - this._fieldObjectsPromise, - ]); + const [annotations, hasJSActions, fieldObjects, optionalContentConfig] = + await Promise.all([ + this.pdfPage.getAnnotations({ intent }), + this._hasJSActionsPromise, + this._fieldObjectsPromise, + optionalContentConfigPromise, + ]); if (this._cancelled) { return; } @@ -169,6 +180,7 @@ class AnnotationLayerBuilder { enableScripting: this.enableScripting, hasJSActions, fieldObjects, + optionalContentConfig, }); this.#annotations = annotations; diff --git a/web/pdf_page_view.js b/web/pdf_page_view.js index 05124fc8f..070151b8e 100644 --- a/web/pdf_page_view.js +++ b/web/pdf_page_view.js @@ -464,6 +464,7 @@ class PDFPageView extends BasePDFPageView { viewport: this.viewport, intent: "display", structTreeLayer: this.structTreeLayer, + optionalContentConfigPromise: this._optionalContentConfigPromise, }); } catch (ex) { console.error("#renderAnnotationLayer:", ex);