| // 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', |
| } |
| |
| const FRAGMENT_DIRECTIVE_DELIMITER = ':~:'; |
| |
| 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'; |
| } |
| |
| /** |
| * Fetch text fragment directives that appear in the PDF URL if any. |
| * |
| * @param url that needs to be parsed. |
| * @return The text fragment directives or an empty array if they do not |
| * exist. |
| */ |
| getTextFragments(url: string): string[] { |
| // The hash of the URL object decodes the escaped characters in our |
| // fragment. So in order to keep characters such as `,` or `-` which could |
| // be part of the prefix or suffix, the fragment of the URL needs to be |
| // fetched directly from the `url` string instead of using the URL |
| // interface. |
| const hashSplit = url.split('#'); |
| if (hashSplit.length !== 2) { |
| return []; |
| } |
| const hash = hashSplit[1]; |
| assert(hash !== undefined); |
| |
| // Handle the case of text directives included in the URL. |
| const fragmentDirectiveSplit = hash.split(FRAGMENT_DIRECTIVE_DELIMITER); |
| if (fragmentDirectiveSplit.length !== 2) { |
| return []; |
| } |
| const fragmentDirective = fragmentDirectiveSplit[1]; |
| assert(fragmentDirective !== undefined); |
| |
| // Loop through the directive split at character `&` in case of multiple |
| // text directives. This cannot be done with URLSearchParams since the `get` |
| // and `getAll` functions decode the parameter values which can result in a |
| // broken text fragment parse. |
| const textFragmentDirectives: string[] = []; |
| for (const param of fragmentDirective.split('&')) { |
| const [key, value] = param.split('='); |
| if (key === 'text' && value) { |
| textFragmentDirectives.push(value); |
| } |
| } |
| return textFragmentDirectives; |
| } |
| |
| /** |
| * 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; |
| } |
| } |