[Regression] Re-factor the *internal* includeAnnotationStorage handling, since it's currently subtly wrong

*This patch is very similar to the recently fixed `renderInteractiveForms`-options, see PR 13867.*
As far as I can tell, this *subtle* bug has existed ever since `AnnotationStorage`-support was first added in PR 12106 (a little over a year ago).

The value of the `includeAnnotationStorage`-option, as passed to the `PDFPageProxy.render` method, will (potentially) affect the size/content of the operatorList that's returned from the worker (for documents with forms).
Given that operatorLists will generally, unless they contain huge images, be cached in the API, repeated `PDFPageProxy.render` calls where the form-data has been changed by the user in between, can thus *wrongly* return a cached operatorList.

In the viewer we're only using the `includeAnnotationStorage`-option when printing, which is probably why this has gone unnoticed for so long. Note that we, for performance reasons, don't cache printing-operatorLists in the API.
However, there's nothing stopping an API-user from using the `includeAnnotationStorage`-option during "normal" rendering, which could thus result in *subtle* (and difficult to understand) rendering bugs.

In order to handle this, we need to know if the `AnnotationStorage`-instance has been updated since the last `PDFPageProxy.render` call. The most "correct" solution would obviously be to create a hash of the `AnnotationStorage` contents, however that would require adding a bunch of code, complexity, and runtime overhead.
Given that operatorList caching in the API doesn't have to be perfect[1], but only have to avoid *false* cache-hits, we can simplify things significantly be only keeping track of the last time that the `AnnotationStorage`-data was modified.

*Please note:* While working on this patch, I also noticed that the `renderInteractiveForms`- and `includeAnnotationStorage`-options in the `PDFPageProxy.render` method are mutually exclusive.[2]
Given that the various Annotation-related options in `PDFPageProxy.render` have been added at different times, this has unfortunately led to the current "messy" situation.[3]

---
[1] Note how we're already not caching operatorLists for pages with *huge* images, in order to save memory, hence there's no guarantee that operatorLists will always be cached.

[2] Setting both to `true` will result in undefined behaviour, since trying to insert `AnnotationStorage`-values into fields that are being excluded from the operatorList-building will obviously not work, which isn't at all clear from the documentation.

[3] My intention is to try and fix this in a follow-up PR, and I've got a WIP patch locally, however it will result in a number of API-observable changes.
This commit is contained in:
Jonas Jenwald 2021-08-15 19:57:42 +02:00
parent 1465b1670f
commit a7f0301f21
6 changed files with 107 additions and 57 deletions

View File

@ -49,6 +49,7 @@
"unicorn/no-new-buffer": "error", "unicorn/no-new-buffer": "error",
"unicorn/no-instanceof-array": "error", "unicorn/no-instanceof-array": "error",
"unicorn/no-useless-spread": "error", "unicorn/no-useless-spread": "error",
"unicorn/prefer-date-now": "error",
"unicorn/prefer-string-starts-ends-with": "error", "unicorn/prefer-string-starts-ends-with": "error",
// Possible errors // Possible errors

View File

@ -320,7 +320,14 @@ class Page {
}); });
} }
getOperatorList({ handler, sink, task, intent, annotationStorage }) { getOperatorList({
handler,
sink,
task,
intent,
cacheKey,
annotationStorage = null,
}) {
const contentStreamPromise = this.getContentStream(handler); const contentStreamPromise = this.getContentStream(handler);
const resourcesPromise = this.loadResources([ const resourcesPromise = this.loadResources([
"ColorSpace", "ColorSpace",
@ -354,7 +361,7 @@ class Page {
this.nonBlendModesSet this.nonBlendModesSet
), ),
pageIndex: this.pageIndex, pageIndex: this.pageIndex,
intent, cacheKey,
}); });
return partialEvaluator return partialEvaluator
@ -377,7 +384,7 @@ class Page {
pageOpList.flush(true); pageOpList.flush(true);
return { length: pageOpList.totalLength }; return { length: pageOpList.totalLength };
} }
const renderForms = !!(intent & RenderingIntentFlag.ANNOTATION_FORMS), const renderForms = !!(intent & RenderingIntentFlag.ANNOTATIONS_FORMS),
intentAny = !!(intent & RenderingIntentFlag.ANY), intentAny = !!(intent & RenderingIntentFlag.ANY),
intentDisplay = !!(intent & RenderingIntentFlag.DISPLAY), intentDisplay = !!(intent & RenderingIntentFlag.DISPLAY),
intentPrint = !!(intent & RenderingIntentFlag.PRINT); intentPrint = !!(intent & RenderingIntentFlag.PRINT);

View File

@ -688,6 +688,7 @@ class WorkerMessageHandler {
sink, sink,
task, task,
intent: data.intent, intent: data.intent,
cacheKey: data.cacheKey,
annotationStorage: data.annotationStorage, annotationStorage: data.annotationStorage,
}) })
.then( .then(

View File

@ -21,6 +21,7 @@ import { objectFromMap } from "../shared/util.js";
class AnnotationStorage { class AnnotationStorage {
constructor() { constructor() {
this._storage = new Map(); this._storage = new Map();
this._timeStamp = Date.now();
this._modified = false; this._modified = false;
// Callbacks to signal when the modification state is set or reset. // Callbacks to signal when the modification state is set or reset.
@ -41,8 +42,7 @@ class AnnotationStorage {
* @returns {Object} * @returns {Object}
*/ */
getValue(key, defaultValue) { getValue(key, defaultValue) {
const obj = this._storage.get(key); return this._storage.get(key) ?? defaultValue;
return obj !== undefined ? obj : defaultValue;
} }
/** /**
@ -64,10 +64,11 @@ class AnnotationStorage {
} }
} }
} else { } else {
this._storage.set(key, value);
modified = true; modified = true;
this._storage.set(key, value);
} }
if (modified) { if (modified) {
this._timeStamp = Date.now();
this._setModified(); this._setModified();
} }
} }
@ -108,6 +109,14 @@ class AnnotationStorage {
get serializable() { get serializable() {
return this._storage.size > 0 ? this._storage : null; return this._storage.size > 0 ? this._storage : null;
} }
/**
* PLEASE NOTE: Only intended for usage within the API itself.
* @ignore
*/
get lastModified() {
return this._timeStamp.toString();
}
} }
export { AnnotationStorage }; export { AnnotationStorage };

View File

@ -1279,15 +1279,15 @@ class PDFPageProxy {
* {Array} of the annotation objects. * {Array} of the annotation objects.
*/ */
getAnnotations({ intent = "display" } = {}) { getAnnotations({ intent = "display" } = {}) {
const renderingIntent = this._transport.getRenderingIntent(intent, {}); const intentArgs = this._transport.getRenderingIntent(intent, {});
let promise = this._annotationPromises.get(renderingIntent); let promise = this._annotationPromises.get(intentArgs.cacheKey);
if (!promise) { if (!promise) {
promise = this._transport.getAnnotations( promise = this._transport.getAnnotations(
this._pageIndex, this._pageIndex,
renderingIntent intentArgs.renderingIntent
); );
this._annotationPromises.set(renderingIntent, promise); this._annotationPromises.set(intentArgs.cacheKey, promise);
} }
return promise; return promise;
} }
@ -1335,8 +1335,9 @@ class PDFPageProxy {
this._stats.time("Overall"); this._stats.time("Overall");
} }
const renderingIntent = this._transport.getRenderingIntent(intent, { const intentArgs = this._transport.getRenderingIntent(intent, {
renderForms: renderInteractiveForms === true, renderForms: renderInteractiveForms === true,
includeAnnotationStorage: includeAnnotationStorage === true,
}); });
// If there was a pending destroy, cancel it so no cleanup happens during // If there was a pending destroy, cancel it so no cleanup happens during
// this call to render. // this call to render.
@ -1346,10 +1347,10 @@ class PDFPageProxy {
optionalContentConfigPromise = this._transport.getOptionalContentConfig(); optionalContentConfigPromise = this._transport.getOptionalContentConfig();
} }
let intentState = this._intentStates.get(renderingIntent); let intentState = this._intentStates.get(intentArgs.cacheKey);
if (!intentState) { if (!intentState) {
intentState = Object.create(null); intentState = Object.create(null);
this._intentStates.set(renderingIntent, intentState); this._intentStates.set(intentArgs.cacheKey, intentState);
} }
// Ensure that a pending `streamReader` cancel timeout is always aborted. // Ensure that a pending `streamReader` cancel timeout is always aborted.
@ -1361,9 +1362,9 @@ class PDFPageProxy {
const canvasFactoryInstance = const canvasFactoryInstance =
canvasFactory || canvasFactory ||
new DefaultCanvasFactory({ ownerDocument: this._ownerDocument }); new DefaultCanvasFactory({ ownerDocument: this._ownerDocument });
const annotationStorage = includeAnnotationStorage const intentPrint = !!(
? this._transport.annotationStorage.serializable intentArgs.renderingIntent & RenderingIntentFlag.PRINT
: null; );
// If there's no displayReadyCapability yet, then the operatorList // If there's no displayReadyCapability yet, then the operatorList
// was never requested before. Make the request and create the promise. // was never requested before. Make the request and create the promise.
@ -1378,11 +1379,7 @@ class PDFPageProxy {
if (this._stats) { if (this._stats) {
this._stats.time("Page Request"); this._stats.time("Page Request");
} }
this._pumpOperatorList({ this._pumpOperatorList(intentArgs);
pageIndex: this._pageIndex,
intent: renderingIntent,
annotationStorage,
});
} }
const complete = error => { const complete = error => {
@ -1390,10 +1387,7 @@ class PDFPageProxy {
// Attempt to reduce memory usage during *printing*, by always running // Attempt to reduce memory usage during *printing*, by always running
// cleanup once rendering has finished (regardless of cleanupAfterRender). // cleanup once rendering has finished (regardless of cleanupAfterRender).
if ( if (this.cleanupAfterRender || intentPrint) {
this.cleanupAfterRender ||
renderingIntent & RenderingIntentFlag.PRINT
) {
this.pendingCleanup = true; this.pendingCleanup = true;
} }
this._tryCleanup(); this._tryCleanup();
@ -1403,7 +1397,7 @@ class PDFPageProxy {
this._abortOperatorList({ this._abortOperatorList({
intentState, intentState,
reason: error, reason: error instanceof Error ? error : new Error(error),
}); });
} else { } else {
internalRenderTask.capability.resolve(); internalRenderTask.capability.resolve();
@ -1429,7 +1423,7 @@ class PDFPageProxy {
operatorList: intentState.operatorList, operatorList: intentState.operatorList,
pageIndex: this._pageIndex, pageIndex: this._pageIndex,
canvasFactory: canvasFactoryInstance, canvasFactory: canvasFactoryInstance,
useRequestAnimationFrame: !(renderingIntent & RenderingIntentFlag.PRINT), useRequestAnimationFrame: !intentPrint,
pdfBug: this._pdfBug, pdfBug: this._pdfBug,
}); });
@ -1474,13 +1468,13 @@ class PDFPageProxy {
} }
} }
const renderingIntent = this._transport.getRenderingIntent(intent, { const intentArgs = this._transport.getRenderingIntent(intent, {
isOpList: true, isOpList: true,
}); });
let intentState = this._intentStates.get(renderingIntent); let intentState = this._intentStates.get(intentArgs.cacheKey);
if (!intentState) { if (!intentState) {
intentState = Object.create(null); intentState = Object.create(null);
this._intentStates.set(renderingIntent, intentState); this._intentStates.set(intentArgs.cacheKey, intentState);
} }
let opListTask; let opListTask;
@ -1498,10 +1492,7 @@ class PDFPageProxy {
if (this._stats) { if (this._stats) {
this._stats.time("Page Request"); this._stats.time("Page Request");
} }
this._pumpOperatorList({ this._pumpOperatorList(intentArgs);
pageIndex: this._pageIndex,
intent: renderingIntent,
});
} }
return intentState.opListReadCapability.promise; return intentState.opListReadCapability.promise;
} }
@ -1584,14 +1575,14 @@ class PDFPageProxy {
this._transport.pageCache[this._pageIndex] = null; this._transport.pageCache[this._pageIndex] = null;
const waitOn = []; const waitOn = [];
for (const [intent, intentState] of this._intentStates) { for (const intentState of this._intentStates.values()) {
this._abortOperatorList({ this._abortOperatorList({
intentState, intentState,
reason: new Error("Page was destroyed."), reason: new Error("Page was destroyed."),
force: true, force: true,
}); });
if (intent & RenderingIntentFlag.OPLIST) { if (intentState.opListReadCapability) {
// Avoid errors below, since the renderTasks are just stubs. // Avoid errors below, since the renderTasks are just stubs.
continue; continue;
} }
@ -1649,8 +1640,8 @@ class PDFPageProxy {
/** /**
* @private * @private
*/ */
_startRenderPage(transparency, intent) { _startRenderPage(transparency, cacheKey) {
const intentState = this._intentStates.get(intent); const intentState = this._intentStates.get(cacheKey);
if (!intentState) { if (!intentState) {
return; // Rendering was cancelled. return; // Rendering was cancelled.
} }
@ -1688,19 +1679,32 @@ class PDFPageProxy {
/** /**
* @private * @private
*/ */
_pumpOperatorList(args) { _pumpOperatorList({ renderingIntent, cacheKey }) {
if (
typeof PDFJSDev === "undefined" ||
PDFJSDev.test("!PRODUCTION || TESTING")
) {
assert( assert(
args.intent, Number.isInteger(renderingIntent) && renderingIntent > 0,
'PDFPageProxy._pumpOperatorList: Expected "intent" argument.' '_pumpOperatorList: Expected valid "renderingIntent" argument.'
); );
}
const readableStream = this._transport.messageHandler.sendWithStream( const readableStream = this._transport.messageHandler.sendWithStream(
"GetOperatorList", "GetOperatorList",
args {
pageIndex: this._pageIndex,
intent: renderingIntent,
cacheKey,
annotationStorage:
renderingIntent & RenderingIntentFlag.ANNOTATIONS_STORAGE
? this._transport.annotationStorage.serializable
: null,
}
); );
const reader = readableStream.getReader(); const reader = readableStream.getReader();
const intentState = this._intentStates.get(args.intent); const intentState = this._intentStates.get(cacheKey);
intentState.streamReader = reader; intentState.streamReader = reader;
const pump = () => { const pump = () => {
@ -1749,11 +1753,15 @@ class PDFPageProxy {
* @private * @private
*/ */
_abortOperatorList({ intentState, reason, force = false }) { _abortOperatorList({ intentState, reason, force = false }) {
if (
typeof PDFJSDev === "undefined" ||
PDFJSDev.test("!PRODUCTION || TESTING")
) {
assert( assert(
reason instanceof Error || reason instanceof Error,
(typeof reason === "object" && reason !== null), '_abortOperatorList: Expected valid "reason" argument.'
'PDFPageProxy._abortOperatorList: Expected "reason" argument.'
); );
}
if (!intentState.streamReader) { if (!intentState.streamReader) {
return; return;
@ -1787,9 +1795,9 @@ class PDFPageProxy {
} }
// Remove the current `intentState`, since a cancelled `getOperatorList` // Remove the current `intentState`, since a cancelled `getOperatorList`
// call on the worker-thread cannot be re-started... // call on the worker-thread cannot be re-started...
for (const [intent, curIntentState] of this._intentStates) { for (const [curCacheKey, curIntentState] of this._intentStates) {
if (curIntentState === intentState) { if (curIntentState === intentState) {
this._intentStates.delete(intent); this._intentStates.delete(curCacheKey);
break; break;
} }
} }
@ -2333,8 +2341,12 @@ class WorkerTransport {
return shadow(this, "annotationStorage", new AnnotationStorage()); return shadow(this, "annotationStorage", new AnnotationStorage());
} }
getRenderingIntent(intent, { renderForms = false, isOpList = false }) { getRenderingIntent(
intent,
{ renderForms = false, includeAnnotationStorage = false, isOpList = false }
) {
let renderingIntent = RenderingIntentFlag.DISPLAY; // Default value. let renderingIntent = RenderingIntentFlag.DISPLAY; // Default value.
let lastModified = "";
switch (intent) { switch (intent) {
case "any": case "any":
@ -2350,14 +2362,22 @@ class WorkerTransport {
} }
if (renderForms) { if (renderForms) {
renderingIntent += RenderingIntentFlag.ANNOTATION_FORMS; renderingIntent += RenderingIntentFlag.ANNOTATIONS_FORMS;
}
if (includeAnnotationStorage) {
renderingIntent += RenderingIntentFlag.ANNOTATIONS_STORAGE;
lastModified = this.annotationStorage.lastModified;
} }
if (isOpList) { if (isOpList) {
renderingIntent += RenderingIntentFlag.OPLIST; renderingIntent += RenderingIntentFlag.OPLIST;
} }
return renderingIntent; return {
renderingIntent,
cacheKey: `${renderingIntent}_${lastModified}`,
};
} }
destroy() { destroy() {
@ -2617,7 +2637,7 @@ class WorkerTransport {
} }
const page = this.pageCache[data.pageIndex]; const page = this.pageCache[data.pageIndex];
page._startRenderPage(data.transparency, data.intent); page._startRenderPage(data.transparency, data.cacheKey);
}); });
messageHandler.on("commonobj", data => { messageHandler.on("commonobj", data => {

View File

@ -18,11 +18,23 @@ import "./compatibility.js";
const IDENTITY_MATRIX = [1, 0, 0, 1, 0, 0]; const IDENTITY_MATRIX = [1, 0, 0, 1, 0, 0];
const FONT_IDENTITY_MATRIX = [0.001, 0, 0, 0.001, 0, 0]; const FONT_IDENTITY_MATRIX = [0.001, 0, 0, 0.001, 0, 0];
/**
* Refer to the `WorkerTransport.getRenderingIntent`-method in the API, to see
* how these flags are being used:
* - ANY, DISPLAY, and PRINT are the normal rendering intents, note the
* `PDFPageProxy.{render, getOperatorList, getAnnotations}`-methods.
* - ANNOTATIONS_FORMS, and ANNOTATIONS_STORAGE controls which annotations are
* rendered onto the canvas, note the `renderInteractiveForms`- respectively
* `includeAnnotationStorage`-options in the `PDFPageProxy.render`-method.
* - OPLIST is used with the `PDFPageProxy.getOperatorList`-method, note the
* `OperatorList`-constructor (on the worker-thread).
*/
const RenderingIntentFlag = { const RenderingIntentFlag = {
ANY: 0x01, ANY: 0x01,
DISPLAY: 0x02, DISPLAY: 0x02,
PRINT: 0x04, PRINT: 0x04,
ANNOTATION_FORMS: 0x20, ANNOTATIONS_FORMS: 0x10,
ANNOTATIONS_STORAGE: 0x20,
OPLIST: 0x100, OPLIST: 0x100,
}; };