diff --git a/src/core/catalog.js b/src/core/catalog.js index 3a0b3bb26..928f564ac 100644 --- a/src/core/catalog.js +++ b/src/core/catalog.js @@ -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; diff --git a/src/display/annotation_layer.js b/src/display/annotation_layer.js index 91cc8a1c9..b4a7629e2 100644 --- a/src/display/annotation_layer.js +++ b/src/display/annotation_layer.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. * diff --git a/src/display/optional_content_config.js b/src/display/optional_content_config.js index baa4aeb5c..d96c70024 100644 --- a/src/display/optional_content_config.js +++ b/src/display/optional_content_config.js @@ -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 }; diff --git a/test/pdfs/.gitignore b/test/pdfs/.gitignore index e17ce2b67..119701c04 100644 --- a/test/pdfs/.gitignore +++ b/test/pdfs/.gitignore @@ -51,6 +51,7 @@ !issue7847_radial.pdf !issue14953.pdf !issue15367.pdf +!issue15372.pdf !issue7446.pdf !issue7492.pdf !issue7544.pdf diff --git a/test/pdfs/issue15372.pdf b/test/pdfs/issue15372.pdf new file mode 100644 index 000000000..dbebb01b5 Binary files /dev/null and b/test/pdfs/issue15372.pdf differ diff --git a/test/unit/api_spec.js b/test/unit/api_spec.js index 0f7b38c4e..a5fba7314 100644 --- a/test/unit/api_spec.js +++ b/test/unit/api_spec.js @@ -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; diff --git a/web/interfaces.js b/web/interfaces.js index 8131a6cfc..7cce362fc 100644 --- a/web/interfaces.js +++ b/web/interfaces.js @@ -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. diff --git a/web/pdf_layer_viewer.js b/web/pdf_layer_viewer.js index d068224c6..b19bcf3e1 100644 --- a/web/pdf_layer_viewer.js +++ b/web/pdf_layer_viewer.js @@ -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()); - this.eventBus.dispatch("optionalcontentconfig", { - source: this, - promise: Promise.resolve(optionalContentConfig), - }); + 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, diff --git a/web/pdf_link_service.js b/web/pdf_link_service.js index 4795ce0e4..52cba0ec2 100644 --- a/web/pdf_link_service.js +++ b/web/pdf_link_service.js @@ -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. diff --git a/web/pdf_outline_viewer.js b/web/pdf_outline_viewer.js index 46d20937d..9bcb8179a 100644 --- a/web/pdf_outline_viewer.js +++ b/web/pdf_outline_viewer.js @@ -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 => {