chromium/chrome/browser/resources/pdf/pdf_viewer_base.ts

// Copyright 2013 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.js';
import {EventTracker} from 'chrome://resources/js/event_tracker.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {PromiseResolver} from 'chrome://resources/js/promise_resolver.js';
import {CrLitElement} from 'chrome://resources/lit/v3_0/lit.rollup.js';

import type {BrowserApi} from './browser_api.js';
import {ZoomBehavior} from './browser_api.js';
import type {Point} from './constants.js';
import {FittingType} from './constants.js';
import type {ContentController, MessageData} from './controller.js';
import {PluginController, PluginControllerEventType} from './controller.js';
import {record, recordFitTo, UserAction} from './metrics.js';
import type {OpenPdfParams} from './open_pdf_params_parser.js';
import {OpenPdfParamsParser} from './open_pdf_params_parser.js';
import type {SerializedKeyEvent} from './pdf_scripting_api.js';
import {LoadState} from './pdf_scripting_api.js';
import type {DocumentDimensionsMessageData} from './pdf_viewer_utils.js';
import {Viewport} from './viewport.js';
import {ZoomManager} from './zoom_manager.js';

/** @return Width of a scrollbar in pixels */
function getScrollbarWidth(): number {
  const div = document.createElement('div');
  div.style.visibility = 'hidden';
  div.style.overflow = 'scroll';
  div.style.width = '50px';
  div.style.height = '50px';
  div.style.position = 'absolute';
  document.body.appendChild(div);
  const result = div.offsetWidth - div.clientWidth;
  div.parentNode!.removeChild(div);
  return result;
}

export type KeyEventData = MessageData&{keyEvent: SerializedKeyEvent};

export abstract class PdfViewerBaseElement extends CrLitElement {
  static override get properties() {
    return {
      showErrorDialog: {type: Boolean},
      strings: {type: Object},
    };
  }

  protected browserApi: BrowserApi|null = null;
  protected currentController: ContentController|null = null;
  protected documentDimensions: DocumentDimensionsMessageData|null = null;
  protected isUserInitiatedEvent: boolean = true;
  protected lastViewportPosition: Point|null = null;
  protected originalUrl: string = '';
  protected paramsParser: OpenPdfParamsParser|null = null;
  protected pdfOopifEnabled: boolean = false;
  showErrorDialog: boolean = false;
  protected strings?: {[key: string]: string};
  protected tracker: EventTracker = new EventTracker();
  private delayedScriptingMessages_: MessageEvent[] = [];
  private initialLoadComplete_: boolean = false;
  private loaded_: PromiseResolver<void>|null = null;
  private loadState_: LoadState = LoadState.LOADING;
  private overrideSendScriptingMessageForTest_: boolean = false;
  private parentOrigin_: string|null = null;
  private parentWindow_: WindowProxy|null = null;
  private plugin_: HTMLEmbedElement|null = null;
  private viewport_: Viewport|null = null;
  private zoomManager_: ZoomManager|null = null;

  protected abstract forceFit(view: FittingType): void;

  protected abstract afterZoom(viewportZoom: number): void;

  protected abstract setPluginSrc(plugin: HTMLEmbedElement): void;

  /** Whether to enable the new UI. */
  protected isNewUiEnabled(): boolean {
    return true;
  }

  abstract getBackgroundColor(): number;

  /** Creates the plugin element. */
  private createPlugin_(): HTMLEmbedElement {
    // Create the plugin object dynamically. The plugin element is sized to
    // fill the entire window and is set to be fixed positioning, acting as a
    // viewport. The plugin renders into this viewport according to the scroll
    // position of the window.
    const plugin = document.createElement('embed');

    // NOTE: The plugin's 'id' field must be set to 'plugin' since
    // ChromePrintRenderFrameHelperDeleage::GetPdfElement() in
    // chrome/renderer/printing/chrome_print_render_frame_helper_delegate.cc
    // actually references it.
    plugin.id = 'plugin';
    plugin.type = 'application/x-google-chrome-pdf';

    plugin.setAttribute('original-url', this.originalUrl);
    this.setPluginSrc(plugin);

    plugin.setAttribute(
        'background-color', this.getBackgroundColor().toString());

    const javascript = this.browserApi!.getStreamInfo().javascript || 'block';
    plugin.setAttribute('javascript', javascript);

    if (this.browserApi!.getStreamInfo().embedded) {
      plugin.setAttribute(
          'top-level-url', this.browserApi!.getStreamInfo().tabUrl!);
    } else {
      plugin.toggleAttribute('full-frame', true);
    }

    if (this.isNewUiEnabled()) {
      plugin.toggleAttribute('pdf-viewer-update-enabled', true);
    }

    // Pass the attributes for loading PDF plugin through the `pdfViewerPrivate`
    // API if OOPIF PDF is enabled, or the `mimeHandlerPrivate` API.
    const attributesForLoading:
        chrome.mimeHandlerPrivate.PdfPluginAttributes = {
      backgroundColor: this.getBackgroundColor(),
      allowJavascript: javascript === 'allow',
    };

    // PDF viewer only, as Print Preview doesn't set PDF plugin attributes.
    if (this.pdfOopifEnabled) {
      if (chrome.pdfViewerPrivate) {
        chrome.pdfViewerPrivate.setPdfPluginAttributes(attributesForLoading);
      }
    } else if (chrome.mimeHandlerPrivate) {
      chrome.mimeHandlerPrivate.setPdfPluginAttributes(attributesForLoading);
    }

    return plugin;
  }

  abstract init(browserApi: BrowserApi): void;

  /**
   * Initializes the PDF viewer.
   * @param browserApi The interface with the browser.
   * @param scroller The viewport's scroller element.
   * @param sizer The viewport's sizer element.
   * @param content The viewport's content element.
   */
  protected initInternal(
      browserApi: BrowserApi, scroller: HTMLElement, sizer: HTMLElement,
      content: HTMLElement) {
    this.browserApi = browserApi;
    this.originalUrl = this.browserApi!.getStreamInfo().originalUrl;
    this.pdfOopifEnabled =
        document.documentElement.hasAttribute('pdfOopifEnabled');

    record(UserAction.DOCUMENT_OPENED);

    // Create the viewport.
    const defaultZoom =
        this.browserApi!.getZoomBehavior() === ZoomBehavior.MANAGE ?
        this.browserApi!.getDefaultZoom() :
        1.0;

    this.viewport_ = new Viewport(
        scroller, sizer, content, getScrollbarWidth(), defaultZoom);
    this.viewport_!.setViewportChangedCallback(() => this.viewportChanged_());
    this.viewport_!.setBeforeZoomCallback(
        () => this.currentController!.beforeZoom());
    this.viewport_!.setAfterZoomCallback(() => {
      this.currentController!.afterZoom();
      this.afterZoom(this.viewport_!.getZoom());
    });
    this.viewport_!.setUserInitiatedCallback(
        userInitiated => this.setUserInitiated_(userInitiated));
    window.addEventListener('beforeunload', (event: BeforeUnloadEvent) =>
        this.onBeforeUnload(event),
    );

    // Handle scripting messages from outside the extension that wish to
    // interact with it. We also send a message indicating that extension has
    // loaded and is ready to receive messages.
    window.addEventListener('message', message => {
      this.handleScriptingMessage(message);
    }, false);

    // Create the plugin.
    this.plugin_ = this.createPlugin_();

    const pluginController = PluginController.getInstance();
    pluginController.init(
        this.plugin_, this.viewport_, () => this.isUserInitiatedEvent,
        () => this.loaded);
    pluginController.isActive = true;
    this.currentController = pluginController;

    // Parse open pdf parameters.
    const getNamedDestinationCallback = (destination: string) => {
      return PluginController.getInstance().getNamedDestination(destination);
    };
    const getPageBoundingBoxCallback = (page: number) => {
      return PluginController.getInstance().getPageBoundingBox(page);
    };
    this.paramsParser = new OpenPdfParamsParser(
        getNamedDestinationCallback, getPageBoundingBoxCallback);

    this.tracker.add(
        pluginController.getEventTarget(),
        PluginControllerEventType.PLUGIN_MESSAGE,
        (e: Event) => this.handlePluginMessage(e as CustomEvent<MessageData>));

    document.body.addEventListener('change-page-and-xy', e => {
      const point =
          this.viewport_!.convertPageToScreen(e.detail.page, e.detail);
      this.viewport_!.goToPageAndXy(e.detail.page, point.x, point.y);
    });

    // Setup the keyboard event listener.
    document.addEventListener('keydown', this.handleKeyEvent.bind(this));

    // Set up the ZoomManager.
    this.zoomManager_ = ZoomManager.create(
        this.browserApi!.getZoomBehavior(), () => this.viewport_!.getZoom(),
        zoom => this.browserApi!.setZoom(zoom),
        this.browserApi!.getInitialZoom());
    this.viewport_!.setZoomManager(this.zoomManager_);
    this.browserApi!.addZoomEventListener(
        (zoom: number) => this.zoomManager_!.onBrowserZoomChange(zoom));

    // Request translated strings.
    chrome.resourcesPrivate.getStrings(
        chrome.resourcesPrivate.Component.PDF,
        strings => this.handleStrings(strings));
  }

  /**
   * Updates the loading progress of the document in response to a progress
   * message being received from the content controller.
   * @param progress The progress as a percentage.
   */
  updateProgress(progress: number) {
    if (progress === -1) {
      // Document load failed.
      this.showErrorDialog = true;
      this.viewport_!.setContent(null);
      this.setLoadState(LoadState.FAILED);
      this.sendDocumentLoadedMessage();
    } else if (progress === 100) {
      // Document load complete.
      if (this.lastViewportPosition) {
        this.viewport_!.setPosition(this.lastViewportPosition);
      }
      this.paramsParser!.getViewportFromUrlParams(this.originalUrl)
          .then(params => this.handleUrlParams_(params));
      this.setLoadState(LoadState.SUCCESS);
      this.sendDocumentLoadedMessage();
      while (this.delayedScriptingMessages_.length > 0) {
        this.handleScriptingMessage(this.delayedScriptingMessages_.shift()!);
      }
    } else {
      this.setLoadState(LoadState.LOADING);
    }
  }

  /** @return Whether the documentLoaded message can be sent. */
  readyToSendLoadMessage(): boolean {
    return true;
  }

  /**
   * Sends a 'documentLoaded' message to the PdfScriptingApi if the document has
   * finished loading.
   */
  sendDocumentLoadedMessage() {
    if (this.loadState_ === LoadState.LOADING ||
        !this.readyToSendLoadMessage()) {
      return;
    }
    this.sendScriptingMessage(
        {type: 'documentLoaded', load_state: this.loadState_});
  }

  /** Updates the UI before sending the viewport scripting message. */
  protected abstract updateUiForViewportChange(): void;

  /** A callback to be called after the viewport changes. */
  private viewportChanged_() {
    if (!this.documentDimensions) {
      return;
    }

    this.updateUiForViewportChange();

    const visiblePage = this.viewport_!.getMostVisiblePage();
    const visiblePageDimensions =
        this.viewport_!.getPageScreenRect(visiblePage);
    const size = this.viewport_!.size;
    this.paramsParser!.setViewportDimensions(size);

    this.sendScriptingMessage({
      type: 'viewport',
      pageX: visiblePageDimensions.x,
      pageY: visiblePageDimensions.y,
      pageWidth: visiblePageDimensions.width,
      viewportWidth: size.width,
      viewportHeight: size.height,
    });
  }

  /**
   * Handles a scripting message from outside the extension (typically sent by
   * PdfScriptingApi in a page containing the extension) to interact with the
   * plugin.
   * @return Whether the message was handled.
   */
  handleScriptingMessage(message: MessageEvent): boolean {
    // TODO(crbug.com/40189769): Remove this message handler when a permanent
    // postMessage() bridge is implemented for the viewer.
    if (message.data.type === 'connect') {
      const token: string = message.data.token;
      if (token === this.browserApi!.getStreamInfo().streamUrl) {
        assert(message.ports[0] !== undefined);
        PluginController.getInstance().bindMessageHandler(message.ports[0]);
      } else {
        this.dispatchEvent(new CustomEvent('connection-denied-for-testing'));
      }
      return true;
    }

    if (this.parentWindow_ !== message.source) {
      this.parentWindow_ = message.source as WindowProxy;
      this.parentOrigin_ = message.origin;
      // Ensure that we notify the embedder if the document is loaded.
      if (this.loadState_ !== LoadState.LOADING) {
        this.sendDocumentLoadedMessage();
      }
    }
    return false;
  }

  /**
   * @return Whether the message was delayed and added to the queue.
   */
  delayScriptingMessage(message: MessageEvent): boolean {
    // Delay scripting messages from users of the scripting API until the
    // document is loaded. This simplifies use of the APIs.
    if (this.loadState_ !== LoadState.SUCCESS) {
      this.delayedScriptingMessages_.push(message);
      return true;
    }
    return false;
  }

  protected abstract handlePluginMessage(e: CustomEvent<MessageData>): void;

  /**
   * Handles key events. For instance, these may come from the user directly,
   * the plugin frame, or the scripting API.
   */
  protected abstract handleKeyEvent(e: KeyboardEvent): void;

  /** Sets document dimensions from the current controller. */
  protected setDocumentDimensions(documentDimensions:
                                      DocumentDimensionsMessageData) {
    this.documentDimensions = documentDimensions;
    this.isUserInitiatedEvent = false;
    this.viewport_!.setDocumentDimensions(this.documentDimensions);
    this.paramsParser!.setPageCount(documentDimensions.pageDimensions.length);
    this.paramsParser!.setViewportDimensions(this.viewport_!.size);
    this.isUserInitiatedEvent = true;
  }

  /**
   * @return True if OOPIF PDF is enabled, false otherwise.
   */
  get isPdfOopifEnabled(): boolean {
    return this.pdfOopifEnabled;
  }

  /**
   * @return Resolved when the load state reaches LOADED, rejects on FAILED.
   *     Returns null if no promise has been created, which is the case for
   *     initial load of the PDF.
   */
  get loaded(): Promise<void>|null {
    return this.loaded_ ? this.loaded_!.promise : null;
  }

  get viewport(): Viewport {
    assert(this.viewport_);
    return this.viewport_;
  }

  /**
   * Updates the load state and triggers completion of the `loaded`
   * promise if necessary.
   */
  protected setLoadState(loadState: LoadState) {
    if (this.loadState_ === loadState) {
      return;
    }
    assert(
        loadState === LoadState.LOADING ||
        this.loadState_ === LoadState.LOADING);
    this.loadState_ = loadState;
    if (!this.initialLoadComplete_) {
      this.initialLoadComplete_ = true;
      return;
    }
    if (loadState === LoadState.SUCCESS) {
      this.loaded_!.resolve();
    } else if (loadState === LoadState.FAILED) {
      this.loaded_!.reject();
    } else {
      this.loaded_ = new PromiseResolver();
    }
  }

  /**
   * Load a dictionary of translated strings into the UI. Used as a callback for
   * chrome.resourcesPrivate.
   * @param strings Dictionary of translated strings
   */
  protected handleStrings(strings?: {[key: string]: string}) {
    if (!strings) {
      return;
    }
    loadTimeData.data = strings;

    // Predefined zoom factors to be used when zooming in/out. These are in
    // ascending order.
    const presetZoomFactors =
        JSON.parse(loadTimeData.getString('presetZoomFactors')) as number[];
    this.viewport_!.setZoomFactorRange(presetZoomFactors);

    this.strings = strings;
  }

  /**
   * Handles open pdf parameters. This function updates the viewport as per the
   * parameters appended to the URL when opening pdf. The order is important as
   * later actions can override the effects of previous actions.
   * @param params The open params passed in the URL.
   */
  private handleUrlParams_(params: OpenPdfParams) {
    assert(this.viewport_);

    if (params.zoom) {
      this.viewport_.setZoom(params.zoom);
    }

    if (params.position) {
      this.viewport_.goToPageAndXy(
          params.page || 0, params.position.x, params.position.y);
    }

    if (params.view) {
      this.isUserInitiatedEvent = false;
      const fittingTypeParams = {
        boundingBox: params.boundingBox,
        page: params.page || 0,
        viewPosition: params.viewPosition,
        fitToWidth: params.view === FittingType.FIT_TO_BOUNDING_BOX_WIDTH,
      };
      this.viewport_.setFittingType(params.view, fittingTypeParams);
      this.forceFit(params.view);
      this.isUserInitiatedEvent = true;
    } else if (!params.position && params.page) {
      // No fitting type provided, so just go to page.
      this.viewport_.goToPage(params.page);
    }
  }

  /**
   * A callback that sets `isUserInitiatedEvent` to `userInitiated`.
   * @param userInitiated The value to which to set `isUserInitiatedEvent`.
   */
  private setUserInitiated_(userInitiated: boolean) {
    assert(this.isUserInitiatedEvent !== userInitiated);
    this.isUserInitiatedEvent = userInitiated;
  }

  overrideSendScriptingMessageForTest() {
    this.overrideSendScriptingMessageForTest_ = true;
  }

  /**
   * Send a scripting message outside the extension (typically to
   * PdfScriptingApi in a page containing the extension).
   */
  protected sendScriptingMessage(message: any) {
    if (this.parentWindow_ && this.parentOrigin_) {
      let targetOrigin;
      // Only send data back to the embedder if it is from the same origin,
      // unless we're sending it to ourselves (which could happen in the case
      // of tests). We also allow 'documentLoaded' and 'passwordPrompted'
      // messages through as they do not leak sensitive information.
      if (this.parentOrigin_ === window.location.origin) {
        targetOrigin = this.parentOrigin_;
      } else if (
          message.type === 'documentLoaded' ||
          message.type === 'passwordPrompted') {
        targetOrigin = '*';
      } else {
        targetOrigin = this.originalUrl;
      }
      try {
        this.parentWindow_!.postMessage(message, targetOrigin);
      } catch (ok) {
        // TODO(crbug.com/40647731): targetOrigin probably was rejected, such as
        // a "data:" URL. This shouldn't cause this method to throw, though.
      }
    }
  }

  /** Requests to change the viewport fitting type. */
  protected onFitToChanged(e: CustomEvent<FittingType>) {
    this.viewport_!.setFittingType(e.detail);
    recordFitTo(e.detail);
  }

  protected onZoomIn() {
    this.viewport_!.zoomIn();
    record(UserAction.ZOOM_IN);
  }

  protected onZoomChanged(e: CustomEvent<number>) {
    this.viewport_!.setZoom(e.detail / 100);
    record(UserAction.ZOOM_CUSTOM);
  }

  protected onZoomOut() {
    this.viewport_!.zoomOut();
    record(UserAction.ZOOM_OUT);
  }

  /** Handles a selected text reply from the current controller. */
  protected handleSelectedTextReply(message: {selectedText: string}) {
    if (this.overrideSendScriptingMessageForTest_) {
      this.overrideSendScriptingMessageForTest_ = false;
      try {
        this.sendScriptingMessage(message);
      } finally {
        this.parentWindow_!.postMessage('flush', '*');
      }
      return;
    }
    this.sendScriptingMessage(message);
  }

  protected rotateClockwise() {
    record(UserAction.ROTATE);
    this.currentController!.rotateClockwise();
  }

  protected rotateCounterclockwise() {
    record(UserAction.ROTATE);
    this.currentController!.rotateCounterclockwise();
  }

  /**
   * Handles the `BeforeUnloadEvent` event.
   * @param event The `BeforeUnloadEvent` object representing the event.
   */
  protected onBeforeUnload(_: BeforeUnloadEvent) {
    this.resetTrackers_();
  }

  private resetTrackers_() {
    this.viewport_!.resetTracker();
    if (this.tracker) {
      this.tracker.removeAll();
    }
  }
}