blob: ca3a8c9653f1bd9dbc92020cc69d25f3a9b8a125 [file] [log] [blame]
// Copyright 2019 The Chromium Authors. All rights reserved.
// 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.m.js';
import {NativeEventTarget as EventTarget} from 'chrome://resources/js/cr/event_target.m.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.m.js';
import {PromiseResolver} from 'chrome://resources/js/promise_resolver.m.js';
import {NamedDestinationMessageData, Point, SaveRequestType} from './constants.js';
import {Gesture} from './gesture_detector.js';
import {UnseasonedPdfPluginElement} from './internal_plugin.js';
import {PartialPoint, PinchPhase, Viewport} from './viewport.js';
/** @typedef {{type: string, messageId: (string|undefined)}} */
export let MessageData;
/**
* @typedef {{
* type: string,
* dataToSave: Array,
* messageId: string,
* }}
*/
let SaveAttachmentMessageData;
/**
* @typedef {{
* dataToSave: Array,
* token: string,
* fileName: string
* }}
*/
let SaveDataMessageData;
/**
* @typedef {{
* type: string,
* url: string,
* grayscale: boolean,
* modifiable: boolean,
* pageNumbers: !Array<number>
* }}
*/
export let PrintPreviewParams;
/**
* @typedef {{
* imageData: !ArrayBuffer,
* width: number,
* height: number,
* }}
*/
let ThumbnailMessageData;
/**
* Creates a cryptographically secure pseudorandom 128-bit token.
* @return {string} The generated token as a hex string.
*/
function createToken() {
const randomBytes = new Uint8Array(16);
window.crypto.getRandomValues(randomBytes);
return Array.from(randomBytes, b => b.toString(16).padStart(2, '0')).join('');
}
/** @interface */
export class ContentController {
constructor() {}
/** @return {!EventTarget} */
getEventTarget() {}
/** @return {boolean} */
get isActive() {}
/** @param {boolean} isActive */
set isActive(isActive) {}
beforeZoom() {}
afterZoom() {}
viewportChanged() {}
rotateClockwise() {}
rotateCounterclockwise() {}
/** @param {boolean} displayAnnotations */
setDisplayAnnotations(displayAnnotations) {}
/** @param {boolean} enableTwoUpView */
setTwoUpView(enableTwoUpView) {}
/** Triggers printing of the current document. */
print() {}
/** Undo an edit action. */
undo() {}
/** Redo an edit action. */
redo() {}
/**
* Requests that the current document be saved.
* @param {!SaveRequestType} requestType The type of save request. If
* ANNOTATION, a response is required, otherwise the controller may save
* the document to disk internally.
* @return {!Promise<!{fileName: string, dataToSave: !ArrayBuffer}>}
*/
save(requestType) {}
/**
* Requests that the attachment at a certain index be saved.
* @param {number} index The index of the attachment to be saved.
* @return {!Promise<!SaveAttachmentMessageData>}
* @abstract
*/
saveAttachment(index) {}
/**
* Loads PDF document from `data` activates UI.
* @param {string} fileName
* @param {!ArrayBuffer} data
* @return {!Promise<void>}
*/
load(fileName, data) {}
/** Unloads the current document and removes the UI. */
unload() {}
}
/**
* Event types dispatched by the plugin controller.
* @enum {string}
*/
export const 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.
* @implements {ContentController}
*/
export class PluginController {
constructor() {
/** @private {!EventTarget} */
this.eventTarget_ = new EventTarget();
/** @private {boolean} */
this.isActive_ = false;
/** @private {!HTMLEmbedElement} */
this.plugin_;
/** @private {?UnseasonedPdfPluginElement} */
this.unseasonedPlugin_ = null;
/** @private {?Array} */
this.unseasonedDelayedMessages_ = null;
/** @private {!Viewport} */
this.viewport_;
/** @private {!function():boolean} */
this.getIsUserInitiatedCallback_;
/** @private {!function():?Promise} */
this.getLoadedCallback_;
/** @private {!Map<string, PromiseResolver>} */
this.pendingTokens_;
/** @private {!Map<string, !PromiseResolver>} */
this.requestResolverMap_;
/**
* Counter for use with createUid
* @private {number}
*/
this.uidCounter_ = 1;
}
/**
* @param {!HTMLEmbedElement} plugin
* @param {!Viewport} viewport
* @param {function():boolean} getIsUserInitiatedCallback
* @param {function():?Promise} getLoadedCallback
*/
init(plugin, viewport, getIsUserInitiatedCallback, getLoadedCallback) {
this.plugin_ = plugin;
this.viewport_ = viewport;
this.getIsUserInitiatedCallback_ = getIsUserInitiatedCallback;
this.getLoadedCallback_ = getLoadedCallback;
this.pendingTokens_ = new Map();
this.requestResolverMap_ = new Map();
this.viewport_.setContent(this.plugin_);
this.plugin_.addEventListener(
'message', e => this.handlePluginMessage_(e), false);
if (!this.plugin_.postMessage) {
this.unseasonedPlugin_ =
/** @type {!UnseasonedPdfPluginElement} */ (this.plugin_);
this.unseasonedDelayedMessages_ = [];
this.unseasonedPlugin_.postMessage =
/**
* @param {*} message
* @param {!Array<!Transferable>=} transfer
*/
(message, transfer) => {
this.unseasonedDelayedMessages_.push({message, transfer});
};
this.viewport_.setRemoteContent(this.unseasonedPlugin_);
}
}
/**
* override
* Note: Not using @override because it breaks TypeScript.
* @return {boolean}
*/
get isActive() {
// Check whether `plugin_` is defined as a signal that `init()` was called.
return !!this.plugin_ && this.isActive_;
}
/**
* override
* @param {boolean} isActive
*/
set isActive(isActive) {
const wasActive = this.isActive;
this.isActive_ = isActive;
if (this.isActive === wasActive) {
return;
}
this.eventTarget_.dispatchEvent(new CustomEvent(
PluginControllerEventType.IS_ACTIVE_CHANGED, {detail: this.isActive}));
}
/**
* @return {number} A new unique ID.
* @private
*/
createUid_() {
return this.uidCounter_++;
}
/**
* override
* @return {!EventTarget}
*/
getEventTarget() {
return this.eventTarget_;
}
/**
* @param {number} x
* @param {number} y
*/
updateScroll(x, y) {
if (this.unseasonedPlugin_) {
// Ignore "local" scroll events in unseasoned mode, as these are synthetic
// events generated by the remote scrolling implementation.
return;
}
this.postMessage_({type: 'updateScroll', x, y});
}
viewportChanged() {}
redo() {}
undo() {}
/**
* Notify the plugin to stop reacting to scroll events while zoom is taking
* place to avoid flickering.
*
* override
*/
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.
*
* override
*/
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 PPAPI plugin. Some messages will cause an async reply
* to be received through handlePluginMessage_().
* @param {!MessageData} message Message to post.
* @private
*/
postMessage_(message) {
this.plugin_.postMessage(message);
}
/**
* Post a message to the PPAPI plugin, for cases where direct response is
* expected from the PPAPI plugin.
* @param {!MessageData} message
* @return {!Promise} A promise holding the response from the PPAPI plugin.
* @private
*/
postMessageWithReply_(message) {
const promiseResolver = new PromiseResolver();
message.messageId = `${message.type}_${this.createUid_()}`;
this.requestResolverMap_.set(message.messageId, promiseResolver);
this.postMessage_(message);
return promiseResolver.promise;
}
// override
rotateClockwise() {
this.postMessage_({type: 'rotateClockwise'});
}
// override
rotateCounterclockwise() {
this.postMessage_({type: 'rotateCounterclockwise'});
}
// override
setDisplayAnnotations(displayAnnotations) {
this.postMessage_({
type: 'displayAnnotations',
display: displayAnnotations,
});
}
// override
setTwoUpView(enableTwoUpView) {
this.postMessage_({
type: 'setTwoUpView',
enableTwoUpView: enableTwoUpView,
});
}
// override
print() {
this.postMessage_({type: 'print'});
}
selectAll() {
this.postMessage_({type: 'selectAll'});
}
getSelectedText() {
return this.postMessageWithReply_({type: 'getSelectedText'});
}
/**
* Post a thumbnail request message to the plugin.
* @param {number} page
* @return {!Promise<!ThumbnailMessageData>} A promise holding the thumbnail
* response from the plugin.
*/
requestThumbnail(page) {
return this.postMessageWithReply_({
type: 'getThumbnail',
// The plugin references pages using zero-based indices.
page: page - 1,
});
}
/** @param {!PrintPreviewParams} printPreviewParams */
resetPrintPreviewMode(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 {number} color New color, as a 32-bit integer, of the PDF plugin
* background.
*/
setBackgroundColor(color) {
this.postMessage_({
type: 'setBackgroundColor',
color: color,
});
}
/**
* @param {string} url
* @param {number} index
*/
loadPreviewPage(url, index) {
this.postMessage_({type: 'loadPreviewPage', url: url, index: index});
}
/** @param {string} password */
getPasswordComplete(password) {
this.postMessage_({type: 'getPasswordComplete', password: password});
}
/**
* @param {string} destination
* @return {!Promise<!NamedDestinationMessageData>}
* A promise holding the named destination information from the plugin.
*/
getNamedDestination(destination) {
return this.postMessageWithReply_({
type: 'getNamedDestination',
namedDestination: destination,
});
}
/** @param {boolean} enableReadOnly */
setReadOnly(enableReadOnly) {
this.postMessage_({
type: 'setReadOnly',
enableReadOnly: enableReadOnly,
});
}
// override
save(requestType) {
const resolver = new PromiseResolver();
const newToken = createToken();
this.pendingTokens_.set(newToken, resolver);
this.postMessage_({
type: 'save',
token: newToken,
saveRequestType: requestType,
});
return resolver.promise;
}
// override
saveAttachment(index) {
return this.postMessageWithReply_({
type: 'saveAttachment',
attachmentIndex: index,
});
}
// override
async load(fileName, data) {
// Load `data` into the PDF plugin. The unseasoned plugin transfers the data
// to be loaded within the inner frame, while the Pepper plugin updates
// `src` directly.
let url;
if (this.unseasonedPlugin_) {
assert(this.unseasonedPlugin_ === this.plugin_);
this.viewport_.setRemoteContent(this.unseasonedPlugin_);
this.unseasonedPlugin_.postMessage(
{type: 'loadArray', dataToLoad: data}, [data]);
} else {
this.viewport_.setContent(this.plugin_);
url = URL.createObjectURL(new Blob([data]));
this.plugin_.setAttribute('src', url);
this.plugin_.setAttribute('has-edits', '');
}
this.plugin_.style.display = 'block';
try {
await this.getLoadedCallback_();
this.isActive = true;
} finally {
if (url) {
URL.revokeObjectURL(url);
}
}
}
// override
unload() {
this.plugin_.style.display = 'none';
this.isActive = false;
}
/**
* Binds an event handler for messages received from the unseasoned plugin.
*
* TODO(crbug.com/1228987): Remove this method when a permanent postMessage()
* bridge is implemented for the Unseasoned viewer.
*
* @param {!MessagePort} port The message port to bind to.
*/
bindUnseasonedMessageHandler(port) {
assert(this.unseasonedDelayedMessages_ !== null);
const delayedMessages = this.unseasonedDelayedMessages_;
this.unseasonedDelayedMessages_ = null;
this.unseasonedPlugin_.postMessage = port.postMessage.bind(port);
port.onmessage = e => this.handlePluginMessage_(e);
for (const {message, transfer} of delayedMessages) {
this.unseasonedPlugin_.postMessage(message, transfer);
}
}
/**
* An event handler for handling message events received from the plugin.
* @param {!Event} messageEvent a message event.
* @private
*/
handlePluginMessage_(messageEvent) {
const messageData = /** @type {!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(
/** @type {{ gesture: !Gesture }} */ (messageData).gesture);
break;
case 'goToPage':
this.viewport_.goToPage(
/** @type {{type: string, page: number}} */ (messageData).page);
break;
case 'setScrollPosition':
this.viewport_.scrollTo(/** @type {!PartialPoint} */ (messageData));
break;
case 'scrollBy':
this.viewport_.scrollBy(/** @type {!Point} */ (messageData));
break;
case 'syncScrollFromRemote':
this.viewport_.syncScrollFromRemote(
/** @type {!Point} */ (messageData));
break;
case 'ackScrollToRemote':
this.viewport_.ackScrollToRemote(
/** @type {!Point} */ (messageData));
break;
case 'saveData':
this.saveData_(/** @type {!SaveDataMessageData} */ (messageData));
break;
case 'consumeSaveToken':
const saveTokenData =
/** @type {{ type: string, token: string }} */ (messageData);
const resolver = this.pendingTokens_.get(saveTokenData.token);
assert(this.pendingTokens_.delete(saveTokenData.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.
* @param {!SaveDataMessageData} messageData data of the message event.
* @private
*/
saveData_(messageData) {
// Verify a token that was created by this instance is included to avoid
// being spammed.
const resolver = this.pendingTokens_.get(messageData.token);
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);
}
/** @return {!PluginController} */
static getInstance() {
return instance || (instance = new PluginController());
}
}
/** @type {?PluginController} */
let instance = null;