[api-minor] Add basic support for the SetOCGState
action (issue 15372)
Note that this patch implements the `SetOCGState`-handling in `PDFLinkService`, rather than as a new method in `OptionalContentConfig`[1], since this action is nothing but a series of `setVisibility`-calls and that it seems quite uncommon in real-world PDF documents. The new functionality also required some tweaks in the `PDFLayerViewer`, to ensure that the `layersView` in the sidebar is updated correctly when the optional-content visibility changes from "outside" of `PDFLayerViewer`. --- [1] We can obviously move this code into `OptionalContentConfig` instead, if deemed necessary, but for an initial implementation I figured that doing it this way might be acceptable.
This commit is contained in:
parent
e9bdbe4574
commit
cc4baa2fe9
@ -329,6 +329,7 @@ class Catalog {
|
||||
url: data.url,
|
||||
unsafeUrl: data.unsafeUrl,
|
||||
newWindow: data.newWindow,
|
||||
setOCGState: data.setOCGState,
|
||||
title: stringToPDFString(title),
|
||||
color: rgbColor,
|
||||
count: Number.isInteger(count) ? count : undefined,
|
||||
@ -1533,6 +1534,38 @@ class Catalog {
|
||||
}
|
||||
break;
|
||||
|
||||
case "SetOCGState":
|
||||
const state = action.get("State");
|
||||
const preserveRB = action.get("PreserveRB");
|
||||
|
||||
if (!Array.isArray(state) || state.length === 0) {
|
||||
break;
|
||||
}
|
||||
const stateArr = [];
|
||||
|
||||
for (const elem of state) {
|
||||
if (elem instanceof Name) {
|
||||
switch (elem.name) {
|
||||
case "ON":
|
||||
case "OFF":
|
||||
case "Toggle":
|
||||
stateArr.push(elem.name);
|
||||
break;
|
||||
}
|
||||
} else if (elem instanceof Ref) {
|
||||
stateArr.push(elem.toString());
|
||||
}
|
||||
}
|
||||
|
||||
if (stateArr.length !== state.length) {
|
||||
break; // Some of the original entries are not valid.
|
||||
}
|
||||
resultObj.setOCGState = {
|
||||
state: stateArr,
|
||||
preserveRB: typeof preserveRB === "boolean" ? preserveRB : true,
|
||||
};
|
||||
break;
|
||||
|
||||
case "JavaScript":
|
||||
const jsAction = action.get("JS");
|
||||
let js;
|
||||
|
@ -597,6 +597,9 @@ class LinkAnnotationElement extends AnnotationElement {
|
||||
} else if (data.action) {
|
||||
this._bindNamedAction(link, data.action);
|
||||
isBound = true;
|
||||
} else if (data.setOCGState) {
|
||||
this.#bindSetOCGState(link, data.setOCGState);
|
||||
isBound = true;
|
||||
} else if (data.dest) {
|
||||
this._bindLink(link, data.dest);
|
||||
isBound = true;
|
||||
@ -678,6 +681,20 @@ class LinkAnnotationElement extends AnnotationElement {
|
||||
link.className = "internalLink";
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind SetOCGState actions to the link element.
|
||||
* @param {Object} link
|
||||
* @param {Object} action
|
||||
*/
|
||||
#bindSetOCGState(link, action) {
|
||||
link.href = this.linkService.getAnchorUrl("");
|
||||
link.onclick = () => {
|
||||
this.linkService.executeSetOCGState(action);
|
||||
return false;
|
||||
};
|
||||
link.className = "internalLink";
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind JS actions to the link element.
|
||||
*
|
||||
|
@ -14,6 +14,7 @@
|
||||
*/
|
||||
|
||||
import { objectFromMap, unreachable, warn } from "../shared/util.js";
|
||||
import { MurmurHash3_64 } from "../shared/murmurhash3.js";
|
||||
|
||||
const INTERNAL = Symbol("INTERNAL");
|
||||
|
||||
@ -44,11 +45,11 @@ class OptionalContentGroup {
|
||||
}
|
||||
|
||||
class OptionalContentConfig {
|
||||
#cachedHasInitialVisibility = true;
|
||||
#cachedGetHash = null;
|
||||
|
||||
#groups = new Map();
|
||||
|
||||
#initialVisibility = null;
|
||||
#initialHash = null;
|
||||
|
||||
#order = null;
|
||||
|
||||
@ -84,10 +85,7 @@ class OptionalContentConfig {
|
||||
}
|
||||
|
||||
// The following code must always run *last* in the constructor.
|
||||
this.#initialVisibility = new Map();
|
||||
for (const [id, group] of this.#groups) {
|
||||
this.#initialVisibility.set(id, group.visible);
|
||||
}
|
||||
this.#initialHash = this.getHash();
|
||||
}
|
||||
|
||||
#evaluateVisibilityExpression(array) {
|
||||
@ -206,20 +204,11 @@ class OptionalContentConfig {
|
||||
}
|
||||
this.#groups.get(id)._setVisible(INTERNAL, !!visible);
|
||||
|
||||
this.#cachedHasInitialVisibility = null;
|
||||
this.#cachedGetHash = null;
|
||||
}
|
||||
|
||||
get hasInitialVisibility() {
|
||||
if (this.#cachedHasInitialVisibility !== null) {
|
||||
return this.#cachedHasInitialVisibility;
|
||||
}
|
||||
for (const [id, group] of this.#groups) {
|
||||
const visible = this.#initialVisibility.get(id);
|
||||
if (group.visible !== visible) {
|
||||
return (this.#cachedHasInitialVisibility = false);
|
||||
}
|
||||
}
|
||||
return (this.#cachedHasInitialVisibility = true);
|
||||
return this.getHash() === this.#initialHash;
|
||||
}
|
||||
|
||||
getOrder() {
|
||||
@ -239,6 +228,18 @@ class OptionalContentConfig {
|
||||
getGroup(id) {
|
||||
return this.#groups.get(id) || null;
|
||||
}
|
||||
|
||||
getHash() {
|
||||
if (this.#cachedGetHash !== null) {
|
||||
return this.#cachedGetHash;
|
||||
}
|
||||
const hash = new MurmurHash3_64();
|
||||
|
||||
for (const [id, group] of this.#groups) {
|
||||
hash.update(`${id}:${group.visible}`);
|
||||
}
|
||||
return (this.#cachedGetHash = hash.hexdigest());
|
||||
}
|
||||
}
|
||||
|
||||
export { OptionalContentConfig };
|
||||
|
1
test/pdfs/.gitignore
vendored
1
test/pdfs/.gitignore
vendored
@ -51,6 +51,7 @@
|
||||
!issue7847_radial.pdf
|
||||
!issue14953.pdf
|
||||
!issue15367.pdf
|
||||
!issue15372.pdf
|
||||
!issue7446.pdf
|
||||
!issue7492.pdf
|
||||
!issue7544.pdf
|
||||
|
BIN
test/pdfs/issue15372.pdf
Normal file
BIN
test/pdfs/issue15372.pdf
Normal file
Binary file not shown.
@ -1540,6 +1540,7 @@ describe("api", function () {
|
||||
url: null,
|
||||
unsafeUrl: undefined,
|
||||
newWindow: undefined,
|
||||
setOCGState: undefined,
|
||||
title: "Händel -- Halle🎆lujah",
|
||||
color: new Uint8ClampedArray([0, 0, 0]),
|
||||
count: undefined,
|
||||
@ -1565,6 +1566,7 @@ describe("api", function () {
|
||||
url: null,
|
||||
unsafeUrl: undefined,
|
||||
newWindow: undefined,
|
||||
setOCGState: undefined,
|
||||
title: "Previous Page",
|
||||
color: new Uint8ClampedArray([0, 0, 0]),
|
||||
count: undefined,
|
||||
@ -1576,6 +1578,32 @@ describe("api", function () {
|
||||
await loadingTask.destroy();
|
||||
});
|
||||
|
||||
it("gets outline, with SetOCGState-actions (issue 15372)", async function () {
|
||||
const loadingTask = getDocument(buildGetDocumentParams("issue15372.pdf"));
|
||||
const pdfDoc = await loadingTask.promise;
|
||||
const outline = await pdfDoc.getOutline();
|
||||
|
||||
expect(Array.isArray(outline)).toEqual(true);
|
||||
expect(outline.length).toEqual(1);
|
||||
|
||||
expect(outline[0]).toEqual({
|
||||
action: null,
|
||||
dest: null,
|
||||
url: null,
|
||||
unsafeUrl: undefined,
|
||||
newWindow: undefined,
|
||||
setOCGState: { state: ["OFF", "ON", "50R"], preserveRB: false },
|
||||
title: "Display Layer",
|
||||
color: new Uint8ClampedArray([0, 0, 0]),
|
||||
count: undefined,
|
||||
bold: false,
|
||||
italic: false,
|
||||
items: [],
|
||||
});
|
||||
|
||||
await loadingTask.destroy();
|
||||
});
|
||||
|
||||
it("gets outline with non-displayable chars", async function () {
|
||||
const loadingTask = getDocument(buildGetDocumentParams("issue14267.pdf"));
|
||||
const pdfDoc = await loadingTask.promise;
|
||||
|
@ -110,6 +110,11 @@ class IPDFLinkService {
|
||||
*/
|
||||
executeNamedAction(action) {}
|
||||
|
||||
/**
|
||||
* @param {Object} action
|
||||
*/
|
||||
executeSetOCGState(action) {}
|
||||
|
||||
/**
|
||||
* @param {number} pageNum - page number.
|
||||
* @param {Object} pageRef - reference to the page.
|
||||
|
@ -34,13 +34,19 @@ class PDFLayerViewer extends BaseTreeViewer {
|
||||
super(options);
|
||||
this.l10n = options.l10n;
|
||||
|
||||
this.eventBus._on("resetlayers", this._resetLayers.bind(this));
|
||||
this.eventBus._on("optionalcontentconfigchanged", evt => {
|
||||
this.#updateLayers(evt.promise);
|
||||
});
|
||||
this.eventBus._on("resetlayers", () => {
|
||||
this.#updateLayers();
|
||||
});
|
||||
this.eventBus._on("togglelayerstree", this._toggleAllTreeItems.bind(this));
|
||||
}
|
||||
|
||||
reset() {
|
||||
super.reset();
|
||||
this._optionalContentConfig = null;
|
||||
this._optionalContentHash = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -59,6 +65,7 @@ class PDFLayerViewer extends BaseTreeViewer {
|
||||
_bindLink(element, { groupId, input }) {
|
||||
const setVisibility = () => {
|
||||
this._optionalContentConfig.setVisibility(groupId, input.checked);
|
||||
this._optionalContentHash = this._optionalContentConfig.getHash();
|
||||
|
||||
this.eventBus.dispatch("optionalcontentconfig", {
|
||||
source: this,
|
||||
@ -123,6 +130,7 @@ class PDFLayerViewer extends BaseTreeViewer {
|
||||
this._dispatchEvent(/* layersCount = */ 0);
|
||||
return;
|
||||
}
|
||||
this._optionalContentHash = optionalContentConfig.getHash();
|
||||
|
||||
const fragment = document.createDocumentFragment(),
|
||||
queue = [{ parent: fragment, groups }];
|
||||
@ -170,23 +178,29 @@ class PDFLayerViewer extends BaseTreeViewer {
|
||||
this._finishRendering(fragment, layersCount, hasAnyNesting);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
async _resetLayers() {
|
||||
async #updateLayers(promise = null) {
|
||||
if (!this._optionalContentConfig) {
|
||||
return;
|
||||
}
|
||||
// Fetch the default optional content configuration...
|
||||
const optionalContentConfig =
|
||||
await this._pdfDocument.getOptionalContentConfig();
|
||||
const pdfDocument = this._pdfDocument;
|
||||
const optionalContentConfig = await (promise ||
|
||||
pdfDocument.getOptionalContentConfig());
|
||||
|
||||
if (pdfDocument !== this._pdfDocument) {
|
||||
return; // The document was closed while the optional content resolved.
|
||||
}
|
||||
if (promise) {
|
||||
if (optionalContentConfig.getHash() === this._optionalContentHash) {
|
||||
return; // The optional content didn't change, hence no need to reset the UI.
|
||||
}
|
||||
} else {
|
||||
this.eventBus.dispatch("optionalcontentconfig", {
|
||||
source: this,
|
||||
promise: Promise.resolve(optionalContentConfig),
|
||||
});
|
||||
}
|
||||
|
||||
// ... and reset the sidebarView to the default state.
|
||||
// Reset the sidebarView to the new state.
|
||||
this.render({
|
||||
optionalContentConfig,
|
||||
pdfDocument: this._pdfDocument,
|
||||
|
@ -493,6 +493,48 @@ class PDFLinkService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} action
|
||||
*/
|
||||
async executeSetOCGState(action) {
|
||||
const pdfDocument = this.pdfDocument;
|
||||
const optionalContentConfig = await this.pdfViewer
|
||||
.optionalContentConfigPromise;
|
||||
|
||||
if (pdfDocument !== this.pdfDocument) {
|
||||
return; // The document was closed while the optional content resolved.
|
||||
}
|
||||
let operator;
|
||||
|
||||
for (const elem of action.state) {
|
||||
switch (elem) {
|
||||
case "ON":
|
||||
case "OFF":
|
||||
case "Toggle":
|
||||
operator = elem;
|
||||
continue;
|
||||
}
|
||||
switch (operator) {
|
||||
case "ON":
|
||||
optionalContentConfig.setVisibility(elem, true);
|
||||
break;
|
||||
case "OFF":
|
||||
optionalContentConfig.setVisibility(elem, false);
|
||||
break;
|
||||
case "Toggle":
|
||||
const group = optionalContentConfig.getGroup(elem);
|
||||
if (group) {
|
||||
optionalContentConfig.setVisibility(elem, !group.visible);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.pdfViewer.optionalContentConfigPromise = Promise.resolve(
|
||||
optionalContentConfig
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} pageNum - page number.
|
||||
* @param {Object} pageRef - reference to the page.
|
||||
@ -676,6 +718,11 @@ class SimpleLinkService {
|
||||
*/
|
||||
executeNamedAction(action) {}
|
||||
|
||||
/**
|
||||
* @param {Object} action
|
||||
*/
|
||||
executeSetOCGState(action) {}
|
||||
|
||||
/**
|
||||
* @param {number} pageNum - page number.
|
||||
* @param {Object} pageRef - reference to the page.
|
||||
|
@ -109,7 +109,7 @@ class PDFOutlineViewer extends BaseTreeViewer {
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_bindLink(element, { url, newWindow, action, dest }) {
|
||||
_bindLink(element, { url, newWindow, action, dest, setOCGState }) {
|
||||
const { linkService } = this;
|
||||
|
||||
if (url) {
|
||||
@ -124,6 +124,14 @@ class PDFOutlineViewer extends BaseTreeViewer {
|
||||
};
|
||||
return;
|
||||
}
|
||||
if (setOCGState) {
|
||||
element.href = linkService.getAnchorUrl("");
|
||||
element.onclick = () => {
|
||||
linkService.executeSetOCGState(setOCGState);
|
||||
return false;
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
element.href = linkService.getDestinationHash(dest);
|
||||
element.onclick = evt => {
|
||||
|
Loading…
Reference in New Issue
Block a user