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

// Copyright 2014 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 type {NamedDestinationMessageData, Point, Rect} from './constants.js';
import {FittingType} from './constants.js';
import type {Size} from './viewport.js';

export interface OpenPdfParams {
  boundingBox?: Rect;
  page?: number;
  position?: Point;
  url?: string;
  view?: FittingType;
  viewPosition?: number;
  zoom?: number;
}

export enum ViewMode {
  FIT = 'fit',
  FIT_B = 'fitb',
  FIT_BH = 'fitbh',
  FIT_BV = 'fitbv',
  FIT_H = 'fith',
  FIT_R = 'fitr',
  FIT_V = 'fitv',
  XYZ = 'xyz',
}

type GetNamedDestinationCallback = (name: string) =>
    Promise<NamedDestinationMessageData>;

type GetPageBoundingBoxCallback = (page: number) => Promise<Rect>;

// Parses the open pdf parameters passed in the url to set initial viewport
// settings for opening the pdf.
export class OpenPdfParamsParser {
  private getNamedDestinationCallback_: GetNamedDestinationCallback;
  private getPageBoundingBoxCallback_: GetPageBoundingBoxCallback;
  private pageCount_?: number;
  private viewportDimensions_?: Size;

  /**
   * @param getNamedDestinationCallback Function called to fetch information for
   *     a named destination.
   * @param getPageBoundingBoxCallback Function called to fetch information for
   *     a page's bounding box.
   */
  constructor(
      getNamedDestinationCallback: GetNamedDestinationCallback,
      getPageBoundingBoxCallback: GetPageBoundingBoxCallback) {
    this.getNamedDestinationCallback_ = getNamedDestinationCallback;
    this.getPageBoundingBoxCallback_ = getPageBoundingBoxCallback;
  }

  /**
   * Calculate the zoom level needed for making viewport focus on a rectangular
   * area in the PDF document.
   * @param size The dimensions of the rectangular area to be focused on.
   * @return The zoom level needed for focusing on the rectangular area. A zoom
   *     level of 0 indicates that the zoom level cannot be calculated with the
   *     given information.
   */
  private calculateRectZoomLevel_(size: Size): number {
    if (size.height === 0 || size.width === 0) {
      return 0;
    }

    assert(this.viewportDimensions_);
    return Math.min(
        this.viewportDimensions_.height / size.height,
        this.viewportDimensions_.width / size.width);
  }

  /**
   * Parse zoom parameter of open PDF parameters. The PDF should be opened at
   * the specified zoom level.
   * @return Map with zoom parameters (zoom and position).
   */
  private parseZoomParam_(paramValue: string): OpenPdfParams {
    const paramValueSplit = paramValue.split(',');
    if (paramValueSplit.length !== 1 && paramValueSplit.length !== 3) {
      return {};
    }

    // User scale of 100 means zoom value of 100% i.e. zoom factor of 1.0.
    const zoomFactor = parseFloat(paramValueSplit[0]!) / 100;
    if (Number.isNaN(zoomFactor)) {
      return {};
    }

    // Handle #zoom=scale.
    if (paramValueSplit.length === 1) {
      return {'zoom': zoomFactor};
    }

    // Handle #zoom=scale,left,top.
    const position = {
      x: parseFloat(paramValueSplit[1]!),
      y: parseFloat(paramValueSplit[2]!),
    };
    return {'position': position, 'zoom': zoomFactor};
  }

  /**
   * Parse view parameter of open PDF parameters. The PDF should be opened at
   * the specified fitting type mode and position.
   * @param paramValue Params to parse.
   * @param pageNumber Page number for bounding box, if there is a fit bounding
   *     box param. `pageNumber` is 1-indexed and must be bounded by 1 and the
   *     number of pages in the PDF, inclusive.
   * @return Map with view parameters (view and viewPosition).
   */
  private async parseViewParam_(paramValue: string, pageNumber: number):
      Promise<OpenPdfParams> {
    assert(pageNumber > 0);
    if (this.pageCount_) {
      assert(pageNumber <= this.pageCount_);
    }

    const viewModeComponents = paramValue.toLowerCase().split(',');
    if (viewModeComponents.length === 0) {
      return {};
    }

    const params: OpenPdfParams = {};
    const viewMode = viewModeComponents[0]!;
    let acceptsPositionParam = false;

    // Note that `pageNumber` is 1-indexed, but PDF Viewer is 0-indexed.
    switch (viewMode) {
      case ViewMode.FIT:
        params['view'] = FittingType.FIT_TO_PAGE;
        break;
      case ViewMode.FIT_H:
        params['view'] = FittingType.FIT_TO_WIDTH;
        acceptsPositionParam = true;
        break;
      case ViewMode.FIT_V:
        params['view'] = FittingType.FIT_TO_HEIGHT;
        acceptsPositionParam = true;
        break;
      case ViewMode.FIT_B:
        if (this.pageCount_) {
          params['view'] = FittingType.FIT_TO_BOUNDING_BOX;
          params['boundingBox'] =
              await this.getPageBoundingBoxCallback_(pageNumber - 1);
        }
        break;
      case ViewMode.FIT_BH:
        if (this.pageCount_) {
          params['view'] = FittingType.FIT_TO_BOUNDING_BOX_WIDTH;
          params['boundingBox'] =
              await this.getPageBoundingBoxCallback_(pageNumber - 1);
          acceptsPositionParam = true;
        }
        break;
      case ViewMode.FIT_BV:
        if (this.pageCount_) {
          params['view'] = FittingType.FIT_TO_BOUNDING_BOX_HEIGHT;
          params['boundingBox'] =
              await this.getPageBoundingBoxCallback_(pageNumber - 1);
          acceptsPositionParam = true;
        }
        break;
      case ViewMode.FIT_R:
      case ViewMode.XYZ:
        // Should have already been handled in `parseNameddestViewParam_()`.
        break;
      default:
        // Invalid view parameter, do nothing.
    }

    if (!acceptsPositionParam || viewModeComponents.length === 1) {
      return params;
    }

    const position = parseFloat(viewModeComponents[1]!);
    if (!Number.isNaN(position)) {
      params['viewPosition'] = position;
    }

    return params;
  }

  /**
   * Parse view parameters which come from nameddest.
   * @param paramValue Params to parse.
   * @param pageNumber Page number for bounding box, if there is a fit bounding
   *     box param.
   * @return Map with view parameters.
   */
  private async parseNameddestViewParam_(
      paramValue: string, pageNumber: number): Promise<OpenPdfParams> {
    const viewModeComponents = paramValue.toLowerCase().split(',');
    assert(viewModeComponents.length > 0);
    const viewMode = viewModeComponents[0]!;
    const params: OpenPdfParams = {};

    if (viewMode === ViewMode.XYZ && viewModeComponents.length === 4) {
      const x = parseFloat(viewModeComponents[1]!);
      const y = parseFloat(viewModeComponents[2]!);
      const zoom = parseFloat(viewModeComponents[3]!);

      // If zoom is originally 0 for the XYZ view, it is guaranteed to be
      // transformed into "null" by the backend.
      assert(zoom !== 0);

      if (!Number.isNaN(zoom)) {
        params['zoom'] = zoom;
      }
      if (!Number.isNaN(x) || !Number.isNaN(y)) {
        params['position'] = {x: x, y: y};
      }
      return params;
    }

    if (viewMode === ViewMode.FIT_R && viewModeComponents.length === 5) {
      assert(this.viewportDimensions_ !== undefined);
      let x1 = parseFloat(viewModeComponents[1]!);
      let y1 = parseFloat(viewModeComponents[2]!);
      let x2 = parseFloat(viewModeComponents[3]!);
      let y2 = parseFloat(viewModeComponents[4]!);
      if (!Number.isNaN(x1) && !Number.isNaN(y1) && !Number.isNaN(x2) &&
          !Number.isNaN(y2)) {
        if (x1 > x2) {
          [x1, x2] = [x2, x1];
        }
        if (y1 > y2) {
          [y1, y2] = [y2, y1];
        }
        const rectSize = {width: x2 - x1, height: y2 - y1};
        params['position'] = {x: x1, y: y1};
        const zoom = this.calculateRectZoomLevel_(rectSize);
        if (zoom !== 0) {
          params['zoom'] = zoom;
        }
      }
      return params;
    }

    return this.parseViewParam_(paramValue, pageNumber);
  }

  /** Parse the parameters encoded in the fragment of a URL. */
  private parseUrlParams_(url: string): URLSearchParams {
    const hash = new URL(url).hash;
    const params = new URLSearchParams(hash.substring(1));

    // Handle the case of http://foo.com/bar#NAMEDDEST. This is not
    // explicitly mentioned except by example in the Adobe
    // "PDF Open Parameters" document.
    if (Array.from(params).length === 1) {
      const key = Array.from(params.keys())[0]!;
      if (params.get(key) === '') {
        params.append('nameddest', key);
        params.delete(key);
      }
    }

    return params;
  }

  /** Store the number of pages. */
  setPageCount(pageCount: number) {
    this.pageCount_ = pageCount;
  }

  /** Store current viewport's dimensions. */
  setViewportDimensions(dimensions: Size) {
    this.viewportDimensions_ = dimensions;
  }

  /**
   * @param url that needs to be parsed.
   * @return Whether the toolbar UI element should be shown.
   */
  shouldShowToolbar(url: string): boolean {
    const urlParams = this.parseUrlParams_(url);
    const navpanes = urlParams.get('navpanes');
    const toolbar = urlParams.get('toolbar');

    // If navpanes is set to '1', then the toolbar must be shown, regardless of
    // the value of toolbar.
    return navpanes === '1' || toolbar !== '0';
  }

  /**
   * @param url that needs to be parsed.
   * @param sidenavCollapsed the default sidenav state if there are no
   *     overriding open parameters.
   * @return Whether the sidenav UI element should be shown.
   */
  shouldShowSidenav(url: string, sidenavCollapsed: boolean): boolean {
    const urlParams = this.parseUrlParams_(url);
    const navpanes = urlParams.get('navpanes');
    const toolbar = urlParams.get('toolbar');

    // If there are no relevant open parameters, default to the original value.
    if (navpanes === null && toolbar === null) {
      return !sidenavCollapsed;
    }

    return navpanes === '1';
  }

  /**
   * Parse PDF url parameters. These parameters are mentioned in the url
   * and specify actions to be performed when opening pdf files.
   * See http://www.adobe.com/content/dam/Adobe/en/devnet/acrobat/
   * pdfs/pdf_open_parameters.pdf for details.
   * @param url that needs to be parsed.
   */
  async getViewportFromUrlParams(url: string): Promise<OpenPdfParams> {
    const params: OpenPdfParams = {url};

    const urlParams = this.parseUrlParams_(url);

    // `pageNumber` is 1-based.
    let pageNumber = 1;
    if (urlParams.has('page')) {
      pageNumber = parseInt(urlParams.get('page')!, 10);
      if (!Number.isNaN(pageNumber) && this.pageCount_) {
        // If necessary, clip `pageNumber` to stay within bounds.
        if (pageNumber < 1) {
          pageNumber = 1;
        } else if (pageNumber > this.pageCount_) {
          pageNumber = this.pageCount_;
        }
        // goToPage() takes a zero-based page index.
        params['page'] = pageNumber - 1;
      }
    }

    if (urlParams.has('view')) {
      Object.assign(
          params,
          await this.parseViewParam_(urlParams.get('view')!, pageNumber));
    }

    if (urlParams.has('zoom')) {
      Object.assign(params, this.parseZoomParam_(urlParams.get('zoom')!));
    }

    if (params.page === undefined && urlParams.has('nameddest')) {
      const data =
          await this.getNamedDestinationCallback_(urlParams.get('nameddest')!);

      if (data.pageNumber !== -1) {
        params.page = data.pageNumber;
        pageNumber = data.pageNumber;
      }

      if (data.namedDestinationView) {
        Object.assign(
            params,
            await this.parseNameddestViewParam_(
                data.namedDestinationView, pageNumber!));
      }
      return params;
    }
    return params;
  }
}