// Copyright 2019 The Chromium Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import {assert} from 'chrome://resources/js/assert_ts.js'; import {PromiseResolver} from 'chrome://resources/js/promise_resolver.js'; import {NamedDestinationMessageData, SaveRequestType} from './constants.js'; import {PdfPluginElement} from './internal_plugin.js'; import {PinchPhase, Viewport} from './viewport.js'; export interface MessageData { type: string; messageId?: string; } export interface SaveAttachmentMessageData { type: string; dataToSave: ArrayBuffer; messageId: string; } interface SaveDataMessageData { dataToSave: ArrayBuffer; token: string; fileName: string; } export interface PrintPreviewParams { type: string; url: string; grayscale: boolean; modifiable: boolean; pageNumbers: number[]; } interface ThumbnailMessageData { imageData: ArrayBuffer; width: number; height: number; } /** * Creates a cryptographically secure pseudorandom 128-bit token. * @return The generated token as a hex string. */ function createToken(): string { const randomBytes = new Uint8Array(16); window.crypto.getRandomValues(randomBytes); return Array.from(randomBytes, b => b.toString(16).padStart(2, '0')).join(''); } export interface ContentController { isActive: boolean; getEventTarget(): EventTarget; beforeZoom(): void; afterZoom(): void; viewportChanged(): void; rotateClockwise(): void; rotateCounterclockwise(): void; setDisplayAnnotations(displayAnnotations: boolean): void; setTwoUpView(enableTwoUpView: boolean): void; /** Triggers printing of the current document. */ print(): void; /** Undo an edit action. */ undo(): void; /** Redo an edit action. */ redo(): void; /** * Requests that the current document be saved. * @param requestType The type of save request. If ANNOTATION, a response is * required, otherwise the controller may save the document to disk * internally. */ save(requestType: SaveRequestType): Promise<{ fileName: string, dataToSave: ArrayBuffer, editModeForTesting?: boolean, }>; /** * Requests that the attachment at a certain index be saved. * @param index The index of the attachment to be saved. */ saveAttachment(index: number): Promise; /** Loads PDF document from `data` activates UI. */ load(fileName: string, data: ArrayBuffer): Promise; /** Unloads the current document and removes the UI. */ unload(): void; } /** Event types dispatched by the plugin controller. */ export enum PluginControllerEventType { IS_ACTIVE_CHANGED = 'PluginControllerEventType.IS_ACTIVE_CHANGED', PLUGIN_MESSAGE = 'PluginControllerEventType.PLUGIN_MESSAGE', } /** * PDF plugin controller singleton, responsible for communicating with the * embedded plugin element. Dispatches a * `PluginControllerEventType.PLUGIN_MESSAGE` event containing the message from * the plugin, if a message type not handled by this controller is received. */ export class PluginController implements ContentController { private eventTarget_: EventTarget = new EventTarget(); private isActive_: boolean = false; private plugin_: PdfPluginElement; private delayedMessages_: Array<{message: any, transfer?: Transferable[]}>| null = []; private viewport_: Viewport; private getIsUserInitiatedCallback_: () => boolean; private getLoadedCallback_: () => Promise| null; private pendingTokens_: Map>; private requestResolverMap_: Map>; private uidCounter_: number = 1; init( plugin: HTMLEmbedElement, viewport: Viewport, getIsUserInitiatedCallback: () => boolean, getLoadedCallback: () => Promise| null) { this.plugin_ = plugin as PdfPluginElement; this.plugin_.addEventListener( 'message', e => this.handlePluginMessage_(e as MessageEvent), false); this.plugin_.postMessage = (message, transfer) => { this.delayedMessages_!.push({message, transfer}); }; this.viewport_ = viewport; this.getIsUserInitiatedCallback_ = getIsUserInitiatedCallback; this.getLoadedCallback_ = getLoadedCallback; this.pendingTokens_ = new Map(); this.requestResolverMap_ = new Map(); this.viewport_.setContent(this.plugin_); this.viewport_.setRemoteContent(this.plugin_); } get isActive(): boolean { // Check whether `plugin_` is defined as a signal that `init()` was called. return !!this.plugin_ && this.isActive_; } set isActive(isActive: boolean) { const wasActive = this.isActive; this.isActive_ = isActive; if (this.isActive === wasActive) { return; } this.eventTarget_.dispatchEvent(new CustomEvent( PluginControllerEventType.IS_ACTIVE_CHANGED, {detail: this.isActive})); } private createUid_(): number { return this.uidCounter_++; } getEventTarget() { return this.eventTarget_; } viewportChanged() {} redo() {} undo() {} /** * Notify the plugin to stop reacting to scroll events while zoom is taking * place to avoid flickering. */ beforeZoom() { this.postMessage_({type: 'stopScrolling'}); if (this.viewport_.pinchPhase === PinchPhase.START) { const position = this.viewport_.position; const zoom = this.viewport_.getZoom(); const pinchPhase = this.viewport_.pinchPhase; const layoutOptions = this.viewport_.getLayoutOptions(); this.postMessage_({ type: 'viewport', userInitiated: true, zoom: zoom, layoutOptions: layoutOptions, xOffset: position.x, yOffset: position.y, pinchPhase: pinchPhase, }); } } /** * Notify the plugin of the zoom change and to continue reacting to scroll * events. */ afterZoom() { const position = this.viewport_.position; const zoom = this.viewport_.getZoom(); const layoutOptions = this.viewport_.getLayoutOptions(); const pinchVector = this.viewport_.pinchPanVector || {x: 0, y: 0}; const pinchCenter = this.viewport_.pinchCenter || {x: 0, y: 0}; const pinchPhase = this.viewport_.pinchPhase; this.postMessage_({ type: 'viewport', userInitiated: this.getIsUserInitiatedCallback_(), zoom: zoom, layoutOptions: layoutOptions, xOffset: position.x, yOffset: position.y, pinchPhase: pinchPhase, pinchX: pinchCenter.x, pinchY: pinchCenter.y, pinchVectorX: pinchVector.x, pinchVectorY: pinchVector.y, }); } /** * Post a message to the plugin. Some messages will cause an async reply to be * received through handlePluginMessage_(). */ private postMessage_(message: M) { this.plugin_.postMessage(message); } /** * Post a message to the plugin, for cases where direct response is expected * from the plugin. * @return A promise holding the response from the plugin. */ private postMessageWithReply_(message: M): Promise { const promiseResolver = new PromiseResolver(); message.messageId = `${message.type}_${this.createUid_()}`; this.requestResolverMap_.set(message.messageId, promiseResolver); this.postMessage_(message); return promiseResolver.promise; } rotateClockwise() { this.postMessage_({type: 'rotateClockwise'}); } rotateCounterclockwise() { this.postMessage_({type: 'rotateCounterclockwise'}); } setDisplayAnnotations(displayAnnotations: boolean) { this.postMessage_({ type: 'displayAnnotations', display: displayAnnotations, }); } setTwoUpView(enableTwoUpView: boolean) { this.postMessage_({ type: 'setTwoUpView', enableTwoUpView: enableTwoUpView, }); } print() { this.postMessage_({type: 'print'}); } selectAll() { this.postMessage_({type: 'selectAll'}); } getSelectedText(): Promise<{selectedText: string}> { return this.postMessageWithReply_({type: 'getSelectedText'}); } /** * Post a thumbnail request message to the plugin. * @return A promise holding the thumbnail response from the plugin. */ requestThumbnail(page: number): Promise { return this.postMessageWithReply_({ type: 'getThumbnail', // The plugin references pages using zero-based indices. page: page - 1, }); } resetPrintPreviewMode(printPreviewParams: PrintPreviewParams) { this.postMessage_({ type: 'resetPrintPreviewMode', url: printPreviewParams.url, grayscale: printPreviewParams.grayscale, // If the PDF isn't modifiable we send 0 as the page count so that no // blank placeholder pages get appended to the PDF. pageCount: (printPreviewParams.modifiable ? printPreviewParams.pageNumbers.length : 0), }); } /** * @param color New color, as a 32-bit integer, of the PDF plugin * background. */ setBackgroundColor(color: number) { this.postMessage_({ type: 'setBackgroundColor', color: color, }); } loadPreviewPage(url: string, index: number) { this.postMessage_({type: 'loadPreviewPage', url: url, index: index}); } getPasswordComplete(password: string) { this.postMessage_({type: 'getPasswordComplete', password: password}); } /** * @return A promise holding the named destination information from the * plugin. */ getNamedDestination(destination: string): Promise { return this.postMessageWithReply_({ type: 'getNamedDestination', namedDestination: destination, }); } setPresentationMode(enablePresentationMode: boolean) { this.postMessage_({ type: 'setPresentationMode', enablePresentationMode, }); } save(requestType: SaveRequestType) { const resolver = new PromiseResolver<{fileName: string, dataToSave: ArrayBuffer}>(); const newToken = createToken(); this.pendingTokens_.set(newToken, resolver); this.postMessage_({ type: 'save', token: newToken, saveRequestType: requestType, }); return resolver.promise; } saveAttachment(index: number): Promise { return this.postMessageWithReply_({ type: 'saveAttachment', attachmentIndex: index, }); } async load(_fileName: string, data: ArrayBuffer) { // Load `data` into the PDF plugin. The plugin transfers the data to be // loaded within the inner frame. this.viewport_.setRemoteContent(this.plugin_); this.plugin_.postMessage({type: 'loadArray', dataToLoad: data}, [data]); this.plugin_.style.display = 'block'; await this.getLoadedCallback_(); this.isActive = true; } unload() { this.plugin_.style.display = 'none'; this.isActive = false; } /** * Binds an event handler for messages received from the plugin. * * TODO(crbug.com/1228987): Remove this method when a permanent postMessage() * bridge is implemented for the viewer. */ bindMessageHandler(port: MessagePort) { assert(this.delayedMessages_ !== null); assert(this.plugin_); const delayedMessages = this.delayedMessages_; this.delayedMessages_ = null; this.plugin_.postMessage = port.postMessage.bind(port); port.onmessage = e => this.handlePluginMessage_(e); for (const {message, transfer} of delayedMessages) { this.plugin_.postMessage(message, transfer); } } /** * An event handler for handling message events received from the plugin. */ private handlePluginMessage_(messageEvent: MessageEvent) { const messageData = messageEvent.data; // Handle case where this Plugin->Page message is a direct response // to a previous Page->Plugin message if (messageData.messageId !== undefined) { const resolver = this.requestResolverMap_.get(messageData.messageId) || null; assert(resolver !== null); this.requestResolverMap_.delete(messageData.messageId); resolver.resolve(messageData); return; } switch (messageData.type) { case 'gesture': this.viewport_.dispatchGesture(messageData.gesture); break; case 'swipe': this.viewport_.dispatchSwipe(messageData.direction); break; case 'goToPage': this.viewport_.goToPage(messageData.page); break; case 'setScrollPosition': this.viewport_.scrollTo(messageData); break; case 'scrollBy': this.viewport_.scrollBy(messageData); break; case 'syncScrollFromRemote': this.viewport_.syncScrollFromRemote(messageData); break; case 'ackScrollToRemote': this.viewport_.ackScrollToRemote(messageData); break; case 'saveData': this.saveData_(messageData); break; case 'consumeSaveToken': const resolver = this.pendingTokens_.get(messageData.token); assert(resolver); assert(this.pendingTokens_.delete(messageData.token)); resolver.resolve(null); break; default: this.eventTarget_.dispatchEvent(new CustomEvent( PluginControllerEventType.PLUGIN_MESSAGE, {detail: messageData})); } } /** Handles the pdf file buffer received from the plugin. */ private saveData_(messageData: SaveDataMessageData) { // Verify a token that was created by this instance is included to avoid // being spammed. const resolver = this.pendingTokens_.get(messageData.token); assert(resolver); assert(this.pendingTokens_.delete(messageData.token)); if (!messageData.dataToSave) { resolver.reject(); return; } // Verify the file size and the first bytes to make sure it's a PDF. Cap at // 100 MB. This cap should be kept in sync with and is also enforced in // pdf/out_of_process_instance.cc. const MIN_FILE_SIZE = '%PDF1.0'.length; const MAX_FILE_SIZE = 100 * 1000 * 1000; const buffer = messageData.dataToSave; const bufView = new Uint8Array(buffer); assert( bufView.length <= MAX_FILE_SIZE, `File too large to be saved: ${bufView.length} bytes.`); assert(bufView.length >= MIN_FILE_SIZE); assert( String.fromCharCode(bufView[0], bufView[1], bufView[2], bufView[3]) === '%PDF'); resolver.resolve(messageData); } static getInstance(): PluginController { return instance || (instance = new PluginController()); } } let instance: PluginController|null = null;