diff --git a/l10n/en-US/viewer.properties b/l10n/en-US/viewer.properties index 6f7598e3dac62..d31103c008f26 100644 --- a/l10n/en-US/viewer.properties +++ b/l10n/en-US/viewer.properties @@ -137,17 +137,20 @@ print_progress_close=Cancel # (the _label strings are alt text for the buttons, the .title strings are # tooltips) toggle_sidebar.title=Toggle Sidebar -toggle_sidebar_notification.title=Toggle Sidebar (document contains outline/attachments) +toggle_sidebar_notification2.title=Toggle Sidebar (document contains outline/attachments/layers) toggle_sidebar_label=Toggle Sidebar document_outline.title=Show Document Outline (double-click to expand/collapse all items) document_outline_label=Document Outline attachments.title=Show Attachments attachments_label=Attachments +layers.title=Show Layers (double-click to reset all layers to the default state) +layers_label=Layers thumbs.title=Show Thumbnails thumbs_label=Thumbnails findbar.title=Find in Document findbar_label=Find +additional_layers=Additional Layers # LOCALIZATION NOTE (page_canvas): "{{page}}" will be replaced by the page number. page_canvas=Page {{page}} # Thumbnails panel item (tooltip and alt text for images) diff --git a/l10n/sv-SE/viewer.properties b/l10n/sv-SE/viewer.properties index 1f8300f8bcf5c..7c75281c3f77f 100644 --- a/l10n/sv-SE/viewer.properties +++ b/l10n/sv-SE/viewer.properties @@ -137,17 +137,20 @@ print_progress_close=Avbryt # (the _label strings are alt text for the buttons, the .title strings are # tooltips) toggle_sidebar.title=Visa/dölj sidofält -toggle_sidebar_notification.title=Visa/dölj sidofält (dokument innehåller översikt/bilagor) +toggle_sidebar_notification2.title=Visa/dölj sidofält (dokument innehåller översikt/bilagor/lager) toggle_sidebar_label=Visa/dölj sidofält document_outline.title=Visa dokumentdisposition (dubbelklicka för att expandera/komprimera alla objekt) document_outline_label=Dokumentöversikt attachments.title=Visa Bilagor attachments_label=Bilagor +layers.title=Visa lager (dubbelklicka för att återställa alla lager till ursrungligt läge) +layers_label=Lager thumbs.title=Visa miniatyrer thumbs_label=Miniatyrer findbar.title=Sök i dokument findbar_label=Sök +additional_layers=Ytterligare lager # LOCALIZATION NOTE (page_canvas): "{{page}}" will be replaced by the page number. page_canvas=Sida {{page}} # Thumbnails panel item (tooltip and alt text for images) diff --git a/src/core/obj.js b/src/core/obj.js index f4b9d2e85a1bc..0e1ffbcb6cd97 100644 --- a/src/core/obj.js +++ b/src/core/obj.js @@ -355,6 +355,67 @@ class Catalog { return onParsed; } + function parseOrder(refs, nestedLevels = 0) { + if (!Array.isArray(refs)) { + return null; + } + const order = []; + + for (const value of refs) { + if (isRef(value) && contentGroupRefs.includes(value)) { + parsedOrderRefs.put(value); // Handle "hidden" groups, see below. + + order.push(value.toString()); + continue; + } + // Handle nested /Order arrays (see e.g. issue 9462 and bug 1240641). + const nestedOrder = parseNestedOrder(value, nestedLevels); + if (nestedOrder) { + order.push(nestedOrder); + } + } + + if (nestedLevels > 0) { + return order; + } + const hiddenGroups = []; + for (const groupRef of contentGroupRefs) { + if (parsedOrderRefs.has(groupRef)) { + continue; + } + hiddenGroups.push(groupRef.toString()); + } + if (hiddenGroups.length) { + order.push({ name: null, order: hiddenGroups }); + } + + return order; + } + + function parseNestedOrder(ref, nestedLevels) { + if (++nestedLevels > MAX_NESTED_LEVELS) { + warn("parseNestedOrder - reached MAX_NESTED_LEVELS."); + return null; + } + const value = xref.fetchIfRef(ref); + if (!Array.isArray(value)) { + return null; + } + const nestedName = xref.fetchIfRef(value[0]); + if (typeof nestedName !== "string") { + return null; + } + const nestedOrder = parseOrder(value.slice(1), nestedLevels); + if (!nestedOrder || !nestedOrder.length) { + return null; + } + return { name: stringToPDFString(nestedName), order: nestedOrder }; + } + + const xref = this.xref, + parsedOrderRefs = new RefSet(), + MAX_NESTED_LEVELS = 10; + return { name: isString(config.get("Name")) ? stringToPDFString(config.get("Name")) @@ -367,6 +428,8 @@ class Catalog { : null, on: parseOnOff(config.get("ON")), off: parseOnOff(config.get("OFF")), + order: parseOrder(config.get("Order")), + groups: null, }; } diff --git a/src/display/api.js b/src/display/api.js index f9a295dccfc7e..bb7b184a60222 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -778,9 +778,9 @@ class PDFDocumentProxy { } /** - * @returns {Promise} A promise that is resolved - * with an {@link OptionalContentConfig} that has all the optional content - * groups, or `null` if the document does not have any. + * @returns {Promise} A promise that is resolved with + * an {@link OptionalContentConfig} that contains all the optional content + * groups (assuming that the document has any). */ getOptionalContentConfig() { return this._transport.getOptionalContentConfig(); diff --git a/src/display/optional_content_config.js b/src/display/optional_content_config.js index 16308623d12b1..da9f00794f2b6 100644 --- a/src/display/optional_content_config.js +++ b/src/display/optional_content_config.js @@ -26,42 +26,44 @@ class OptionalContentConfig { constructor(data) { this.name = null; this.creator = null; - this.groups = new Map(); + this._order = null; + this._groups = new Map(); if (data === null) { return; } this.name = data.name; this.creator = data.creator; + this._order = data.order; for (const group of data.groups) { - this.groups.set( + this._groups.set( group.id, new OptionalContentGroup(group.name, group.intent) ); } if (data.baseState === "OFF") { - for (const group of this.groups) { + for (const group of this._groups) { group.visible = false; } } for (const on of data.on) { - this.groups.get(on).visible = true; + this._groups.get(on).visible = true; } for (const off of data.off) { - this.groups.get(off).visible = false; + this._groups.get(off).visible = false; } } isVisible(group) { if (group.type === "OCG") { - if (!this.groups.has(group.id)) { + if (!this._groups.has(group.id)) { warn(`Optional content group not found: ${group.id}`); return true; } - return this.groups.get(group.id).visible; + 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. @@ -71,44 +73,44 @@ class OptionalContentConfig { if (!group.policy || group.policy === "AnyOn") { // Default for (const id of group.ids) { - if (!this.groups.has(id)) { + if (!this._groups.has(id)) { warn(`Optional content group not found: ${id}`); return true; } - if (this.groups.get(id).visible) { + 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)) { + if (!this._groups.has(id)) { warn(`Optional content group not found: ${id}`); return true; } - if (!this.groups.get(id).visible) { + 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)) { + if (!this._groups.has(id)) { warn(`Optional content group not found: ${id}`); return true; } - if (!this.groups.get(id).visible) { + 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)) { + if (!this._groups.has(id)) { warn(`Optional content group not found: ${id}`); return true; } - if (this.groups.get(id).visible) { + if (this._groups.get(id).visible) { return false; } } @@ -120,6 +122,35 @@ class OptionalContentConfig { warn(`Unknown group type ${group.type}.`); return true; } + + setVisibility(id, visible = true) { + if (!this._groups.has(id)) { + warn(`Optional content group not found: ${id}`); + return; + } + this._groups.get(id).visible = !!visible; + } + + getOrder() { + if (!this._groups.size) { + return null; + } + if (this._order) { + return this._order.slice(); + } + return Array.from(this._groups.keys()); + } + + getGroups() { + if (!this._groups.size) { + return null; + } + return Object.fromEntries(this._groups); + } + + getGroup(id) { + return this._groups.get(id) || null; + } } export { OptionalContentConfig }; diff --git a/test/driver.js b/test/driver.js index efc28063edeed..2825525eca377 100644 --- a/test/driver.js +++ b/test/driver.js @@ -397,6 +397,8 @@ var Driver = (function DriverClosure() { loadingTask.promise.then( doc => { task.pdfDoc = doc; + task.optionalContentConfigPromise = doc.getOptionalContentConfig(); + this._nextPage(task, failure); }, err => { @@ -605,6 +607,7 @@ var Driver = (function DriverClosure() { canvasContext: ctx, viewport, renderInteractiveForms: renderForms, + optionalContentConfigPromise: task.optionalContentConfigPromise, }; if (renderPrint) { const annotationStorage = task.annotationStorage; diff --git a/web/app.js b/web/app.js index 0ebc726638256..1dae72e18f4f3 100644 --- a/web/app.js +++ b/web/app.js @@ -64,6 +64,7 @@ import { PDFDocumentProperties } from "./pdf_document_properties.js"; import { PDFFindBar } from "./pdf_find_bar.js"; import { PDFFindController } from "./pdf_find_controller.js"; import { PDFHistory } from "./pdf_history.js"; +import { PDFLayerViewer } from "./pdf_layer_viewer.js"; import { PDFLinkService } from "./pdf_link_service.js"; import { PDFOutlineViewer } from "./pdf_outline_viewer.js"; import { PDFPresentationMode } from "./pdf_presentation_mode.js"; @@ -209,6 +210,8 @@ const PDFViewerApplication = { pdfOutlineViewer: null, /** @type {PDFAttachmentViewer} */ pdfAttachmentViewer: null, + /** @type {PDFLayerViewer} */ + pdfLayerViewer: null, /** @type {PDFCursorTools} */ pdfCursorTools: null, /** @type {ViewHistory} */ @@ -445,6 +448,7 @@ const PDFViewerApplication = { this.pdfThumbnailViewer = new PDFThumbnailViewer({ container: appConfig.sidebar.thumbnailView, + eventBus, renderingQueue: pdfRenderingQueue, linkService: pdfLinkService, l10n: this.l10n, @@ -509,6 +513,12 @@ const PDFViewerApplication = { downloadManager, }); + this.pdfLayerViewer = new PDFLayerViewer({ + container: appConfig.sidebar.layersView, + eventBus, + l10n: this.l10n, + }); + this.pdfSidebar = new PDFSidebar({ elements: appConfig.sidebar, pdfViewer: this.pdfViewer, @@ -737,6 +747,7 @@ const PDFViewerApplication = { this.pdfSidebar.reset(); this.pdfOutlineViewer.reset(); this.pdfAttachmentViewer.reset(); + this.pdfLayerViewer.reset(); if (this.pdfHistory) { this.pdfHistory.reset(); @@ -1318,6 +1329,11 @@ const PDFViewerApplication = { pdfDocument.getAttachments().then(attachments => { this.pdfAttachmentViewer.render({ attachments }); }); + // Ensure that the layers accurately reflects the current state in the + // viewer itself, rather than the default state provided by the API. + pdfViewer.optionalContentConfigPromise.then(optionalContentConfig => { + this.pdfLayerViewer.render({ optionalContentConfig, pdfDocument }); + }); }); this._initializePageLabels(pdfDocument); @@ -1667,12 +1683,15 @@ const PDFViewerApplication = { const pagesOverview = this.pdfViewer.getPagesOverview(); const printContainer = this.appConfig.printContainer; const printResolution = AppOptions.get("printResolution"); + const optionalContentConfigPromise = this.pdfViewer + .optionalContentConfigPromise; const printService = PDFPrintServiceFactory.instance.createPrintService( this.pdfDocument, pagesOverview, printContainer, printResolution, + optionalContentConfigPromise, this.l10n ); this.printService = printService; @@ -1748,6 +1767,7 @@ const PDFViewerApplication = { eventBus._on("scalechanged", webViewerScaleChanged); eventBus._on("rotatecw", webViewerRotateCw); eventBus._on("rotateccw", webViewerRotateCcw); + eventBus._on("optionalcontentconfig", webViewerOptionalContentConfig); eventBus._on("switchscrollmode", webViewerSwitchScrollMode); eventBus._on("scrollmodechanged", webViewerScrollModeChanged); eventBus._on("switchspreadmode", webViewerSwitchSpreadMode); @@ -1827,6 +1847,7 @@ const PDFViewerApplication = { eventBus._off("scalechanged", webViewerScaleChanged); eventBus._off("rotatecw", webViewerRotateCw); eventBus._off("rotateccw", webViewerRotateCcw); + eventBus._off("optionalcontentconfig", webViewerOptionalContentConfig); eventBus._off("switchscrollmode", webViewerSwitchScrollMode); eventBus._off("scrollmodechanged", webViewerScrollModeChanged); eventBus._off("switchspreadmode", webViewerSwitchSpreadMode); @@ -2169,12 +2190,15 @@ function webViewerPageMode({ mode }) { view = SidebarView.THUMBS; break; case "bookmarks": - case "outline": + case "outline": // non-standard view = SidebarView.OUTLINE; break; - case "attachments": + case "attachments": // non-standard view = SidebarView.ATTACHMENTS; break; + case "layers": // non-standard + view = SidebarView.LAYERS; + break; case "none": view = SidebarView.NONE; break; @@ -2420,6 +2444,9 @@ function webViewerRotateCw() { function webViewerRotateCcw() { PDFViewerApplication.rotatePages(-90); } +function webViewerOptionalContentConfig(evt) { + PDFViewerApplication.pdfViewer.optionalContentConfigPromise = evt.promise; +} function webViewerSwitchScrollMode(evt) { PDFViewerApplication.pdfViewer.scrollMode = evt.mode; } @@ -3013,7 +3040,7 @@ function apiPageModeToSidebarView(mode) { case "UseAttachments": return SidebarView.ATTACHMENTS; case "UseOC": - // Not implemented, since we don't support Optional Content Groups yet. + return SidebarView.LAYERS; } return SidebarView.NONE; // Default value. } diff --git a/web/base_viewer.js b/web/base_viewer.js index 4e1cbe284a6e0..1cd2e083014bb 100644 --- a/web/base_viewer.js +++ b/web/base_viewer.js @@ -439,6 +439,7 @@ class BaseViewer { const firstPagePromise = pdfDocument.getPage(1); const annotationStorage = pdfDocument.annotationStorage; + const optionalContentConfigPromise = pdfDocument.getOptionalContentConfig(); this._pagesCapability.promise.then(() => { this.eventBus.dispatch("pagesloaded", { @@ -474,6 +475,7 @@ class BaseViewer { firstPagePromise .then(firstPdfPage => { this._firstPageCapability.resolve(firstPdfPage); + this._optionalContentConfigPromise = optionalContentConfigPromise; const scale = this.currentScale; const viewport = firstPdfPage.getViewport({ scale: scale * CSS_UNITS }); @@ -486,8 +488,9 @@ class BaseViewer { eventBus: this.eventBus, id: pageNum, scale, - annotationStorage, defaultViewport: viewport.clone(), + annotationStorage, + optionalContentConfigPromise, renderingQueue: this.renderingQueue, textLayerFactory, textLayerMode: this.textLayerMode, @@ -605,6 +608,7 @@ class BaseViewer { this._buffer = new PDFPageViewBuffer(DEFAULT_CACHE_SIZE); this._location = null; this._pagesRotation = 0; + this._optionalContentConfigPromise = null; this._pagesRequests = new WeakMap(); this._firstPageCapability = createPromiseCapability(); this._onePageRenderedCapability = createPromiseCapability(); @@ -1222,6 +1226,50 @@ class BaseViewer { }); } + /** + * @type {Promise} + */ + get optionalContentConfigPromise() { + if (!this.pdfDocument) { + return Promise.resolve(null); + } + if (!this._optionalContentConfigPromise) { + // Prevent issues if the getter is accessed *before* the `onePageRendered` + // promise has resolved; won't (normally) happen in the default viewer. + return this.pdfDocument.getOptionalContentConfig(); + } + return this._optionalContentConfigPromise; + } + + /** + * @param {Promise} promise - A promise that is + * resolved with an {@link OptionalContentConfig} instance. + */ + set optionalContentConfigPromise(promise) { + if (!(promise instanceof Promise)) { + throw new Error(`Invalid optionalContentConfigPromise: ${promise}`); + } + if (!this.pdfDocument) { + return; + } + if (!this._optionalContentConfigPromise) { + // Ignore the setter *before* the `onePageRendered` promise has resolved, + // since it'll be overwritten anyway; won't happen in the default viewer. + return; + } + this._optionalContentConfigPromise = promise; + + for (const pageView of this._pages) { + pageView.update(pageView.scale, pageView.rotation, promise); + } + this.update(); + + this.eventBus.dispatch("optionalcontentconfigchanged", { + source: this, + promise, + }); + } + /** * @type {number} One of the values in {ScrollMode}. */ diff --git a/web/firefox_print_service.js b/web/firefox_print_service.js index 3098c519e60a6..98b8d0d885371 100644 --- a/web/firefox_print_service.js +++ b/web/firefox_print_service.js @@ -23,7 +23,8 @@ function composePage( pageNumber, size, printContainer, - printResolution + printResolution, + optionalContentConfigPromise ) { const canvas = document.createElement("canvas"); @@ -58,6 +59,7 @@ function composePage( viewport: pdfPage.getViewport({ scale: 1, rotation: size.rotation }), intent: "print", annotationStorage: pdfDocument.annotationStorage, + optionalContentConfigPromise, }; return pdfPage.render(renderContext).promise; }) @@ -84,12 +86,15 @@ function FirefoxPrintService( pdfDocument, pagesOverview, printContainer, - printResolution + printResolution, + optionalContentConfigPromise = null ) { this.pdfDocument = pdfDocument; this.pagesOverview = pagesOverview; this.printContainer = printContainer; this._printResolution = printResolution || 150; + this._optionalContentConfigPromise = + optionalContentConfigPromise || pdfDocument.getOptionalContentConfig(); } FirefoxPrintService.prototype = { @@ -99,6 +104,7 @@ FirefoxPrintService.prototype = { pagesOverview, printContainer, _printResolution, + _optionalContentConfigPromise, } = this; const body = document.querySelector("body"); @@ -110,7 +116,8 @@ FirefoxPrintService.prototype = { /* pageNumber = */ i + 1, pagesOverview[i], printContainer, - _printResolution + _printResolution, + _optionalContentConfigPromise ); } }, @@ -135,13 +142,15 @@ PDFPrintServiceFactory.instance = { pdfDocument, pagesOverview, printContainer, - printResolution + printResolution, + optionalContentConfigPromise ) { return new FirefoxPrintService( pdfDocument, pagesOverview, printContainer, - printResolution + printResolution, + optionalContentConfigPromise ); }, }; diff --git a/web/images/toolbarButton-viewLayers-dark.svg b/web/images/toolbarButton-viewLayers-dark.svg new file mode 100644 index 0000000000000..76b042a952769 --- /dev/null +++ b/web/images/toolbarButton-viewLayers-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/images/toolbarButton-viewLayers.svg b/web/images/toolbarButton-viewLayers.svg new file mode 100644 index 0000000000000..e8687b770485c --- /dev/null +++ b/web/images/toolbarButton-viewLayers.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/pdf_layer_viewer.js b/web/pdf_layer_viewer.js new file mode 100644 index 0000000000000..83a917dcfe3ea --- /dev/null +++ b/web/pdf_layer_viewer.js @@ -0,0 +1,212 @@ +/* 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 { BaseTreeViewer } from "./base_tree_viewer.js"; + +/** + * @typedef {Object} PDFLayerViewerOptions + * @property {HTMLDivElement} container - The viewer element. + * @property {EventBus} eventBus - The application event bus. + * @property {IL10n} l10n - Localization service. + */ + +/** + * @typedef {Object} PDFLayerViewerRenderParameters + * @property {OptionalContentConfig|null} optionalContentConfig - An + * {OptionalContentConfig} instance. + * @property {PDFDocument} pdfDocument - A {PDFDocument} instance. + */ + +class PDFLayerViewer extends BaseTreeViewer { + constructor(options) { + super(options); + this.l10n = options.l10n; + + this.eventBus._on("resetlayers", this._resetLayers.bind(this)); + this.eventBus._on("togglelayerstree", this._toggleAllTreeItems.bind(this)); + } + + reset() { + super.reset(); + this._optionalContentConfig = null; + this._pdfDocument = null; + } + + /** + * @private + */ + _dispatchEvent(layersCount) { + this.eventBus.dispatch("layersloaded", { + source: this, + layersCount, + }); + } + + /** + * @private + */ + _bindLink(element, { groupId, input }) { + const setVisibility = () => { + this._optionalContentConfig.setVisibility(groupId, input.checked); + + this.eventBus.dispatch("optionalcontentconfig", { + source: this, + promise: Promise.resolve(this._optionalContentConfig), + }); + }; + + element.onclick = evt => { + if (evt.target === input) { + setVisibility(); + return true; + } else if (evt.target !== element) { + return true; // The target is the "label", which is handled above. + } + input.checked = !input.checked; + setVisibility(); + return false; + }; + } + + /** + * @private + */ + async _setNestedName(element, { name = null }) { + if (typeof name === "string") { + element.textContent = this._normalizeTextContent(name); + return; + } + element.textContent = await this.l10n.get( + "additional_layers", + null, + "Additional Layers" + ); + element.style.fontStyle = "italic"; + } + + /** + * @private + */ + _addToggleButton(div, { name = null }) { + super._addToggleButton(div, /* hidden = */ name === null); + } + + /** + * @private + */ + _toggleAllTreeItems() { + if (!this._optionalContentConfig) { + return; + } + super._toggleAllTreeItems(); + } + + /** + * @param {PDFLayerViewerRenderParameters} params + */ + render({ optionalContentConfig, pdfDocument }) { + if (this._optionalContentConfig) { + this.reset(); + } + this._optionalContentConfig = optionalContentConfig || null; + this._pdfDocument = pdfDocument || null; + + const groups = optionalContentConfig && optionalContentConfig.getOrder(); + if (!groups) { + this._dispatchEvent(/* layersCount = */ 0); + return; + } + + const fragment = document.createDocumentFragment(), + queue = [{ parent: fragment, groups }]; + let layersCount = 0, + hasAnyNesting = false; + while (queue.length > 0) { + const levelData = queue.shift(); + for (const groupId of levelData.groups) { + const div = document.createElement("div"); + div.className = "treeItem"; + + const element = document.createElement("a"); + div.appendChild(element); + + if (typeof groupId === "object") { + hasAnyNesting = true; + this._addToggleButton(div, groupId); + this._setNestedName(element, groupId); + + const itemsDiv = document.createElement("div"); + itemsDiv.className = "treeItems"; + div.appendChild(itemsDiv); + + queue.push({ parent: itemsDiv, groups: groupId.order }); + } else { + const group = optionalContentConfig.getGroup(groupId); + + const input = document.createElement("input"); + this._bindLink(element, { groupId, input }); + input.type = "checkbox"; + input.id = groupId; + input.checked = group.visible; + + const label = document.createElement("label"); + label.setAttribute("for", groupId); + label.textContent = this._normalizeTextContent(group.name); + + element.appendChild(input); + element.appendChild(label); + + layersCount++; + } + + levelData.parent.appendChild(div); + } + } + if (hasAnyNesting) { + this.container.classList.add("treeWithDeepNesting"); + + this._lastToggleIsShow = + fragment.querySelectorAll(".treeItemsHidden").length === 0; + } + + this.container.appendChild(fragment); + + this._dispatchEvent(layersCount); + } + + /** + * @private + */ + async _resetLayers() { + if (!this._optionalContentConfig) { + return; + } + // Fetch the default optional content configuration... + const optionalContentConfig = await this._pdfDocument.getOptionalContentConfig(); + + this.eventBus.dispatch("optionalcontentconfig", { + source: this, + promise: Promise.resolve(optionalContentConfig), + }); + + // ... and reset the sidebarView to the default state. + this.render({ + optionalContentConfig, + pdfDocument: this._pdfDocument, + }); + } +} + +export { PDFLayerViewer }; diff --git a/web/pdf_page_view.js b/web/pdf_page_view.js index 2ac11aa6945d1..31e54ff0a765a 100644 --- a/web/pdf_page_view.js +++ b/web/pdf_page_view.js @@ -40,6 +40,9 @@ import { viewerCompatibilityParams } from "./viewer_compatibility.js"; * @property {PageViewport} defaultViewport - The page viewport. * @property {AnnotationStorage} [annotationStorage] - Storage for annotation * data in forms. The default value is `null`. + * @property {Promise} [optionalContentConfigPromise] - + * A promise that is resolved with an {@link OptionalContentConfig} instance. + * The default value is `null`. * @property {PDFRenderingQueue} renderingQueue - The rendering queue object. * @property {IPDFTextLayerFactory} textLayerFactory * @property {number} [textLayerMode] - Controls if the text layer used for @@ -83,8 +86,10 @@ class PDFPageView { this.rotation = 0; this.scale = options.scale || DEFAULT_SCALE; this.viewport = defaultViewport; - this._annotationStorage = options.annotationStorage || null; this.pdfPageRotate = defaultViewport.rotation; + this._annotationStorage = options.annotationStorage || null; + this._optionalContentConfigPromise = + options.optionalContentConfigPromise || null; this.hasRestrictedScaling = false; this.textLayerMode = Number.isInteger(options.textLayerMode) ? options.textLayerMode @@ -236,12 +241,15 @@ class PDFPageView { div.appendChild(this.loadingIconDiv); } - update(scale, rotation) { + update(scale, rotation, optionalContentConfigPromise = null) { this.scale = scale || this.scale; // The rotation may be zero. if (typeof rotation !== "undefined") { this.rotation = rotation; } + if (optionalContentConfigPromise instanceof Promise) { + this._optionalContentConfigPromise = optionalContentConfigPromise; + } const totalRotation = (this.rotation + this.pdfPageRotate) % 360; this.viewport = this.viewport.clone({ @@ -660,6 +668,7 @@ class PDFPageView { viewport: this.viewport, enableWebGL: this.enableWebGL, renderInteractiveForms: this.renderInteractiveForms, + optionalContentConfigPromise: this._optionalContentConfigPromise, }; const renderTask = this.pdfPage.render(renderContext); renderTask.onContinue = function (cont) { diff --git a/web/pdf_print_service.js b/web/pdf_print_service.js index 7f2dff0ca901e..534a56342d9e7 100644 --- a/web/pdf_print_service.js +++ b/web/pdf_print_service.js @@ -27,7 +27,8 @@ function renderPage( pdfDocument, pageNumber, size, - printResolution + printResolution, + optionalContentConfigPromise ) { const scratchCanvas = activeService.scratchCanvas; @@ -55,6 +56,7 @@ function renderPage( viewport: pdfPage.getViewport({ scale: 1, rotation: size.rotation }), intent: "print", annotationStorage: pdfDocument.annotationStorage, + optionalContentConfigPromise, }; return pdfPage.render(renderContext).promise; }) @@ -71,12 +73,15 @@ function PDFPrintService( pagesOverview, printContainer, printResolution, + optionalContentConfigPromise = null, l10n ) { this.pdfDocument = pdfDocument; this.pagesOverview = pagesOverview; this.printContainer = printContainer; this._printResolution = printResolution || 150; + this._optionalContentConfigPromise = + optionalContentConfigPromise || pdfDocument.getOptionalContentConfig(); this.l10n = l10n || NullL10n; this.currentPage = -1; // The temporary canvas where renderPage paints one page at a time. @@ -170,7 +175,8 @@ PDFPrintService.prototype = { this.pdfDocument, /* pageNumber = */ index + 1, this.pagesOverview[index], - this._printResolution + this._printResolution, + this._optionalContentConfigPromise ) .then(this.useRenderedPage.bind(this)) .then(function () { @@ -372,6 +378,7 @@ PDFPrintServiceFactory.instance = { pagesOverview, printContainer, printResolution, + optionalContentConfigPromise, l10n ) { if (activeService) { @@ -382,6 +389,7 @@ PDFPrintServiceFactory.instance = { pagesOverview, printContainer, printResolution, + optionalContentConfigPromise, l10n ); return activeService; diff --git a/web/pdf_sidebar.js b/web/pdf_sidebar.js index c7a16251dec91..49ccb39f60689 100644 --- a/web/pdf_sidebar.js +++ b/web/pdf_sidebar.js @@ -52,12 +52,16 @@ const SidebarView = { * the outline view. * @property {HTMLButtonElement} attachmentsButton - The button used to show * the attachments view. + * @property {HTMLButtonElement} layersButton - The button used to show + * the layers view. * @property {HTMLDivElement} thumbnailView - The container in which * the thumbnails are placed. * @property {HTMLDivElement} outlineView - The container in which * the outline is placed. * @property {HTMLDivElement} attachmentsView - The container in which * the attachments are placed. + * @property {HTMLDivElement} layersView - The container in which + * the layers are placed. */ class PDFSidebar { @@ -92,10 +96,12 @@ class PDFSidebar { this.thumbnailButton = elements.thumbnailButton; this.outlineButton = elements.outlineButton; this.attachmentsButton = elements.attachmentsButton; + this.layersButton = elements.layersButton; this.thumbnailView = elements.thumbnailView; this.outlineView = elements.outlineView; this.attachmentsView = elements.attachmentsView; + this.layersView = elements.layersView; this.eventBus = eventBus; this.l10n = l10n; @@ -112,6 +118,7 @@ class PDFSidebar { this.outlineButton.disabled = false; this.attachmentsButton.disabled = false; + this.layersButton.disabled = false; } /** @@ -133,6 +140,10 @@ class PDFSidebar { return this.isOpen && this.active === SidebarView.ATTACHMENTS; } + get isLayersViewVisible() { + return this.isOpen && this.active === SidebarView.LAYERS; + } + /** * @param {number} view - The sidebar view that should become visible, * must be one of the values in {SidebarView}. @@ -196,6 +207,11 @@ class PDFSidebar { return false; } break; + case SidebarView.LAYERS: + if (this.layersButton.disabled) { + return false; + } + break; default: console.error(`PDFSidebar._switchView: "${view}" is not a valid view.`); return false; @@ -217,6 +233,7 @@ class PDFSidebar { "toggled", view === SidebarView.ATTACHMENTS ); + this.layersButton.classList.toggle("toggled", view === SidebarView.LAYERS); // ... and for all views. this.thumbnailView.classList.toggle("hidden", view !== SidebarView.THUMBS); this.outlineView.classList.toggle("hidden", view !== SidebarView.OUTLINE); @@ -224,6 +241,7 @@ class PDFSidebar { "hidden", view !== SidebarView.ATTACHMENTS ); + this.layersView.classList.toggle("hidden", view !== SidebarView.LAYERS); if (forceOpen && !this.isOpen) { this.open(); @@ -331,9 +349,9 @@ class PDFSidebar { this.l10n .get( - "toggle_sidebar_notification.title", + "toggle_sidebar_notification2.title", null, - "Toggle Sidebar (document contains outline/attachments)" + "Toggle Sidebar (document contains outline/attachments/layers)" ) .then(msg => { this.toggleButton.title = msg; @@ -356,6 +374,9 @@ class PDFSidebar { case SidebarView.ATTACHMENTS: this.attachmentsButton.classList.add(UI_NOTIFICATION_CLASS); break; + case SidebarView.LAYERS: + this.layersButton.classList.add(UI_NOTIFICATION_CLASS); + break; } } @@ -375,6 +396,9 @@ class PDFSidebar { case SidebarView.ATTACHMENTS: this.attachmentsButton.classList.remove(UI_NOTIFICATION_CLASS); break; + case SidebarView.LAYERS: + this.layersButton.classList.remove(UI_NOTIFICATION_CLASS); + break; } }; @@ -429,6 +453,13 @@ class PDFSidebar { this.switchView(SidebarView.ATTACHMENTS); }); + this.layersButton.addEventListener("click", () => { + this.switchView(SidebarView.LAYERS); + }); + this.layersButton.addEventListener("dblclick", () => { + this.eventBus.dispatch("resetlayers", { source: this }); + }); + // Disable/enable views. const onTreeLoaded = (count, button, view) => { button.disabled = !count; @@ -454,6 +485,10 @@ class PDFSidebar { ); }); + this.eventBus._on("layersloaded", evt => { + onTreeLoaded(evt.layersCount, this.layersButton, SidebarView.LAYERS); + }); + // Update the thumbnailViewer, if visible, when exiting presentation mode. this.eventBus._on("presentationmodechanged", evt => { if (!evt.active && !evt.switchInProgress && this.isThumbnailViewVisible) { diff --git a/web/pdf_thumbnail_view.js b/web/pdf_thumbnail_view.js index d18e9ca3f84c1..685bf53b6aef4 100644 --- a/web/pdf_thumbnail_view.js +++ b/web/pdf_thumbnail_view.js @@ -29,8 +29,12 @@ const THUMBNAIL_WIDTH = 98; // px * @property {HTMLDivElement} container - The viewer element. * @property {number} id - The thumbnail's unique ID (normally its number). * @property {PageViewport} defaultViewport - The page viewport. + * @property {Promise} [optionalContentConfigPromise] - + * A promise that is resolved with an {@link OptionalContentConfig} instance. + * The default value is `null`. * @property {IPDFLinkService} linkService - The navigation/linking service. * @property {PDFRenderingQueue} renderingQueue - The rendering queue object. + * @property {function} checkSetImageDisabled * @property {boolean} [disableCanvasToImageConversion] - Don't convert the * canvas thumbnails to images. This prevents `toDataURL` calls, but * increases the overall memory usage. The default value is `false`. @@ -91,8 +95,10 @@ class PDFThumbnailView { container, id, defaultViewport, + optionalContentConfigPromise, linkService, renderingQueue, + checkSetImageDisabled, disableCanvasToImageConversion = false, l10n = NullL10n, }) { @@ -104,6 +110,7 @@ class PDFThumbnailView { this.rotation = 0; this.viewport = defaultViewport; this.pdfPageRotate = defaultViewport.rotation; + this._optionalContentConfigPromise = optionalContentConfigPromise || null; this.linkService = linkService; this.renderingQueue = renderingQueue; @@ -111,6 +118,11 @@ class PDFThumbnailView { this.renderTask = null; this.renderingState = RenderingStates.INITIAL; this.resume = null; + this._checkSetImageDisabled = + checkSetImageDisabled || + function () { + return false; + }; this.disableCanvasToImageConversion = disableCanvasToImageConversion; this.pageWidth = this.viewport.width; @@ -345,6 +357,7 @@ class PDFThumbnailView { const renderContext = { canvasContext: ctx, viewport: drawViewport, + optionalContentConfigPromise: this._optionalContentConfigPromise, }; const renderTask = (this.renderTask = pdfPage.render(renderContext)); renderTask.onContinue = renderContinueCallback; @@ -361,6 +374,9 @@ class PDFThumbnailView { } setImage(pageView) { + if (this._checkSetImageDisabled()) { + return; + } if (this.renderingState !== RenderingStates.INITIAL) { return; } diff --git a/web/pdf_thumbnail_viewer.js b/web/pdf_thumbnail_viewer.js index 70be7b05e8f4e..46adb51e615f4 100644 --- a/web/pdf_thumbnail_viewer.js +++ b/web/pdf_thumbnail_viewer.js @@ -29,6 +29,7 @@ const THUMBNAIL_SELECTED_CLASS = "selected"; * @typedef {Object} PDFThumbnailViewerOptions * @property {HTMLDivElement} container - The container for the thumbnail * elements. + * @property {EventBus} eventBus - The application event bus. * @property {IPDFLinkService} linkService - The navigation/linking service. * @property {PDFRenderingQueue} renderingQueue - The rendering queue object. * @property {IL10n} l10n - Localization service. @@ -43,7 +44,13 @@ class PDFThumbnailViewer { /** * @param {PDFThumbnailViewerOptions} options */ - constructor({ container, linkService, renderingQueue, l10n = NullL10n }) { + constructor({ + container, + eventBus, + linkService, + renderingQueue, + l10n = NullL10n, + }) { this.container = container; this.linkService = linkService; this.renderingQueue = renderingQueue; @@ -51,6 +58,12 @@ class PDFThumbnailViewer { this.scroll = watchScroll(this.container, this._scrollUpdated.bind(this)); this._resetView(); + + eventBus._on("optionalcontentconfigchanged", () => { + // Ensure that the thumbnails always render with the *default* optional + // content configuration. + this._setImageDisabled = true; + }); } /** @@ -151,7 +164,9 @@ class PDFThumbnailViewer { this._currentPageNumber = 1; this._pageLabels = null; this._pagesRotation = 0; + this._optionalContentConfigPromise = null; this._pagesRequests = new WeakMap(); + this._setImageDisabled = false; // Remove the thumbnails from the DOM. this.container.textContent = ""; @@ -167,19 +182,28 @@ class PDFThumbnailViewer { if (!pdfDocument) { return; } + const firstPagePromise = pdfDocument.getPage(1); + const optionalContentConfigPromise = pdfDocument.getOptionalContentConfig(); - pdfDocument - .getPage(1) + firstPagePromise .then(firstPdfPage => { + this._optionalContentConfigPromise = optionalContentConfigPromise; + const pagesCount = pdfDocument.numPages; const viewport = firstPdfPage.getViewport({ scale: 1 }); + const checkSetImageDisabled = () => { + return this._setImageDisabled; + }; + for (let pageNum = 1; pageNum <= pagesCount; ++pageNum) { const thumbnail = new PDFThumbnailView({ container: this.container, id: pageNum, defaultViewport: viewport.clone(), + optionalContentConfigPromise, linkService: this.linkService, renderingQueue: this.renderingQueue, + checkSetImageDisabled, disableCanvasToImageConversion: false, l10n: this.l10n, }); diff --git a/web/viewer.css b/web/viewer.css index 3a3f00127cf5f..df343a419c099 100644 --- a/web/viewer.css +++ b/web/viewer.css @@ -73,6 +73,7 @@ --toolbarButton-viewThumbnail-icon: url(images/toolbarButton-viewThumbnail.svg); --toolbarButton-viewOutline-icon: url(images/toolbarButton-viewOutline.svg); --toolbarButton-viewAttachments-icon: url(images/toolbarButton-viewAttachments.svg); + --toolbarButton-viewLayers-icon: url(images/toolbarButton-viewLayers.svg); --toolbarButton-search-icon: url(images/toolbarButton-search.svg); --findbarButton-previous-icon: url(images/findbarButton-previous.svg); --findbarButton-next-icon: url(images/findbarButton-next.svg); @@ -143,6 +144,7 @@ --toolbarButton-viewThumbnail-icon: url(images/toolbarButton-viewThumbnail-dark.svg); --toolbarButton-viewOutline-icon: url(images/toolbarButton-viewOutline-dark.svg); --toolbarButton-viewAttachments-icon: url(images/toolbarButton-viewAttachments-dark.svg); + --toolbarButton-viewLayers-icon: url(images/toolbarButton-viewLayers-dark.svg); --toolbarButton-search-icon: url(images/toolbarButton-search-dark.svg); --findbarButton-previous-icon: url(images/findbarButton-previous-dark.svg); --findbarButton-next-icon: url(images/findbarButton-next-dark.svg); @@ -1066,6 +1068,10 @@ html[dir="rtl"] #viewOutline.toolbarButton::before { content: var(--toolbarButton-viewAttachments-icon); } +#viewLayers.toolbarButton::before { + content: var(--toolbarButton-viewLayers-icon); +} + #viewFind.toolbarButton::before { content: var(--toolbarButton-search-icon); } @@ -1339,7 +1345,8 @@ a:focus > .thumbnail > .thumbnailSelectionRing, } #outlineView, -#attachmentsView { +#attachmentsView, +#layersView { position: absolute; width: calc(100% - 8px); top: 0; @@ -1383,6 +1390,16 @@ html[dir='rtl'] .treeItem > a { padding: 2px 4px 5px 0; } +#layersView .treeItem > a > * { + cursor: pointer; +} +html[dir='ltr'] #layersView .treeItem > a > label { + padding-left: 4px; +} +html[dir='rtl'] #layersView .treesItem > a > label { + padding-right: 4px; +} + .treeItemToggler { position: relative; height: 0; diff --git a/web/viewer.html b/web/viewer.html index 75beacd30b87d..1ae6848768b10 100644 --- a/web/viewer.html +++ b/web/viewer.html @@ -86,6 +86,9 @@ +
@@ -95,6 +98,8 @@
+ diff --git a/web/viewer.js b/web/viewer.js index f51cdd44e1d7d..44f20ecc20a8e 100644 --- a/web/viewer.js +++ b/web/viewer.js @@ -121,10 +121,12 @@ function getViewerConfiguration() { thumbnailButton: document.getElementById("viewThumbnail"), outlineButton: document.getElementById("viewOutline"), attachmentsButton: document.getElementById("viewAttachments"), + layersButton: document.getElementById("viewLayers"), // Views thumbnailView: document.getElementById("thumbnailView"), outlineView: document.getElementById("outlineView"), attachmentsView: document.getElementById("attachmentsView"), + layersView: document.getElementById("layersView"), }, sidebarResizer: { outerContainer: document.getElementById("outerContainer"),