Add support for Optional Content in the AnnotationLayer (issue 20433)

This commit is contained in:
Jonas Jenwald 2026-05-21 18:27:43 +02:00
parent 25c7d9eaac
commit fb9758303b
9 changed files with 225 additions and 132 deletions

View File

@ -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]);
}

View File

@ -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({

123
src/core/evaluator_utils.js Normal file
View File

@ -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 };

View File

@ -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;
}

View File

@ -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);
});

View File

@ -0,0 +1 @@
https://web.archive.org/web/20251107005559/https://octopdf.com/octopdf-sample.pdf

View File

@ -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",

View File

@ -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<void>} 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;

View File

@ -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);