Add support for optional marked content.

Add a new method to the API to get the optional content configuration. Add
a new render task param that accepts the above configuration.
For now, the optional content is not controllable by the user in
the viewer, but renders with the default configuration in the PDF.

All of the test files added exhibit different uses of optional content.

Fixes #269.

Fix test to work with optional content.

- Change the stopAtErrors test to ensure the operator list has something,
  instead of asserting the exact number of operators.
This commit is contained in:
Brendan Dahl 2020-07-14 15:17:27 -07:00
parent e68ac05f18
commit ac494a2278
14 changed files with 1179 additions and 54 deletions

View File

@ -392,6 +392,14 @@ class PartialEvaluator {
} else {
bbox = null;
}
let optionalContent = null;
if (dict.has("OC")) {
optionalContent = await this.parseMarkedContentProps(
dict.get("OC"),
resources
);
operatorList.addOp(OPS.beginMarkedContentProps, ["OC", optionalContent]);
}
var group = dict.get("Group");
if (group) {
var groupOptions = {
@ -449,6 +457,10 @@ class PartialEvaluator {
if (group) {
operatorList.addOp(OPS.endGroup, [groupOptions]);
}
if (optionalContent) {
operatorList.addOp(OPS.endMarkedContent, []);
}
});
}
@ -1202,6 +1214,63 @@ class PartialEvaluator {
throw new FormatError(`Unknown PatternName: ${patternName}`);
}
async parseMarkedContentProps(contentProperties, resources) {
let optionalContent;
if (isName(contentProperties)) {
const properties = resources.get("Properties");
optionalContent = properties.get(contentProperties.name);
} else if (isDict(contentProperties)) {
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 optionalContentGroups = optionalContent.get("OCGs");
if (
Array.isArray(optionalContentGroups) ||
isDict(optionalContentGroups)
) {
const groupIds = [];
if (Array.isArray(optionalContentGroups)) {
optionalContent.get("OCGs").forEach(ocg => {
groupIds.push(ocg.toString());
});
} else {
// Dictionary, just use the obj id.
groupIds.push(optionalContentGroups.objId);
}
let expression = null;
if (optionalContent.get("VE")) {
// TODO support visibility expression.
expression = true;
}
return {
type: optionalContentType,
ids: groupIds,
policy: isName(optionalContent.get("P"))
? optionalContent.get("P").name
: null,
expression,
};
} else if (isRef(optionalContentGroups)) {
return {
type: optionalContentType,
id: optionalContentGroups.toString(),
};
}
}
return null;
}
getOperatorList({
stream,
task,
@ -1704,9 +1773,6 @@ class PartialEvaluator {
continue;
case OPS.markPoint:
case OPS.markPointProps:
case OPS.beginMarkedContent:
case OPS.beginMarkedContentProps:
case OPS.endMarkedContent:
case OPS.beginCompat:
case OPS.endCompat:
// Ignore operators where the corresponding handlers are known to
@ -1716,6 +1782,45 @@ class PartialEvaluator {
// e.g. as done in https://github.com/mozilla/pdf.js/pull/6266,
// but doing so is meaningless without knowing the semantics.
continue;
case OPS.beginMarkedContentProps:
if (!isName(args[0])) {
warn(`Expected name for beginMarkedContentProps arg0=${args[0]}`);
continue;
}
if (args[0].name === "OC") {
next(
self
.parseMarkedContentProps(args[1], resources)
.then(data => {
operatorList.addOp(OPS.beginMarkedContentProps, [
"OC",
data,
]);
})
.catch(reason => {
if (reason instanceof AbortException) {
return;
}
if (self.options.ignoreErrors) {
self.handler.send("UnsupportedFeature", {
featureId: UNSUPPORTED_FEATURES.errorMarkedContent,
});
warn(
`getOperatorList - ignoring beginMarkedContentProps: "${reason}".`
);
return;
}
throw reason;
})
);
return;
}
// Other marked content types aren't supported yet.
args = [args[0].name];
break;
case OPS.beginMarkedContent:
case OPS.endMarkedContent:
default:
// Note: Ignore the operator if it has `Dict` arguments, since
// those are non-serializable, otherwise postMessage will throw

View File

@ -254,6 +254,82 @@ class Catalog {
return permissions;
}
get optionalContentConfig() {
let config = null;
try {
const properties = this.catDict.get("OCProperties");
if (!properties) {
return shadow(this, "optionalContentConfig", null);
}
const defaultConfig = properties.get("D");
if (!defaultConfig) {
return shadow(this, "optionalContentConfig", null);
}
const groupsData = properties.get("OCGs");
if (!Array.isArray(groupsData)) {
return shadow(this, "optionalContentConfig", null);
}
const groups = [];
const groupRefs = [];
// Ensure all the optional content groups are valid.
for (const groupRef of groupsData) {
if (!isRef(groupRef)) {
continue;
}
groupRefs.push(groupRef);
const group = this.xref.fetchIfRef(groupRef);
groups.push({
id: groupRef.toString(),
name: isString(group.get("Name"))
? stringToPDFString(group.get("Name"))
: null,
intent: isString(group.get("Intent"))
? stringToPDFString(group.get("Intent"))
: null,
});
}
config = this._readOptionalContentConfig(defaultConfig, groupRefs);
config.groups = groups;
} catch (ex) {
if (ex instanceof MissingDataException) {
throw ex;
}
warn(`Unable to read optional content config: ${ex}`);
}
return shadow(this, "optionalContentConfig", config);
}
_readOptionalContentConfig(config, contentGroupRefs) {
function parseOnOff(refs) {
const onParsed = [];
if (Array.isArray(refs)) {
for (const value of refs) {
if (!isRef(value)) {
continue;
}
if (contentGroupRefs.includes(value)) {
onParsed.push(value.toString());
}
}
}
return onParsed;
}
return {
name: isString(config.get("Name"))
? stringToPDFString(config.get("Name"))
: null,
creator: isString(config.get("Creator"))
? stringToPDFString(config.get("Creator"))
: null,
baseState: isName(config.get("BaseState"))
? config.get("BaseState").name
: null,
on: parseOnOff(config.get("ON")),
off: parseOnOff(config.get("OFF")),
};
}
get numPages() {
const obj = this.toplevelPagesDict.get("Count");
if (!Number.isInteger(obj)) {

View File

@ -481,6 +481,10 @@ class WorkerMessageHandler {
return pdfManager.ensureCatalog("documentOutline");
});
handler.on("GetOptionalContentConfig", function (data) {
return pdfManager.ensureCatalog("optionalContentConfig");
});
handler.on("GetPermissions", function (data) {
return pdfManager.ensureCatalog("permissions");
});

View File

@ -55,6 +55,7 @@ import { GlobalWorkerOptions } from "./worker_options.js";
import { isNodeJS } from "../shared/is_node.js";
import { MessageHandler } from "../shared/message_handler.js";
import { Metadata } from "./metadata.js";
import { OptionalContentConfig } from "./optional_content_config.js";
import { PDFDataTransportStream } from "./transport_stream.js";
import { WebGLContext } from "./webgl.js";
@ -788,6 +789,15 @@ class PDFDocumentProxy {
return this._transport.getOutline();
}
/**
* @returns {Promise<OptionalContentConfig | null>} A promise that is resolved
* with an {@link OptionalContentConfig} that will have all the optional
* content groups (if the document has any).
*/
getOptionalContentConfig() {
return this._transport.getOptionalContentConfig();
}
/**
* @returns {Promise<Array<string | null>>} A promise that is resolved with
* an {Array} that contains the permission flags for the PDF document, or
@ -965,6 +975,11 @@ class PDFDocumentProxy {
* image). The default value is 'rgb(255,255,255)'.
* @property {Object} [annotationStorage] - Storage for annotation data in
* forms.
* @property {Promise} [optionalContentConfigPromise] - A promise that should
* resolve with an {OptionalContentConfig} created from
* PDFDocumentProxy.getOptionalContentConfig. If null, the
* config will be automatically fetched with the default
* visibility states set.
*/
/**
@ -1088,6 +1103,7 @@ class PDFPageProxy {
canvasFactory = null,
background = null,
annotationStorage = null,
optionalContentConfigPromise = null,
}) {
if (this._stats) {
this._stats.time("Overall");
@ -1098,6 +1114,10 @@ class PDFPageProxy {
// this call to render.
this.pendingCleanup = false;
if (!optionalContentConfigPromise) {
optionalContentConfigPromise = this._transport.getOptionalContentConfig();
}
let intentState = this._intentStates.get(renderingIntent);
if (!intentState) {
intentState = Object.create(null);
@ -1191,8 +1211,11 @@ class PDFPageProxy {
intentState.renderTasks.push(internalRenderTask);
const renderTask = internalRenderTask.task;
intentState.displayReadyCapability.promise
.then(transparency => {
Promise.all([
intentState.displayReadyCapability.promise,
optionalContentConfigPromise,
])
.then(([transparency, optionalContentConfig]) => {
if (this.pendingCleanup) {
complete();
return;
@ -1200,7 +1223,10 @@ class PDFPageProxy {
if (this._stats) {
this._stats.time("Rendering");
}
internalRenderTask.initializeGraphics(transparency);
internalRenderTask.initializeGraphics({
transparency,
optionalContentConfig,
});
internalRenderTask.operatorListChanged();
})
.catch(complete);
@ -2546,6 +2572,14 @@ class WorkerTransport {
return this.messageHandler.sendWithPromise("GetOutline", null);
}
getOptionalContentConfig() {
return this.messageHandler
.sendWithPromise("GetOptionalContentConfig", null)
.then(results => {
return new OptionalContentConfig(results);
});
}
getPermissions() {
return this.messageHandler.sendWithPromise("GetPermissions", null);
}
@ -2759,7 +2793,7 @@ const InternalRenderTask = (function InternalRenderTaskClosure() {
});
}
initializeGraphics(transparency = false) {
initializeGraphics({ transparency = false, optionalContentConfig }) {
if (this.cancelled) {
return;
}
@ -2797,7 +2831,8 @@ const InternalRenderTask = (function InternalRenderTaskClosure() {
this.objs,
this.canvasFactory,
this.webGLContext,
imageLayer
imageLayer,
optionalContentConfig
);
this.gfx.beginDrawing({
transform,

View File

@ -447,7 +447,8 @@ var CanvasGraphics = (function CanvasGraphicsClosure() {
objs,
canvasFactory,
webGLContext,
imageLayer
imageLayer,
optionalContentConfig
) {
this.ctx = canvasCtx;
this.current = new CanvasExtraState();
@ -471,6 +472,9 @@ var CanvasGraphics = (function CanvasGraphicsClosure() {
this.smaskStack = [];
this.smaskCounter = 0;
this.tempSMask = null;
this.contentVisible = true;
this.markedContentStack = [];
this.optionalContentConfig = optionalContentConfig;
this.cachedCanvases = new CachedCanvases(this.canvasFactory);
if (canvasCtx) {
// NOTE: if mozCurrentTransform is polyfilled, then the current state of
@ -1262,6 +1266,7 @@ var CanvasGraphics = (function CanvasGraphicsClosure() {
// For stroke we want to temporarily change the global alpha to the
// stroking alpha.
ctx.globalAlpha = this.current.strokeAlpha;
if (this.contentVisible) {
if (
strokeColor &&
strokeColor.hasOwnProperty("type") &&
@ -1271,9 +1276,9 @@ var CanvasGraphics = (function CanvasGraphicsClosure() {
// the pattern, call stroke, and restore to user space
ctx.save();
// The current transform will be replaced while building the pattern,
// but the line width needs to be adjusted by the current transform, so
// we must scale it. To properly fix this we should be using a pattern
// transform instead (see #10955).
// but the line width needs to be adjusted by the current transform,
// so we must scale it. To properly fix this we should be using a
// pattern transform instead (see #10955).
const transform = ctx.mozCurrentTransform;
const scale = Util.singularValueDecompose2dScale(transform)[0];
ctx.strokeStyle = strokeColor.getPattern(ctx, this);
@ -1291,6 +1296,7 @@ var CanvasGraphics = (function CanvasGraphicsClosure() {
);
ctx.stroke();
}
}
if (consumePath) {
this.consumePath();
}
@ -1317,12 +1323,14 @@ var CanvasGraphics = (function CanvasGraphicsClosure() {
needRestore = true;
}
if (this.contentVisible) {
if (this.pendingEOFill) {
ctx.fill("evenodd");
this.pendingEOFill = false;
} else {
ctx.fill();
}
}
if (needRestore) {
ctx.restore();
@ -1700,7 +1708,7 @@ var CanvasGraphics = (function CanvasGraphicsClosure() {
// Only attempt to draw the glyph if it is actually in the embedded font
// file or if there isn't a font file so the fallback font is shown.
if (glyph.isInFont || font.missingFile) {
if (this.contentVisible && (glyph.isInFont || font.missingFile)) {
if (simpleFillText && !accent) {
// common case
ctx.fillText(character, scaledX, scaledY);
@ -1782,12 +1790,14 @@ var CanvasGraphics = (function CanvasGraphicsClosure() {
warn(`Type3 character "${glyph.operatorListId}" is not available.`);
continue;
}
if (this.contentVisible) {
this.processingType3 = glyph;
this.save();
ctx.scale(fontSize, fontSize);
ctx.transform.apply(ctx, fontMatrix);
this.executeOperatorList(operatorList);
this.restore();
}
var transformed = Util.applyTransform([glyph.width, 0], fontMatrix);
width = transformed[0] * fontSize + spacing;
@ -1869,6 +1879,9 @@ var CanvasGraphics = (function CanvasGraphicsClosure() {
},
shadingFill: function CanvasGraphics_shadingFill(patternIR) {
if (!this.contentVisible) {
return;
}
var ctx = this.ctx;
this.save();
@ -1917,6 +1930,9 @@ var CanvasGraphics = (function CanvasGraphicsClosure() {
matrix,
bbox
) {
if (!this.contentVisible) {
return;
}
this.save();
this.baseTransformStack.push(this.baseTransform);
@ -1936,11 +1952,18 @@ var CanvasGraphics = (function CanvasGraphicsClosure() {
},
paintFormXObjectEnd: function CanvasGraphics_paintFormXObjectEnd() {
if (!this.contentVisible) {
return;
}
this.restore();
this.baseTransform = this.baseTransformStack.pop();
},
beginGroup: function CanvasGraphics_beginGroup(group) {
if (!this.contentVisible) {
return;
}
this.save();
var currentCtx = this.ctx;
// TODO non-isolated groups - according to Rik at adobe non-isolated
@ -2062,6 +2085,9 @@ var CanvasGraphics = (function CanvasGraphicsClosure() {
},
endGroup: function CanvasGraphics_endGroup(group) {
if (!this.contentVisible) {
return;
}
this.groupLevel--;
var groupCtx = this.ctx;
this.ctx = this.groupStack.pop();
@ -2117,6 +2143,9 @@ var CanvasGraphics = (function CanvasGraphicsClosure() {
},
paintImageMaskXObject: function CanvasGraphics_paintImageMaskXObject(img) {
if (!this.contentVisible) {
return;
}
var ctx = this.ctx;
var width = img.width,
height = img.height;
@ -2168,6 +2197,9 @@ var CanvasGraphics = (function CanvasGraphicsClosure() {
scaleY,
positions
) {
if (!this.contentVisible) {
return;
}
var width = imgData.width;
var height = imgData.height;
var fillColor = this.current.fillColor;
@ -2212,6 +2244,9 @@ var CanvasGraphics = (function CanvasGraphicsClosure() {
paintImageMaskXObjectGroup: function CanvasGraphics_paintImageMaskXObjectGroup(
images
) {
if (!this.contentVisible) {
return;
}
var ctx = this.ctx;
var fillColor = this.current.fillColor;
@ -2249,6 +2284,9 @@ var CanvasGraphics = (function CanvasGraphicsClosure() {
},
paintImageXObject: function CanvasGraphics_paintImageXObject(objId) {
if (!this.contentVisible) {
return;
}
const imgData = objId.startsWith("g_")
? this.commonObjs.get(objId)
: this.objs.get(objId);
@ -2266,6 +2304,9 @@ var CanvasGraphics = (function CanvasGraphicsClosure() {
scaleY,
positions
) {
if (!this.contentVisible) {
return;
}
const imgData = objId.startsWith("g_")
? this.commonObjs.get(objId)
: this.objs.get(objId);
@ -2292,6 +2333,9 @@ var CanvasGraphics = (function CanvasGraphicsClosure() {
paintInlineImageXObject: function CanvasGraphics_paintInlineImageXObject(
imgData
) {
if (!this.contentVisible) {
return;
}
var width = imgData.width;
var height = imgData.height;
var ctx = this.ctx;
@ -2394,6 +2438,9 @@ var CanvasGraphics = (function CanvasGraphicsClosure() {
imgData,
map
) {
if (!this.contentVisible) {
return;
}
var ctx = this.ctx;
var w = imgData.width;
var h = imgData.height;
@ -2433,6 +2480,9 @@ var CanvasGraphics = (function CanvasGraphicsClosure() {
},
paintSolidColorImageMask: function CanvasGraphics_paintSolidColorImageMask() {
if (!this.contentVisible) {
return;
}
this.ctx.fillRect(0, 0, 1, 1);
},
@ -2445,16 +2495,28 @@ var CanvasGraphics = (function CanvasGraphicsClosure() {
// TODO Marked content.
},
beginMarkedContent: function CanvasGraphics_beginMarkedContent(tag) {
// TODO Marked content.
this.markedContentStack.push({
visible: true,
});
},
beginMarkedContentProps: function CanvasGraphics_beginMarkedContentProps(
tag,
properties
) {
// TODO Marked content.
if (tag === "OC") {
this.markedContentStack.push({
visible: this.optionalContentConfig.isVisible(properties),
});
} else {
this.markedContentStack.push({
visible: true,
});
}
this.contentVisible = this.isContentVisible();
},
endMarkedContent: function CanvasGraphics_endMarkedContent() {
// TODO Marked content.
this.markedContentStack.pop();
this.contentVisible = this.isContentVisible();
},
// Compatibility
@ -2500,6 +2562,15 @@ var CanvasGraphics = (function CanvasGraphicsClosure() {
transform[1] * x + transform[3] * y + transform[5],
];
},
isContentVisible: function CanvasGraphics_isContentVisible() {
for (let i = this.markedContentStack.length - 1; i >= 0; i--) {
if (!this.markedContentStack[i].visible) {
return false;
}
}
return true;
},
};
for (var op in OPS) {

View File

@ -0,0 +1,125 @@
/* Copyright 2020 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 { warn } from "../shared/util.js";
class OptionalContentGroup {
constructor(name, intent) {
this.visible = true;
this.name = name;
this.intent = intent;
}
}
class OptionalContentConfig {
constructor(data) {
this.name = null;
this.creator = null;
this.groups = new Map();
if (data === null) {
return;
}
this.name = data.name;
this.creator = data.creator;
for (const group of data.groups) {
this.groups.set(
group.id,
new OptionalContentGroup(group.name, group.intent)
);
}
if (data.baseState === "OFF") {
for (const group of this.groups) {
group.visible = false;
}
}
for (const on of data.on) {
this.groups.get(on).visible = true;
}
for (const off of data.off) {
this.groups.get(off).visible = false;
}
}
isVisible(group) {
if (group.type === "OCG") {
if (!this.groups.has(group.id)) {
warn(`Optional content group not found: ${group.id}`);
return true;
}
return this.groups.get(group.id).visible;
} else if (group.type === "OCMD") {
// Per the spec, the expression should be preferred if available. Until
// we implement this, just fallback to using the group policy for now.
if (group.expression) {
warn("Visibility expression not supported yet.");
}
if (!group.policy || group.policy === "AnyOn") {
// Default
for (const id of group.ids) {
if (!this.groups.has(id)) {
warn(`Optional content group not found: ${id}`);
return true;
}
if (this.groups.get(id).visible) {
return true;
}
}
return false;
} else if (group.policy === "AllOn") {
for (const id of group.ids) {
if (!this.groups.has(id)) {
warn(`Optional content group not found: ${id}`);
return true;
}
if (!this.groups.get(id).visible) {
return false;
}
}
return true;
} else if (group.policy === "AnyOff") {
for (const id of group.ids) {
if (!this.groups.has(id)) {
warn(`Optional content group not found: ${id}`);
return true;
}
if (!this.groups.get(id).visible) {
return true;
}
}
return false;
} else if (group.policy === "AllOff") {
for (const id of group.ids) {
if (!this.groups.has(id)) {
warn(`Optional content group not found: ${id}`);
return true;
}
if (this.groups.get(id).visible) {
return false;
}
}
return true;
}
warn(`Unknown optional content policy ${group.policy}.`);
return true;
}
warn(`Unknown group type ${group.type}.`);
return true;
}
}
export { OptionalContentConfig };

View File

@ -302,6 +302,7 @@ const UNSUPPORTED_FEATURES = {
errorFontToUnicode: "errorFontToUnicode",
errorFontLoadNative: "errorFontLoadNative",
errorFontGetPath: "errorFontGetPath",
errorMarkedContent: "errorMarkedContent",
};
const PasswordResponses = {

View File

@ -52,6 +52,7 @@
!issue7835.pdf
!issue11922_reduced.pdf
!issue7855.pdf
!issue11144_reduced.pdf
!issue7872.pdf
!issue7901.pdf
!issue8061.pdf
@ -296,6 +297,7 @@
!issue3371.pdf
!issue2956.pdf
!issue2537r.pdf
!issue269_1.pdf
!bug946506.pdf
!issue3885.pdf
!issue11697_reduced.pdf
@ -331,6 +333,7 @@
!issue5481.pdf
!issue5567.pdf
!issue5701.pdf
!issue12007_reduced.pdf
!issue5896.pdf
!issue6010_1.pdf
!issue6010_2.pdf
@ -352,6 +355,7 @@
!issue9278.pdf
!annotation-text-without-popup.pdf
!annotation-underline.pdf
!issue269_2.pdf
!annotation-strikeout.pdf
!annotation-squiggly.pdf
!annotation-highlight.pdf

Binary file not shown.

Binary file not shown.

676
test/pdfs/issue269_1.pdf Normal file

File diff suppressed because one or more lines are too long

BIN
test/pdfs/issue269_2.pdf Normal file

Binary file not shown.

View File

@ -926,6 +926,34 @@
"link": false,
"type": "eq"
},
{ "id": "issue269_1",
"file": "pdfs/issue269_1.pdf",
"md5": "ab932f697b4d2e2bf700de15a8efea9c",
"rounds": 1,
"type": "eq",
"about": "Optional marked content."
},
{ "id": "issue269_2",
"file": "pdfs/issue269_2.pdf",
"md5": "0f553510850ee17c87fbab3fac564165",
"rounds": 1,
"type": "eq",
"about": "Optional marked content."
},
{ "id": "issue11144_reduced",
"file": "pdfs/issue11144_reduced.pdf",
"md5": "09e3e771ebd6867558074e900adb54b9",
"rounds": 1,
"type": "eq",
"about": "Optional marked content."
},
{ "id": "issue12007_reduced",
"file": "pdfs/issue12007_reduced.pdf",
"md5": "3aa9d8a0c5ff8594245149f9c7379613",
"rounds": 1,
"type": "eq",
"about": "Optional marked content."
},
{ "id": "issue10438",
"file": "pdfs/issue10438_reduced.pdf",
"md5": "bb26f68493e33af17b256a6ffe777a24",

View File

@ -1636,8 +1636,8 @@ describe("api", function () {
const result1 = loadingTask1.promise.then(pdfDoc => {
return pdfDoc.getPage(1).then(pdfPage => {
return pdfPage.getOperatorList().then(opList => {
expect(opList.fnArray.length).toEqual(722);
expect(opList.argsArray.length).toEqual(722);
expect(opList.fnArray.length).toBeGreaterThan(100);
expect(opList.argsArray.length).toBeGreaterThan(100);
expect(opList.lastChunk).toEqual(true);
return loadingTask1.destroy();