[api-minor] Implement basic support for OptionalContent Usage dicts (issue 5764, bug 1826783)

The following are some highlights of this patch:
 - In the Worker we only extract a *subset* of the potential contents of the `Usage` dictionary, to avoid having to implement/test a bunch of code that'd be completely unused in the viewer.

 - In order to still allow the user to *manually* override the default visible layers in the viewer, the viewable/printable state is purposely *not* enforced during initialization in the `OptionalContentConfig` constructor.

 - Printing will now always use the *default* visible layers, rather than using the same state as the viewer (as was the case previously).
   This ensures that the printing-output will correctly take the `Usage` dictionary into account, and in practice toggling of visible layers rarely seem to be necessary except in the viewer itself (if at all).[1]

---
[1] In the unlikely case that it'd ever be deemed necessary to support fine-grained control of optional content visibility during printing, some new (additional) UI would likely be needed to support that case.
This commit is contained in:
Jonas Jenwald 2024-02-25 14:12:36 +01:00
parent e647311a89
commit 3c78ff5fb0
13 changed files with 186 additions and 62 deletions

View File

@ -445,20 +445,10 @@ class Catalog {
continue; continue;
} }
groupRefs.put(groupRef); groupRefs.put(groupRef);
const group = this.xref.fetch(groupRef);
groups.push({ groups.push(this.#readOptionalContentGroup(groupRef));
id: groupRef.toString(),
name:
typeof group.get("Name") === "string"
? stringToPDFString(group.get("Name"))
: null,
intent:
typeof group.get("Intent") === "string"
? stringToPDFString(group.get("Intent"))
: null,
});
} }
config = this._readOptionalContentConfig(defaultConfig, groupRefs); config = this.#readOptionalContentConfig(defaultConfig, groupRefs);
config.groups = groups; config.groups = groups;
} catch (ex) { } catch (ex) {
if (ex instanceof MissingDataException) { if (ex instanceof MissingDataException) {
@ -469,7 +459,65 @@ class Catalog {
return shadow(this, "optionalContentConfig", config); return shadow(this, "optionalContentConfig", config);
} }
_readOptionalContentConfig(config, contentGroupRefs) { #readOptionalContentGroup(groupRef) {
const group = this.xref.fetch(groupRef);
const obj = {
id: groupRef.toString(),
name: null,
intent: null,
usage: {
print: null,
view: null,
},
};
const name = group.get("Name");
if (typeof name === "string") {
obj.name = stringToPDFString(name);
}
let intent = group.getArray("Intent");
if (!Array.isArray(intent)) {
intent = [intent];
}
if (intent.every(i => i instanceof Name)) {
obj.intent = intent.map(i => i.name);
}
const usage = group.get("Usage");
if (!(usage instanceof Dict)) {
return obj;
}
const usageObj = obj.usage;
const print = usage.get("Print");
if (print instanceof Dict) {
const printState = print.get("PrintState");
if (printState instanceof Name) {
switch (printState.name) {
case "ON":
case "OFF":
usageObj.print = { printState: printState.name };
}
}
}
const view = usage.get("View");
if (view instanceof Dict) {
const viewState = view.get("ViewState");
if (viewState instanceof Name) {
switch (viewState.name) {
case "ON":
case "OFF":
usageObj.view = { viewState: viewState.name };
}
}
}
return obj;
}
#readOptionalContentConfig(config, contentGroupRefs) {
function parseOnOff(refs) { function parseOnOff(refs) {
const onParsed = []; const onParsed = [];
if (Array.isArray(refs)) { if (Array.isArray(refs)) {

View File

@ -949,12 +949,26 @@ class PDFDocumentProxy {
} }
/** /**
* @typedef {Object} GetOptionalContentConfigParameters
* @property {string} [intent] - Determines the optional content groups that
* are visible by default; valid values are:
* - 'display' (viewable groups).
* - 'print' (printable groups).
* - 'any' (all groups).
* The default value is 'display'.
*/
/**
* @param {GetOptionalContentConfigParameters} [params] - Optional content
* config parameters.
* @returns {Promise<OptionalContentConfig>} A promise that is resolved with * @returns {Promise<OptionalContentConfig>} A promise that is resolved with
* an {@link OptionalContentConfig} that contains all the optional content * an {@link OptionalContentConfig} that contains all the optional content
* groups (assuming that the document has any). * groups (assuming that the document has any).
*/ */
getOptionalContentConfig() { getOptionalContentConfig({ intent = "display" } = {}) {
return this._transport.getOptionalContentConfig(); const { renderingIntent } = this._transport.getRenderingIntent(intent);
return this._transport.getOptionalContentConfig(renderingIntent);
} }
/** /**
@ -1340,17 +1354,14 @@ class PDFPageProxy {
} }
/** /**
* @param {GetAnnotationsParameters} params - Annotation parameters. * @param {GetAnnotationsParameters} [params] - Annotation parameters.
* @returns {Promise<Array<any>>} A promise that is resolved with an * @returns {Promise<Array<any>>} A promise that is resolved with an
* {Array} of the annotation objects. * {Array} of the annotation objects.
*/ */
getAnnotations({ intent = "display" } = {}) { getAnnotations({ intent = "display" } = {}) {
const intentArgs = this._transport.getRenderingIntent(intent); const { renderingIntent } = this._transport.getRenderingIntent(intent);
return this._transport.getAnnotations( return this._transport.getAnnotations(this._pageIndex, renderingIntent);
this._pageIndex,
intentArgs.renderingIntent
);
} }
/** /**
@ -1411,20 +1422,20 @@ class PDFPageProxy {
annotationMode, annotationMode,
printAnnotationStorage printAnnotationStorage
); );
const { renderingIntent, cacheKey } = intentArgs;
// 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...
this.#pendingCleanup = false; this.#pendingCleanup = false;
// ... and ensure that a delayed cleanup is always aborted. // ... and ensure that a delayed cleanup is always aborted.
this.#abortDelayedCleanup(); this.#abortDelayedCleanup();
if (!optionalContentConfigPromise) { optionalContentConfigPromise ||=
optionalContentConfigPromise = this._transport.getOptionalContentConfig(); this._transport.getOptionalContentConfig(renderingIntent);
}
let intentState = this._intentStates.get(intentArgs.cacheKey); let intentState = this._intentStates.get(cacheKey);
if (!intentState) { if (!intentState) {
intentState = Object.create(null); intentState = Object.create(null);
this._intentStates.set(intentArgs.cacheKey, intentState); this._intentStates.set(cacheKey, intentState);
} }
// Ensure that a pending `streamReader` cancel timeout is always aborted. // Ensure that a pending `streamReader` cancel timeout is always aborted.
@ -1433,9 +1444,7 @@ class PDFPageProxy {
intentState.streamReaderCancelTimeout = null; intentState.streamReaderCancelTimeout = null;
} }
const intentPrint = !!( const intentPrint = !!(renderingIntent & RenderingIntentFlag.PRINT);
intentArgs.renderingIntent & RenderingIntentFlag.PRINT
);
// 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.
@ -1512,6 +1521,12 @@ class PDFPageProxy {
} }
this._stats?.time("Rendering"); this._stats?.time("Rendering");
if (!(optionalContentConfig.renderingIntent & renderingIntent)) {
throw new Error(
"Must use the same `intent`-argument when calling the `PDFPageProxy.render` " +
"and `PDFDocumentProxy.getOptionalContentConfig` methods."
);
}
internalRenderTask.initializeGraphics({ internalRenderTask.initializeGraphics({
transparency, transparency,
optionalContentConfig, optionalContentConfig,
@ -2994,10 +3009,10 @@ class WorkerTransport {
return this.messageHandler.sendWithPromise("GetOutline", null); return this.messageHandler.sendWithPromise("GetOutline", null);
} }
getOptionalContentConfig() { getOptionalContentConfig(renderingIntent) {
return this.messageHandler return this.#cacheSimpleMethod("GetOptionalContentConfig").then(
.sendWithPromise("GetOptionalContentConfig", null) data => new OptionalContentConfig(data, renderingIntent)
.then(results => new OptionalContentConfig(results)); );
} }
getPermissions() { getPermissions() {

View File

@ -13,33 +13,63 @@
* limitations under the License. * limitations under the License.
*/ */
import { info, objectFromMap, unreachable, warn } from "../shared/util.js"; import {
info,
objectFromMap,
RenderingIntentFlag,
unreachable,
warn,
} from "../shared/util.js";
import { MurmurHash3_64 } from "../shared/murmurhash3.js"; import { MurmurHash3_64 } from "../shared/murmurhash3.js";
const INTERNAL = Symbol("INTERNAL"); const INTERNAL = Symbol("INTERNAL");
class OptionalContentGroup { class OptionalContentGroup {
#isDisplay = false;
#isPrint = false;
#userSet = false;
#visible = true; #visible = true;
constructor(name, intent) { constructor(renderingIntent, { name, intent, usage }) {
this.#isDisplay = !!(renderingIntent & RenderingIntentFlag.DISPLAY);
this.#isPrint = !!(renderingIntent & RenderingIntentFlag.PRINT);
this.name = name; this.name = name;
this.intent = intent; this.intent = intent;
this.usage = usage;
} }
/** /**
* @type {boolean} * @type {boolean}
*/ */
get visible() { get visible() {
return this.#visible; if (this.#userSet) {
return this.#visible;
}
if (!this.#visible) {
return false;
}
const { print, view } = this.usage;
if (this.#isDisplay) {
return view?.viewState !== "OFF";
} else if (this.#isPrint) {
return print?.printState !== "OFF";
}
return true;
} }
/** /**
* @ignore * @ignore
*/ */
_setVisible(internal, visible) { _setVisible(internal, visible, userSet = false) {
if (internal !== INTERNAL) { if (internal !== INTERNAL) {
unreachable("Internal method `_setVisible` called."); unreachable("Internal method `_setVisible` called.");
} }
this.#userSet = userSet;
this.#visible = visible; this.#visible = visible;
} }
} }
@ -53,7 +83,9 @@ class OptionalContentConfig {
#order = null; #order = null;
constructor(data) { constructor(data, renderingIntent = RenderingIntentFlag.DISPLAY) {
this.renderingIntent = renderingIntent;
this.name = null; this.name = null;
this.creator = null; this.creator = null;
@ -66,7 +98,7 @@ class OptionalContentConfig {
for (const group of data.groups) { for (const group of data.groups) {
this.#groups.set( this.#groups.set(
group.id, group.id,
new OptionalContentGroup(group.name, group.intent) new OptionalContentGroup(renderingIntent, group)
); );
} }
@ -202,7 +234,7 @@ class OptionalContentConfig {
warn(`Optional content group not found: ${id}`); warn(`Optional content group not found: ${id}`);
return; return;
} }
this.#groups.get(id)._setVisible(INTERNAL, !!visible); this.#groups.get(id)._setVisible(INTERNAL, !!visible, /* userSet = */ true);
this.#cachedGetHash = null; this.#cachedGetHash = null;
} }

View File

@ -684,7 +684,9 @@ class Driver {
} }
task.pdfDoc = doc; task.pdfDoc = doc;
task.optionalContentConfigPromise = doc.getOptionalContentConfig(); task.optionalContentConfigPromise = doc.getOptionalContentConfig({
intent: task.print ? "print" : "display",
});
if (task.optionalContent) { if (task.optionalContent) {
const entries = Object.entries(task.optionalContent), const entries = Object.entries(task.optionalContent),

View File

@ -0,0 +1 @@
https://bugzilla.mozilla.org/attachment.cgi?id=9327375

View File

@ -4016,6 +4016,23 @@
"lastPage": 5, "lastPage": 5,
"type": "eq" "type": "eq"
}, },
{
"id": "bug1826783-display",
"file": "pdfs/bug1826783.pdf",
"md5": "93e706efee15dd7b32d32d66f15a3ea2",
"rounds": 1,
"link": true,
"type": "eq"
},
{
"id": "bug1826783-print",
"file": "pdfs/bug1826783.pdf",
"md5": "93e706efee15dd7b32d32d66f15a3ea2",
"rounds": 1,
"link": true,
"type": "eq",
"print": true
},
{ {
"id": "issue8586", "id": "issue8586",
"file": "pdfs/issue8586.pdf", "file": "pdfs/issue8586.pdf",

View File

@ -1796,7 +1796,6 @@ const PDFViewerApplication = {
pagesOverview: this.pdfViewer.getPagesOverview(), pagesOverview: this.pdfViewer.getPagesOverview(),
printContainer: this.appConfig.printContainer, printContainer: this.appConfig.printContainer,
printResolution: AppOptions.get("printResolution"), printResolution: AppOptions.get("printResolution"),
optionalContentConfigPromise: this.pdfViewer.optionalContentConfigPromise,
printAnnotationStoragePromise: this._printAnnotationStoragePromise, printAnnotationStoragePromise: this._printAnnotationStoragePromise,
}); });
this.forceRendering(); this.forceRendering();

View File

@ -119,15 +119,15 @@ class FirefoxPrintService {
pagesOverview, pagesOverview,
printContainer, printContainer,
printResolution, printResolution,
optionalContentConfigPromise = null,
printAnnotationStoragePromise = null, printAnnotationStoragePromise = null,
}) { }) {
this.pdfDocument = pdfDocument; this.pdfDocument = pdfDocument;
this.pagesOverview = pagesOverview; this.pagesOverview = pagesOverview;
this.printContainer = printContainer; this.printContainer = printContainer;
this._printResolution = printResolution || 150; this._printResolution = printResolution || 150;
this._optionalContentConfigPromise = this._optionalContentConfigPromise = pdfDocument.getOptionalContentConfig({
optionalContentConfigPromise || pdfDocument.getOptionalContentConfig(); intent: "print",
});
this._printAnnotationStoragePromise = this._printAnnotationStoragePromise =
printAnnotationStoragePromise || Promise.resolve(); printAnnotationStoragePromise || Promise.resolve();
} }

View File

@ -258,14 +258,8 @@ if (PDFJSDev.test("GECKOVIEW")) {
const hasWillPrint = const hasWillPrint =
pdfViewer.enableScripting && pdfViewer.enableScripting &&
!!(await pdfDocument.getJSActions())?.WillPrint; !!(await pdfDocument.getJSActions())?.WillPrint;
const hasUnchangedOptionalContent = (
await pdfViewer.optionalContentConfigPromise
).hasInitialVisibility;
result = result = hasUnchangedAnnotations && !hasWillPrint;
hasUnchangedAnnotations &&
!hasWillPrint &&
hasUnchangedOptionalContent;
} catch { } catch {
console.warn("Unable to check if the document can be downloaded."); console.warn("Unable to check if the document can be downloaded.");
} }

View File

@ -182,7 +182,7 @@ class PDFLayerViewer extends BaseTreeViewer {
} }
const pdfDocument = this._pdfDocument; const pdfDocument = this._pdfDocument;
const optionalContentConfig = await (promise || const optionalContentConfig = await (promise ||
pdfDocument.getOptionalContentConfig()); pdfDocument.getOptionalContentConfig({ intent: "display" }));
if (pdfDocument !== this._pdfDocument) { if (pdfDocument !== this._pdfDocument) {
return; // The document was closed while the optional content resolved. return; // The document was closed while the optional content resolved.

View File

@ -13,7 +13,12 @@
* limitations under the License. * limitations under the License.
*/ */
import { AnnotationMode, PixelsPerInch, shadow } from "pdfjs-lib"; import {
AnnotationMode,
PixelsPerInch,
RenderingCancelledException,
shadow,
} from "pdfjs-lib";
import { getXfaHtmlForPrinting } from "./print_utils.js"; import { getXfaHtmlForPrinting } from "./print_utils.js";
let activeService = null; let activeService = null;
@ -58,7 +63,14 @@ function renderPage(
optionalContentConfigPromise, optionalContentConfigPromise,
printAnnotationStorage, printAnnotationStorage,
}; };
return pdfPage.render(renderContext).promise; const renderTask = pdfPage.render(renderContext);
return renderTask.promise.catch(reason => {
if (!(reason instanceof RenderingCancelledException)) {
console.error(reason);
}
throw reason;
});
}); });
} }
@ -68,15 +80,15 @@ class PDFPrintService {
pagesOverview, pagesOverview,
printContainer, printContainer,
printResolution, printResolution,
optionalContentConfigPromise = null,
printAnnotationStoragePromise = null, printAnnotationStoragePromise = null,
}) { }) {
this.pdfDocument = pdfDocument; this.pdfDocument = pdfDocument;
this.pagesOverview = pagesOverview; this.pagesOverview = pagesOverview;
this.printContainer = printContainer; this.printContainer = printContainer;
this._printResolution = printResolution || 150; this._printResolution = printResolution || 150;
this._optionalContentConfigPromise = this._optionalContentConfigPromise = pdfDocument.getOptionalContentConfig({
optionalContentConfigPromise || pdfDocument.getOptionalContentConfig(); intent: "print",
});
this._printAnnotationStoragePromise = this._printAnnotationStoragePromise =
printAnnotationStoragePromise || Promise.resolve(); printAnnotationStoragePromise || Promise.resolve();
this.currentPage = -1; this.currentPage = -1;

View File

@ -189,7 +189,9 @@ class PDFThumbnailViewer {
return; return;
} }
const firstPagePromise = pdfDocument.getPage(1); const firstPagePromise = pdfDocument.getPage(1);
const optionalContentConfigPromise = pdfDocument.getOptionalContentConfig(); const optionalContentConfigPromise = pdfDocument.getOptionalContentConfig({
intent: "display",
});
firstPagePromise firstPagePromise
.then(firstPdfPage => { .then(firstPdfPage => {

View File

@ -781,7 +781,9 @@ class PDFViewer {
const pagesCount = pdfDocument.numPages; const pagesCount = pdfDocument.numPages;
const firstPagePromise = pdfDocument.getPage(1); const firstPagePromise = pdfDocument.getPage(1);
// Rendering (potentially) depends on this, hence fetching it immediately. // Rendering (potentially) depends on this, hence fetching it immediately.
const optionalContentConfigPromise = pdfDocument.getOptionalContentConfig(); const optionalContentConfigPromise = pdfDocument.getOptionalContentConfig({
intent: "display",
});
const permissionsPromise = this.#enablePermissions const permissionsPromise = this.#enablePermissions
? pdfDocument.getPermissions() ? pdfDocument.getPermissions()
: Promise.resolve(); : Promise.resolve();
@ -1822,7 +1824,7 @@ class PDFViewer {
console.error("optionalContentConfigPromise: Not initialized yet."); console.error("optionalContentConfigPromise: Not initialized yet.");
// Prevent issues if the getter is accessed *before* the `onePageRendered` // Prevent issues if the getter is accessed *before* the `onePageRendered`
// promise has resolved; won't (normally) happen in the default viewer. // promise has resolved; won't (normally) happen in the default viewer.
return this.pdfDocument.getOptionalContentConfig(); return this.pdfDocument.getOptionalContentConfig({ intent: "display" });
} }
return this._optionalContentConfigPromise; return this._optionalContentConfigPromise;
} }