0

[PDF Viewer] Add support for view destination with 'FitB' fitting type

Table 151 in ISO 32000-1 standard lists multiple view destination
parameters. Implement 'FitB' parameter, which displays the entire
bounding box of a given page.

This CL creates the following flow:
1. OpenPdfParamsParser parses the view parameters by using a
   GetPageBoundingBoxCallback.
2. GetPageBoundingBoxCallback uses PluginController to
   postMessageWithReply() to the PdfViewWebPlugin.
3. PdfViewWebPlugin utilizes a new API PDFiumPage::GetBoundingBox() to
   get the bounding box of a given page and returns it.
4. PDF Viewer handles the URL params and uses setFittingType() to zoom
   and move to the bounding box.

Bug: 1414864
Change-Id: I32f34df226f3cc5f69724d10726b5032471af9d6
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/4371251
Reviewed-by: Demetrios Papadopoulos <dpapad@chromium.org>
Reviewed-by: Lei Zhang <thestig@chromium.org>
Reviewed-by: Nigi <nigi@chromium.org>
Commit-Queue: Andy Phan <andyphan@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1126928}
This commit is contained in:
Andy Phan
2023-04-06 00:18:25 +00:00
committed by Chromium LUCI CQ
parent 2da08cdf91
commit fef93ce0ca
23 changed files with 900 additions and 30 deletions

@ -29,12 +29,13 @@ export interface DocumentMetadata {
version: string;
}
/** Enumeration of page fitting types. */
/** Enumeration of page fitting types and bounding box fitting types. */
export enum FittingType {
NONE = 'none',
FIT_TO_PAGE = 'fit-to-page',
FIT_TO_WIDTH = 'fit-to-width',
FIT_TO_HEIGHT = 'fit-to-height',
FIT_TO_BOUNDING_BOX = 'fit-to-bounding-box',
}
export interface NamedDestinationMessageData {
@ -58,6 +59,13 @@ export interface Point {
y: number;
}
export interface Rect {
x: number;
y: number;
width: number;
height: number;
}
export type ExtendedKeyEvent = KeyboardEvent&{
fromScriptingAPI?: boolean,
fromPlugin?: boolean,

@ -5,7 +5,7 @@
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 {NamedDestinationMessageData, Rect, SaveRequestType} from './constants.js';
import {PdfPluginElement} from './internal_plugin.js';
import {PinchPhase, Viewport} from './viewport.js';
@ -322,6 +322,13 @@ export class PluginController implements ContentController {
this.postMessage_({type: 'loadPreviewPage', url: url, index: index});
}
getPageBoundingBox(page: number): Promise<Rect> {
return this.postMessageWithReply_({
type: 'getPageBoundingBox',
page,
});
}
getPasswordComplete(password: string) {
this.postMessage_({type: 'getPasswordComplete', password: password});
}

@ -3,16 +3,18 @@
// found in the LICENSE file.
import {assert} from 'chrome://resources/js/assert_ts.js';
import {FittingType, NamedDestinationMessageData, Point} from './constants.js';
import {FittingType, NamedDestinationMessageData, Point, Rect} from './constants.js';
import {Size} from './viewport.js';
export interface OpenPdfParams {
boundingBox?: Rect;
page?: number;
position?: Point;
url?: string;
zoom?: number;
view?: FittingType;
viewPosition?: number;
position?: Point;
page?: number;
zoom?: number;
}
export enum ViewMode {
@ -29,18 +31,26 @@ export enum ViewMode {
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 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) {
constructor(
getNamedDestinationCallback: GetNamedDestinationCallback,
getPageBoundingBoxCallback: GetPageBoundingBoxCallback) {
this.getNamedDestinationCallback_ = getNamedDestinationCallback;
this.getPageBoundingBoxCallback_ = getPageBoundingBoxCallback;
}
/**
@ -96,9 +106,12 @@ export class OpenPdfParamsParser {
* 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.
* @return Map with view parameters (view and viewPosition).
*/
private async parseViewParam_(paramValue: string): Promise<OpenPdfParams> {
private async parseViewParam_(paramValue: string, pageNumber: number):
Promise<OpenPdfParams> {
const viewModeComponents = paramValue.toLowerCase().split(',');
if (viewModeComponents.length === 0) {
return {};
@ -120,6 +133,11 @@ export class OpenPdfParamsParser {
acceptsPositionParam = true;
break;
case ViewMode.FIT_B:
params['view'] = FittingType.FIT_TO_BOUNDING_BOX;
// pageNumber is 1-indexed, but PDF Viewer is 0-indexed.
params['boundingBox'] =
await this.getPageBoundingBoxCallback_(pageNumber - 1);
break;
case ViewMode.FIT_BH:
case ViewMode.FIT_BV:
// Not implemented yet, do nothing.
@ -147,10 +165,12 @@ export class OpenPdfParamsParser {
/**
* 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):
Promise<OpenPdfParams> {
private async parseNameddestViewParam_(
paramValue: string, pageNumber: number): Promise<OpenPdfParams> {
const viewModeComponents = paramValue.toLowerCase().split(',');
const viewMode = viewModeComponents[0];
const params: OpenPdfParams = {};
@ -197,7 +217,7 @@ export class OpenPdfParamsParser {
return params;
}
return this.parseViewParam_(paramValue);
return this.parseViewParam_(paramValue, pageNumber);
}
/** Parse the parameters encoded in the fragment of a URL. */
@ -269,16 +289,23 @@ export class OpenPdfParamsParser {
const urlParams = this.parseUrlParams_(url);
let pageNumber;
if (urlParams.has('page')) {
// |pageNumber| is 1-based, but goToPage() take a zero-based page index.
const pageNumber = parseInt(urlParams.get('page')!, 10);
pageNumber = parseInt(urlParams.get('page')!, 10);
if (!Number.isNaN(pageNumber) && pageNumber > 0) {
params['page'] = pageNumber - 1;
}
}
if (!pageNumber || pageNumber < 1) {
pageNumber = 1;
}
if (urlParams.has('view')) {
Object.assign(params, await this.parseViewParam_(urlParams.get('view')!));
Object.assign(
params,
await this.parseViewParam_(urlParams.get('view')!, pageNumber!));
}
if (urlParams.has('zoom')) {
@ -291,12 +318,14 @@ export class OpenPdfParamsParser {
if (data.pageNumber !== -1) {
params.page = data.pageNumber;
pageNumber = data.pageNumber;
}
if (data.namedDestinationView) {
Object.assign(
params,
await this.parseNameddestViewParam_(data.namedDestinationView));
await this.parseNameddestViewParam_(
data.namedDestinationView, pageNumber!));
}
return params;
}

@ -149,11 +149,6 @@ export abstract class PdfViewerBaseElement extends PolymerElement {
record(UserAction.DOCUMENT_OPENED);
// Parse open pdf parameters.
this.paramsParser = new OpenPdfParamsParser(destination => {
return PluginController.getInstance().getNamedDestination(destination);
});
// Create the viewport.
const defaultZoom =
this.browserApi!.getZoomBehavior() === ZoomBehavior.MANAGE ?
@ -190,6 +185,16 @@ export abstract class PdfViewerBaseElement extends PolymerElement {
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,
@ -432,14 +437,22 @@ export abstract class PdfViewerBaseElement extends PolymerElement {
if (params.position) {
this.viewport_.goToPageAndXy(
params.page ? params.page : 0, params.position.x, params.position.y);
params.page || 0, params.position.x, params.position.y);
} else if (params.page) {
this.viewport_.goToPage(params.page);
}
if (params.view) {
this.isUserInitiatedEvent = false;
this.viewport_.setFittingType(params.view);
let fittingTypeParams;
if (params.view === FittingType.FIT_TO_BOUNDING_BOX) {
assert(params.boundingBox);
fittingTypeParams = {
page: params.page || 0,
boundingBox: params.boundingBox,
};
}
this.viewport_.setFittingType(params.view, fittingTypeParams);
this.forceFit(params.view);
if (params.viewPosition) {
const zoomedPositionShift =

@ -9,7 +9,7 @@ export {AnnotationTool} from './annotation_tool.js';
// </if>
export {Bookmark} from './bookmark_type.js';
export {BrowserApi, ZoomBehavior} from './browser_api.js';
export {FittingType, Point, SaveRequestType} from './constants.js';
export {FittingType, Point, Rect, SaveRequestType} from './constants.js';
export {PluginController} from './controller.js';
export {ChangePageAndXyDetail, ChangePageDetail, ChangePageOrigin, ChangeZoomDetail, NavigateDetail, ViewerBookmarkElement} from './elements/viewer-bookmark.js';
export {ViewerDocumentOutlineElement} from './elements/viewer-document-outline.js';

@ -8,7 +8,7 @@ import {EventTracker} from 'chrome://resources/js/event_tracker.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {hasKeyModifiers, isRTL} from 'chrome://resources/js/util_ts.js';
import {ExtendedKeyEvent, FittingType, Point} from './constants.js';
import {ExtendedKeyEvent, FittingType, Point, Rect} from './constants.js';
import {Gesture, GestureDetector, PinchEventDetail} from './gesture_detector.js';
import {PdfPluginElement} from './internal_plugin.js';
import {SwipeDetector, SwipeDirection} from './swipe_detector.js';
@ -40,6 +40,11 @@ export interface Size {
height: number;
}
interface FittingTypeParams {
page: number;
boundingBox: Rect;
}
/** @return The area of the intersection of the rects */
function getIntersectionArea(rect1: ViewportRect, rect2: ViewportRect): number {
const left = Math.max(rect1.x, rect2.x);
@ -604,6 +609,11 @@ export class Viewport implements ViewportInterface {
* Save the current zoom and fitting type.
*/
saveZoomState() {
// Fitting to bounding box does not need to be saved, so set the fitting
// type to none.
if (this.fittingType_ === FittingType.FIT_TO_BOUNDING_BOX) {
this.setFittingType(FittingType.NONE);
}
this.savedZoom_ = this.internalZoom_;
this.savedFittingType_ = this.fittingType_;
}
@ -901,7 +911,11 @@ export class Viewport implements ViewportInterface {
return Math.max(zoom, 0);
}
setFittingType(fittingType: FittingType) {
/**
* Set the fitting type and fit within the viewport accordingly.
* @param params Params required for fitting to the bounding box.
*/
setFittingType(fittingType: FittingType, params?: FittingTypeParams) {
switch (fittingType) {
case FittingType.FIT_TO_PAGE:
this.fitToPage();
@ -912,6 +926,10 @@ export class Viewport implements ViewportInterface {
case FittingType.FIT_TO_HEIGHT:
this.fitToHeight();
return;
case FittingType.FIT_TO_BOUNDING_BOX:
assert(params);
this.fitToBoundingBox_(params.page, params.boundingBox);
return;
case FittingType.NONE:
this.fittingType_ = fittingType;
return;
@ -1021,6 +1039,56 @@ export class Viewport implements ViewportInterface {
});
}
/**
* Zoom the viewport so that the bounding box of a page consumes the entire
* viewport.
* @param page The page to display.
* @param boundingBox The bounding box to fit to.
*/
private fitToBoundingBox_(page: number, boundingBox: Rect) {
// Ignore invalid bounding boxes, which can occur if the plugin fails to
// give a valid box.
if (!boundingBox.width || !boundingBox.height) {
return;
}
this.fittingType_ = FittingType.FIT_TO_BOUNDING_BOX;
// Use the smallest zoom that fits the full bounding box on screen.
const boundingBoxSize = {
width: boundingBox.width,
height: boundingBox.height,
};
const zoomFitToWidth =
this.computeFittingZoom_(boundingBoxSize, true, false);
const zoomFitToHeight =
this.computeFittingZoom_(boundingBoxSize, false, true);
const newZoom = this.clampZoom_(Math.min(zoomFitToWidth, zoomFitToHeight));
this.mightZoom_(() => {
this.setZoomInternal_(newZoom);
});
// Calculate the position.
const pageInsetDimensions = this.getPageInsetDimensions(page);
const viewportSize = this.size;
const screenPosition: Point = {
x: pageInsetDimensions.x + boundingBox.x,
y: pageInsetDimensions.y + boundingBox.y,
};
// Center the bounding box in the dimension that isn't fully zoomed in.
if (newZoom !== zoomFitToWidth) {
screenPosition.x -=
((viewportSize.width / newZoom) - boundingBox.width) / 2;
}
if (newZoom !== zoomFitToHeight) {
screenPosition.y -=
((viewportSize.height / newZoom) - boundingBox.height) / 2;
}
this.setPosition(
{x: screenPosition.x * newZoom, y: screenPosition.y * newZoom});
}
/** Zoom out to the next predefined zoom level. */
zoomOut() {
this.mightZoom_(() => {
@ -1361,6 +1429,7 @@ export class Viewport implements ViewportInterface {
*/
handleNavigateToDestination(
page: number, x: number|undefined, y: number|undefined, zoom: number) {
// TODO(crbug.com/1430193): Handle view parameters and fitting types.
if (zoom) {
this.setZoom(zoom);
}

@ -106,10 +106,15 @@ async function doNavigationUrlTests(
const viewport = getZoomableViewport(mockWindow, mockSizer, 0, 1);
viewport.setViewportChangedCallback(mockViewportChangedCallback.callback);
const paramsParser = new OpenPdfParamsParser(function(_name) {
const getNamedDestinationCallback = function(_name: string) {
return Promise.resolve(
{messageId: 'getNamedDestination_1', pageNumber: -1});
});
};
const getPageBoundingBoxCallback = function(_page: number) {
return Promise.resolve({x: -1, y: -1, width: -1, height: -1});
};
const paramsParser = new OpenPdfParamsParser(
getNamedDestinationCallback, getPageBoundingBoxCallback);
const navigatorDelegate = new MockNavigatorDelegate();
const navigator =
@ -138,7 +143,7 @@ chrome.test.runTests([
const viewport = getZoomableViewport(mockWindow, mockSizer, 0, 1);
viewport.setViewportChangedCallback(mockCallback.callback);
const paramsParser = new OpenPdfParamsParser(function(destination) {
const getNamedDestinationCallback = function(destination: string) {
if (destination === 'US') {
return Promise.resolve(
{messageId: 'getNamedDestination_1', pageNumber: 0});
@ -149,7 +154,12 @@ chrome.test.runTests([
return Promise.resolve(
{messageId: 'getNamedDestination_3', pageNumber: -1});
}
});
};
const getPageBoundingBoxCallback = function(_page: number) {
return Promise.resolve({x: -1, y: -1, width: -1, height: -1});
};
const paramsParser = new OpenPdfParamsParser(
getNamedDestinationCallback, getPageBoundingBoxCallback);
const url = 'http://xyz.pdf';
const navigatorDelegate = new MockNavigatorDelegate();

@ -7,6 +7,9 @@ import {FittingType, OpenPdfParamsParser, ViewMode} from 'chrome-extension://mhj
const URL = 'http://xyz.pdf';
function getParamsParser(): OpenPdfParamsParser {
const getPageBoundingBoxCallback = function(_page: number) {
return Promise.resolve({x: 10, y: 15, width: 200, height: 300});
};
const paramsParser = new OpenPdfParamsParser(function(destination: string) {
// Set the dummy viewport dimensions for calculating the zoom level for
// view destination with 'FitR' type.
@ -89,7 +92,7 @@ function getParamsParser(): OpenPdfParamsParser {
}
return Promise.resolve(
{messageId: 'getNamedDestination_13', pageNumber: -1});
});
}, getPageBoundingBoxCallback);
return paramsParser;
}
@ -381,6 +384,21 @@ chrome.test.runTests([
chrome.test.assertFalse(paramsParser.shouldShowSidenav(`${URL}`, true));
chrome.test.assertTrue(paramsParser.shouldShowSidenav(`${URL}`, false));
chrome.test.succeed();
},
async function testParamsViewFitB() {
const paramsParser = getParamsParser();
// Checking #view=FitB.
const params =
await paramsParser.getViewportFromUrlParams(`${URL}#view=FitB`);
chrome.test.assertEq(FittingType.FIT_TO_BOUNDING_BOX, params.view);
chrome.test.assertTrue(params.boundingBox !== undefined);
chrome.test.assertEq(10, params.boundingBox.x);
chrome.test.assertEq(15, params.boundingBox.y);
chrome.test.assertEq(200, params.boundingBox.width);
chrome.test.assertEq(300, params.boundingBox.height);
chrome.test.succeed();
},
]);

@ -2,7 +2,7 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {FittingType, PAGE_SHADOW, SwipeDirection, Viewport} from 'chrome-extension://mhjfbmdgcfjbbpaeojofohoefgiehjai/pdf_viewer_wrapper.js';
import {FittingType, PAGE_SHADOW, Point, Rect, SwipeDirection, Viewport} from 'chrome-extension://mhjfbmdgcfjbbpaeojofohoefgiehjai/pdf_viewer_wrapper.js';
import {isMac} from 'chrome://resources/js/platform.js';
import {createMockUnseasonedPdfPluginForTest, getZoomableViewport, MockDocumentDimensions, MockElement, MockSizer, MockUnseasonedPdfPluginElement, MockViewportChangedCallback} from './test_util.js';
@ -331,6 +331,14 @@ const tests = [
viewport.setFittingType(FittingType.FIT_TO_HEIGHT);
chrome.test.assertEq(FittingType.FIT_TO_HEIGHT, viewport.fittingType);
const documentDimensions = new MockDocumentDimensions();
documentDimensions.addPage(0, 0);
viewport.setDocumentDimensions(documentDimensions);
const params = {page: 0, boundingBox: {x: 0, y: 0, width: 1, height: 1}};
viewport.setFittingType(FittingType.FIT_TO_BOUNDING_BOX, params);
chrome.test.assertEq(FittingType.FIT_TO_BOUNDING_BOX, viewport.fittingType);
viewport.setFittingType(FittingType.NONE);
chrome.test.assertEq(FittingType.NONE, viewport.fittingType);
@ -622,6 +630,71 @@ const tests = [
chrome.test.succeed();
},
function testFitToBoundingBox() {
const mockWindow = new MockElement(100, 100, null);
const mockSizer = new MockSizer();
const mockCallback = new MockViewportChangedCallback();
const viewport = getZoomableViewport(mockWindow, mockSizer, 0, 1);
viewport.setViewportChangedCallback(mockCallback.callback);
const documentDimensions = new MockDocumentDimensions();
documentDimensions.addPage(50, 50);
documentDimensions.addPage(50, 100);
documentDimensions.addPage(100, 50);
documentDimensions.addPage(100, 100);
documentDimensions.addPage(200, 200);
viewport.setDocumentDimensions(documentDimensions);
function assertPositionAndZoom(
expectedPosition: Point, expectedZoom: number) {
chrome.test.assertEq(
FittingType.FIT_TO_BOUNDING_BOX, viewport.fittingType);
chrome.test.assertTrue(mockCallback.wasCalled);
chrome.test.assertEq(expectedPosition, viewport.position);
chrome.test.assertEq(expectedZoom, viewport.getZoom());
}
function testForVisibleBoundingBox(
page: number, boundingBox: Rect, expectedX: number, expectedY: number,
expectedZoom: number) {
viewport.setZoom(0.1);
mockCallback.reset();
viewport.setFittingType(
FittingType.FIT_TO_BOUNDING_BOX, {page, boundingBox});
assertPositionAndZoom({x: expectedX, y: expectedY}, expectedZoom);
}
// Bounding box is smaller than window size and square.
let boundingBox: Rect = {x: 25, y: 25, width: 50, height: 50};
testForVisibleBoundingBox(0, boundingBox, 60, 56, 2);
testForVisibleBoundingBox(1, boundingBox, 60, 156, 2);
testForVisibleBoundingBox(2, boundingBox, 60, 356, 2);
testForVisibleBoundingBox(3, boundingBox, 60, 456, 2);
testForVisibleBoundingBox(4, boundingBox, 60, 656, 2);
// Bounding box is smaller than window size with larger width.
boundingBox = {x: 20, y: 25, width: 80, height: 50};
testForVisibleBoundingBox(2, boundingBox, 31.25, 203.75, 1.25);
testForVisibleBoundingBox(3, boundingBox, 31.25, 266.25, 1.25);
testForVisibleBoundingBox(4, boundingBox, 31.25, 391.25, 1.25);
// Bounding box is smaller than window size with larger height.
boundingBox = {x: 25, y: 20, width: 50, height: 80};
testForVisibleBoundingBox(1, boundingBox, 18.75, 91.25, 1.25);
testForVisibleBoundingBox(3, boundingBox, 18.75, 278.75, 1.25);
testForVisibleBoundingBox(4, boundingBox, 18.75, 403.75, 1.25);
// Bounding box is the same size as window size.
boundingBox = {x: 0, y: 0, width: 100, height: 100};
testForVisibleBoundingBox(3, boundingBox, 5, 203, 1);
testForVisibleBoundingBox(4, boundingBox, 5, 303, 1);
// Bounding box is larger than window size.
boundingBox = {x: 10, y: 20, width: 150, height: 150};
testForVisibleBoundingBox(
4, boundingBox, 10, 215.33333333333331, 0.6666666666666666);
chrome.test.succeed();
},
async function testPinchZoomInWithGestureEvent() {
const mockWindow = new MockElement(100, 100, null);
const viewport = getZoomableViewport(mockWindow, new MockSizer(), 0, 1);
@ -1694,6 +1767,12 @@ const tests = [
chrome.test.assertEq(0, viewport.position.y);
chrome.test.succeed();
},
// TODO(crbug.com/1430193): Currently, fit types 'FIT_TO_PAGE',
// 'FIT_TO_WIDTH', 'FIT_TO_HEIGHT', and 'FIT_TO_BOUNDING_BOX` do not correctly
// navigate to a destination with the correct position and zoom level. Add
// checks for position and zoom level for these fit types once fully
// supported.
];
chrome.test.runTests(tests);

@ -373,6 +373,9 @@ class PDFEngine {
// Returns a page's rect in screen coordinates, as well as its surrounding
// border areas and bottom separator.
virtual gfx::Rect GetPageScreenRect(int page_index) const = 0;
// Return a page's bounding box rectangle, or an empty rectangle if
// `page_index` is invalid.
virtual gfx::RectF GetPageBoundingBox(int page_index) = 0;
// Set color / grayscale rendering modes.
virtual void SetGrayscale(bool grayscale) = 0;
// Get the number of characters on a given page.

@ -68,6 +68,7 @@
#include "pdf/ui/file_name.h"
#include "pdf/ui/thumbnail.h"
#include "printing/metafile_skia.h"
#include "printing/units.h"
#include "services/network/public/mojom/referrer_policy.mojom-shared.h"
#include "third_party/blink/public/common/associated_interfaces/associated_interface_provider.h"
#include "third_party/blink/public/common/input/web_coalesced_input_event.h"
@ -1295,6 +1296,8 @@ void PdfViewWebPlugin::OnMessage(const base::Value::Dict& message) {
&PdfViewWebPlugin::HandleDisplayAnnotationsMessage},
{"getNamedDestination",
&PdfViewWebPlugin::HandleGetNamedDestinationMessage},
{"getPageBoundingBox",
&PdfViewWebPlugin::HandleGetPageBoundingBoxMessage},
{"getPasswordComplete",
&PdfViewWebPlugin::HandleGetPasswordCompleteMessage},
{"getSelectedText", &PdfViewWebPlugin::HandleGetSelectedTextMessage},
@ -1356,6 +1359,26 @@ void PdfViewWebPlugin::HandleGetNamedDestinationMessage(
client_->PostMessage(std::move(reply));
}
void PdfViewWebPlugin::HandleGetPageBoundingBoxMessage(
const base::Value::Dict& message) {
const int page_index = message.FindInt("page").value();
base::Value::Dict reply =
PrepareReplyMessage("getPageBoundingBoxReply", message);
gfx::RectF bounding_box = engine_->GetPageBoundingBox(page_index);
gfx::Rect page_bounds = engine_->GetPageBoundsRect(page_index);
// Flip the origin from bottom-left to top-left.
bounding_box.set_y(static_cast<float>(page_bounds.height()) -
bounding_box.bottom());
reply.Set("x", bounding_box.x());
reply.Set("y", bounding_box.y());
reply.Set("width", bounding_box.width());
reply.Set("height", bounding_box.height());
client_->PostMessage(std::move(reply));
}
void PdfViewWebPlugin::HandleGetPasswordCompleteMessage(
const base::Value::Dict& message) {
DCHECK(password_callback_);

@ -461,6 +461,7 @@ class PdfViewWebPlugin final : public PDFEngine::Client,
// Message handlers.
void HandleDisplayAnnotationsMessage(const base::Value::Dict& message);
void HandleGetNamedDestinationMessage(const base::Value::Dict& message);
void HandleGetPageBoundingBoxMessage(const base::Value::Dict& message);
void HandleGetPasswordCompleteMessage(const base::Value::Dict& message);
void HandleGetSelectedTextMessage(const base::Value::Dict& message);
void HandleGetThumbnailMessage(const base::Value::Dict& message);

@ -3467,6 +3467,14 @@ gfx::Rect PDFiumEngine::GetScreenRect(const gfx::Rect& rect) const {
return draw_utils::GetScreenRect(rect, position_, current_zoom_);
}
gfx::RectF PDFiumEngine::GetPageBoundingBox(int page_index) {
PDFiumPage* page = GetPage(page_index);
if (!page) {
return gfx::RectF();
}
return page->GetBoundingBox();
}
void PDFiumEngine::Highlight(void* buffer,
int stride,
const gfx::Rect& rect,

@ -37,6 +37,7 @@
#include "third_party/skia/include/core/SkBitmap.h"
#include "ui/gfx/geometry/point.h"
#include "ui/gfx/geometry/rect.h"
#include "ui/gfx/geometry/rect_f.h"
#include "ui/gfx/geometry/size.h"
#include "ui/gfx/geometry/vector2d.h"
@ -143,6 +144,7 @@ class PDFiumEngine : public PDFEngine,
gfx::Rect GetPageBoundsRect(int index) override;
gfx::Rect GetPageContentsRect(int index) override;
gfx::Rect GetPageScreenRect(int page_index) const override;
gfx::RectF GetPageBoundingBox(int page_index) override;
void SetGrayscale(bool grayscale) override;
int GetCharCount(int page_index) override;
gfx::RectF GetCharBounds(int page_index, int char_index) override;

@ -31,6 +31,8 @@
#include "third_party/pdfium/public/cpp/fpdf_scopers.h"
#include "third_party/pdfium/public/fpdf_annot.h"
#include "third_party/pdfium/public/fpdf_catalog.h"
#include "third_party/pdfium/public/fpdf_edit.h"
#include "third_party/pdfium/public/fpdfview.h"
#include "third_party/skia/include/core/SkImageInfo.h"
#include "third_party/skia/include/core/SkPixmap.h"
#include "ui/accessibility/accessibility_features.h"
@ -57,6 +59,17 @@ constexpr float k180DegreesInRadians = base::kPiFloat;
constexpr float k270DegreesInRadians = 3 * base::kPiFloat / 2;
constexpr float k360DegreesInRadians = 2 * base::kPiFloat;
constexpr float kPointsToPixels = static_cast<float>(printing::kPixelsPerInch) /
static_cast<float>(printing::kPointsPerInch);
// Page rotations in clockwise degrees.
enum class Rotation {
kRotate0 = 0,
kRotate90 = 1,
kRotate180 = 2,
kRotate270 = 3,
};
gfx::RectF FloatPageRectToPixelRect(FPDF_PAGE page, const gfx::RectF& input) {
int output_width = FPDF_GetPageWidthF(page);
int output_height = FPDF_GetPageHeightF(page);
@ -279,6 +292,80 @@ bool AreTextStyleEqual(FPDF_TEXTPAGE text_page,
char_style.is_bold == style.is_bold;
}
// Returns the bounds with the smallest left, smallest bottom, largest right,
// and largest top.
FS_RECTF GetLargestBounds(const FS_RECTF& largest_bounds,
const FS_RECTF& bounds) {
return {std::min(largest_bounds.left, bounds.left),
std::max(largest_bounds.top, bounds.top),
std::max(largest_bounds.right, bounds.right),
std::min(largest_bounds.bottom, bounds.bottom)};
}
gfx::RectF GetRotatedRectF(Rotation rotation,
gfx::SizeF page_size,
const FS_RECTF& original_bounds) {
FS_RECTF bounds;
// When the page is rotated 90 degrees or 270 degrees, the page width and
// height are swapped. Swap it back for calculations.
if (rotation == Rotation::kRotate90 || rotation == Rotation::kRotate270) {
page_size.Transpose();
}
switch (rotation) {
case Rotation::kRotate0: {
bounds = original_bounds;
break;
}
case Rotation::kRotate90: {
bounds.left = original_bounds.bottom;
bounds.top = page_size.width() - original_bounds.left;
bounds.right = original_bounds.top;
bounds.bottom = page_size.width() - original_bounds.right;
break;
}
case Rotation::kRotate180: {
bounds.left = page_size.width() - original_bounds.right;
bounds.top = page_size.height() - original_bounds.bottom;
bounds.right = page_size.width() - original_bounds.left;
bounds.bottom = page_size.height() - original_bounds.top;
break;
}
case Rotation::kRotate270: {
bounds.left = page_size.height() - original_bounds.top;
bounds.top = original_bounds.right;
bounds.right = page_size.height() - original_bounds.bottom;
bounds.bottom = original_bounds.left;
break;
}
}
return gfx::RectF(bounds.left, bounds.bottom, bounds.right - bounds.left,
bounds.top - bounds.bottom);
}
// Get the effective crop box. If empty or failed to calculate the effective
// crop box, default to a `gfx::RectF` with dimensions page width by page
// height.
gfx::RectF GetEffectiveCropBox(FPDF_PAGE page,
Rotation rotation,
const gfx::SizeF& page_size) {
gfx::RectF effective_crop_box;
FS_RECTF effective_crop_bounds;
if (FPDF_GetPageBoundingBox(page, &effective_crop_bounds)) {
effective_crop_box =
GetRotatedRectF(rotation, page_size, effective_crop_bounds);
}
if (effective_crop_box.IsEmpty()) {
effective_crop_box =
gfx::RectF(0, 0, page_size.width(), page_size.height());
}
return effective_crop_box;
}
} // namespace
PDFiumPage::LinkTarget::LinkTarget() : page(-1) {}
@ -562,6 +649,69 @@ gfx::RectF PDFiumPage::GetCroppedRect() {
return FloatPageRectToPixelRect(page, rect);
}
gfx::RectF PDFiumPage::GetBoundingBox() {
FPDF_PAGE page = GetPage();
if (!page) {
return gfx::RectF();
}
// Page width and height are already swapped based on page rotation.
gfx::SizeF page_size(FPDF_GetPageWidthF(page), FPDF_GetPageHeightF(page));
Rotation rotation = static_cast<Rotation>(FPDFPage_GetRotation(page));
// Start with bounds with the left and bottom values at the max possible
// bounds and the right and top values at the min possible bounds. Bounds are
// relative to the media box.
FS_RECTF largest_bounds = {page_size.width(), 0, 0, page_size.height()};
for (int i = 0; i < FPDFPage_CountObjects(page); ++i) {
FPDF_PAGEOBJECT page_object = FPDFPage_GetObject(page, i);
if (!page_object) {
continue;
}
FS_RECTF bounds;
if (FPDFPageObj_GetBounds(page_object, &bounds.left, &bounds.bottom,
&bounds.right, &bounds.top)) {
largest_bounds = GetLargestBounds(largest_bounds, bounds);
}
}
for (int i = 0; i < FPDFPage_GetAnnotCount(page); ++i) {
ScopedFPDFAnnotation annotation(FPDFPage_GetAnnot(page, i));
if (!annotation) {
continue;
}
FS_RECTF bounds;
if (FPDFAnnot_GetRect(annotation.get(), &bounds)) {
largest_bounds = GetLargestBounds(largest_bounds, bounds);
}
}
gfx::RectF bounding_box =
GetRotatedRectF(rotation, page_size, largest_bounds);
gfx::RectF effective_crop_box =
GetEffectiveCropBox(page, rotation, page_size);
// If the bounding box is empty, default to the effective crop box.
if (bounding_box.IsEmpty()) {
bounding_box = effective_crop_box;
} else {
// Some bounding boxes may be out-of-bounds of `effective_crop_box`. Clip to
// be within `effective_crop_box`.
bounding_box.Intersect(effective_crop_box);
}
// Set the bounding box to be relative to the effective crop box.
bounding_box.set_x(bounding_box.x() - effective_crop_box.x());
bounding_box.set_y(bounding_box.y() - effective_crop_box.y());
// Scale to page pixels.
bounding_box.Scale(kPointsToPixels);
return bounding_box;
}
bool PDFiumPage::IsCharInPageBounds(int char_index,
const gfx::RectF& page_bounds) {
gfx::RectF char_bounds = GetCharBounds(char_index);

@ -69,6 +69,12 @@ class PDFiumPage {
// Get the bounds of the page with the crop box applied, in page pixels.
gfx::RectF GetCroppedRect();
// Get the bounding box of the page in page pixels. The bounding box is the
// largest rectangle containing all visible content in the effective crop box.
// If the bounding box can't be calculated, returns the effective crop box.
// The resulting bounding box is relative to the effective crop box.
gfx::RectF GetBoundingBox();
// Returns if the character at `char_index` is within `page_bounds`.
bool IsCharInPageBounds(int char_index, const gfx::RectF& page_bounds);

@ -137,6 +137,152 @@ TEST_P(PDFiumPageTest, IsCharInPageBounds) {
EXPECT_FALSE(page.IsCharInPageBounds(29, page_bounds));
}
TEST_P(PDFiumPageTest, GetBoundingBoxRotatedMultipage) {
// Check getting bounding box for multiple rotated pages.
TestClient client;
std::unique_ptr<PDFiumEngine> engine =
InitializeEngine(&client, FILE_PATH_LITERAL("rotated_multi_page.pdf"));
ASSERT_TRUE(engine);
ASSERT_EQ(4, engine->GetNumberOfPages());
// Rotation 0 degrees clockwise.
{
PDFiumPage& page = GetPDFiumPageForTest(*engine, 0);
const gfx::RectF bounding_box = page.GetBoundingBox();
EXPECT_FLOAT_EQ(0.0f, bounding_box.x());
EXPECT_FLOAT_EQ(266.66669f, bounding_box.y());
EXPECT_FLOAT_EQ(133.33334f, bounding_box.width());
EXPECT_FLOAT_EQ(400.0f, bounding_box.height());
}
// Rotation 90 degrees clockwise.
{
PDFiumPage& page = GetPDFiumPageForTest(*engine, 1);
const gfx::RectF bounding_box = page.GetBoundingBox();
EXPECT_FLOAT_EQ(266.66669f, bounding_box.x());
EXPECT_FLOAT_EQ(666.66669f, bounding_box.y());
EXPECT_FLOAT_EQ(400.0f, bounding_box.width());
EXPECT_FLOAT_EQ(133.33334f, bounding_box.height());
}
// Rotation 180 degrees clockwise.
{
PDFiumPage& page = GetPDFiumPageForTest(*engine, 2);
const gfx::RectF bounding_box = page.GetBoundingBox();
EXPECT_FLOAT_EQ(666.66669f, bounding_box.x());
EXPECT_FLOAT_EQ(933.33337f, bounding_box.y());
EXPECT_FLOAT_EQ(133.33334f, bounding_box.width());
EXPECT_FLOAT_EQ(400.0f, bounding_box.height());
}
// Rotation 270 degrees clockwise.
{
PDFiumPage& page = GetPDFiumPageForTest(*engine, 3);
const gfx::RectF bounding_box = page.GetBoundingBox();
EXPECT_FLOAT_EQ(933.33337f, bounding_box.x());
EXPECT_FLOAT_EQ(0.0f, bounding_box.y());
EXPECT_FLOAT_EQ(400.0f, bounding_box.width());
EXPECT_FLOAT_EQ(133.33334f, bounding_box.height());
}
}
TEST_P(PDFiumPageTest, GetBoundingBoxAnnotations) {
// Check getting the bounding box for annotations.
TestClient client;
std::unique_ptr<PDFiumEngine> engine =
InitializeEngine(&client, FILE_PATH_LITERAL("annots.pdf"));
ASSERT_TRUE(engine);
ASSERT_EQ(1, engine->GetNumberOfPages());
PDFiumPage& page = GetPDFiumPageForTest(*engine, 0);
const gfx::RectF bounding_box = page.GetBoundingBox();
EXPECT_FLOAT_EQ(92.0f, bounding_box.x());
EXPECT_FLOAT_EQ(450.66669, bounding_box.y());
EXPECT_FLOAT_EQ(201.33334f, bounding_box.width());
EXPECT_FLOAT_EQ(469.33334f, bounding_box.height());
}
TEST_P(PDFiumPageTest, GetBoundingBoxBlankPage) {
// Check getting the bounding box for a blank page. The bounding box should be
// the crop box scaled to page pixels.
TestClient client;
std::unique_ptr<PDFiumEngine> engine =
InitializeEngine(&client, FILE_PATH_LITERAL("blank.pdf"));
ASSERT_TRUE(engine);
ASSERT_EQ(1, engine->GetNumberOfPages());
// The crop box is 200x200 in points.
PDFiumPage& page = GetPDFiumPageForTest(*engine, 0);
const gfx::RectF bounding_box = page.GetBoundingBox();
EXPECT_FLOAT_EQ(0.0f, bounding_box.x());
EXPECT_FLOAT_EQ(0.0f, bounding_box.y());
EXPECT_FLOAT_EQ(266.66669f, bounding_box.width());
EXPECT_FLOAT_EQ(266.66669f, bounding_box.height());
}
TEST_P(PDFiumPageTest, GetBoundingBoxCropped) {
// Check getting the bounding box for a page with a crop box different than
// the media box.
TestClient client;
std::unique_ptr<PDFiumEngine> engine =
InitializeEngine(&client, FILE_PATH_LITERAL("landscape_rectangles.pdf"));
ASSERT_TRUE(engine);
ASSERT_EQ(1, engine->GetNumberOfPages());
PDFiumPage& page = GetPDFiumPageForTest(*engine, 0);
const gfx::RectF bounding_box = page.GetBoundingBox();
EXPECT_FLOAT_EQ(0.0f, bounding_box.x());
EXPECT_FLOAT_EQ(0.0f, bounding_box.y());
EXPECT_FLOAT_EQ(800.0f, bounding_box.width());
EXPECT_FLOAT_EQ(533.33337f, bounding_box.height());
}
TEST_P(PDFiumPageTest, GetBoundingBoxRotatedMultipageCropped) {
// Check getting the bounding box for a multiple rotated pages with a crop
// box.
TestClient client;
std::unique_ptr<PDFiumEngine> engine = InitializeEngine(
&client, FILE_PATH_LITERAL("rotated_multi_page_cropped.pdf"));
ASSERT_TRUE(engine);
ASSERT_EQ(4, engine->GetNumberOfPages());
// Rotation 0 degrees clockwise.
{
PDFiumPage& page = GetPDFiumPageForTest(*engine, 0);
const gfx::RectF bounding_box = page.GetBoundingBox();
EXPECT_FLOAT_EQ(0.0f, bounding_box.x());
EXPECT_FLOAT_EQ(133.33334f, bounding_box.y());
EXPECT_FLOAT_EQ(66.666672f, bounding_box.width());
EXPECT_FLOAT_EQ(400.0f, bounding_box.height());
}
// Rotation 90 degrees clockwise.
{
PDFiumPage& page = GetPDFiumPageForTest(*engine, 1);
const gfx::RectF bounding_box = page.GetBoundingBox();
EXPECT_FLOAT_EQ(133.33334f, bounding_box.x());
EXPECT_FLOAT_EQ(400.0f, bounding_box.y());
EXPECT_FLOAT_EQ(400.0f, bounding_box.width());
EXPECT_FLOAT_EQ(66.666672f, bounding_box.height());
}
// Rotation 180 degrees clockwise.
{
PDFiumPage& page = GetPDFiumPageForTest(*engine, 2);
const gfx::RectF bounding_box = page.GetBoundingBox();
EXPECT_FLOAT_EQ(400.0f, bounding_box.x());
EXPECT_FLOAT_EQ(133.33334f, bounding_box.y());
EXPECT_FLOAT_EQ(66.666672f, bounding_box.width());
EXPECT_FLOAT_EQ(400.0f, bounding_box.height());
}
// Rotation 270 degrees clockwise.
{
PDFiumPage& page = GetPDFiumPageForTest(*engine, 3);
const gfx::RectF bounding_box = page.GetBoundingBox();
EXPECT_FLOAT_EQ(133.33334f, bounding_box.x());
EXPECT_FLOAT_EQ(0.0f, bounding_box.y());
EXPECT_FLOAT_EQ(400.0f, bounding_box.width());
EXPECT_FLOAT_EQ(66.666672f, bounding_box.height());
}
}
INSTANTIATE_TEST_SUITE_P(All, PDFiumPageTest, testing::Bool());
class PDFiumPageLinkTest : public PDFiumTestBase {

21
pdf/test/data/blank.in Normal file

@ -0,0 +1,21 @@
{{header}}
{{object 1 0}} <<
/Type /Catalog
/Pages 2 0 R
>>
endobj
{{object 2 0}} <<
/Type /Pages
/Count 1
/MediaBox [0 0 200 200]
/Kids [3 0 R]
>>
endobj
{{object 3 0}} <<
/Type /Page
/Parent 2 0 R
>>
{{xref}}
{{trailer}}
{{startxref}}
%%EOF

31
pdf/test/data/blank.pdf Normal file

@ -0,0 +1,31 @@
%PDF-1.7
%<25><><EFBFBD><EFBFBD>
1 0 obj <<
/Type /Catalog
/Pages 2 0 R
>>
endobj
2 0 obj <<
/Type /Pages
/Count 1
/MediaBox [0 0 200 200]
/Kids [3 0 R]
>>
endobj
3 0 obj <<
/Type /Page
/Parent 2 0 R
>>
xref
0 4
0000000000 65535 f
0000000015 00000 n
0000000068 00000 n
0000000157 00000 n
trailer <<
/Root 1 0 R
/Size 4
>>
startxref
201
%%EOF

@ -0,0 +1,54 @@
{{header}}
{{object 1 0}} <<
/Type /Catalog
/Pages 2 0 R
>>
endobj
{{object 2 0}} <<
/Type /Pages
/Count 4
/MediaBox [0 0 600 1200]
/Kids [3 0 R 4 0 R 5 0 R 6 0 R]
>>
endobj
{{object 3 0}} <<
/Type /Page
/Parent 2 0 R
/Contents 7 0 R
>>
endobj
{{object 4 0}} <<
/Type /Page
/Parent 2 0 R
/Contents 7 0 R
/Rotate 90
>>
endobj
{{object 5 0}} <<
/Type /Page
/Parent 2 0 R
/Contents 7 0 R
/Rotate 180
>>
endobj
{{object 6 0}} <<
/Type /Page
/Parent 2 0 R
/Contents 7 0 R
/Rotate 270
>>
endobj
{{object 7 0}} <<
{{streamlen}}
>>
stream
q
1 1 0 rg
1 201 98 298 re B*
Q
endstream
endobj
{{xref}}
{{trailer}}
{{startxref}}
%%EOF

@ -0,0 +1,68 @@
%PDF-1.7
%<25><><EFBFBD><EFBFBD>
1 0 obj <<
/Type /Catalog
/Pages 2 0 R
>>
endobj
2 0 obj <<
/Type /Pages
/Count 4
/MediaBox [0 0 600 1200]
/Kids [3 0 R 4 0 R 5 0 R 6 0 R]
>>
endobj
3 0 obj <<
/Type /Page
/Parent 2 0 R
/Contents 7 0 R
>>
endobj
4 0 obj <<
/Type /Page
/Parent 2 0 R
/Contents 7 0 R
/Rotate 90
>>
endobj
5 0 obj <<
/Type /Page
/Parent 2 0 R
/Contents 7 0 R
/Rotate 180
>>
endobj
6 0 obj <<
/Type /Page
/Parent 2 0 R
/Contents 7 0 R
/Rotate 270
>>
endobj
7 0 obj <<
/Length 32
>>
stream
q
1 1 0 rg
1 201 98 298 re B*
Q
endstream
endobj
xref
0 8
0000000000 65535 f
0000000015 00000 n
0000000068 00000 n
0000000176 00000 n
0000000245 00000 n
0000000327 00000 n
0000000410 00000 n
0000000493 00000 n
trailer <<
/Root 1 0 R
/Size 8
>>
startxref
576
%%EOF

@ -0,0 +1,55 @@
{{header}}
{{object 1 0}} <<
/Type /Catalog
/Pages 2 0 R
>>
endobj
{{object 2 0}} <<
/Type /Pages
/Count 4
/CropBox [50 100 400 600]
/MediaBox [0 0 600 1200]
/Kids [3 0 R 4 0 R 5 0 R 6 0 R]
>>
endobj
{{object 3 0}} <<
/Type /Page
/Parent 2 0 R
/Contents 7 0 R
>>
endobj
{{object 4 0}} <<
/Type /Page
/Parent 2 0 R
/Contents 7 0 R
/Rotate 90
>>
endobj
{{object 5 0}} <<
/Type /Page
/Parent 2 0 R
/Contents 7 0 R
/Rotate 180
>>
endobj
{{object 6 0}} <<
/Type /Page
/Parent 2 0 R
/Contents 7 0 R
/Rotate 270
>>
endobj
{{object 7 0}} <<
{{streamlen}}
>>
stream
q
1 1 0 rg
1 201 98 298 re B*
Q
endstream
endobj
{{xref}}
{{trailer}}
{{startxref}}
%%EOF

@ -0,0 +1,69 @@
%PDF-1.7
%<25><><EFBFBD><EFBFBD>
1 0 obj <<
/Type /Catalog
/Pages 2 0 R
>>
endobj
2 0 obj <<
/Type /Pages
/Count 4
/CropBox [50 100 400 600]
/MediaBox [0 0 600 1200]
/Kids [3 0 R 4 0 R 5 0 R 6 0 R]
>>
endobj
3 0 obj <<
/Type /Page
/Parent 2 0 R
/Contents 7 0 R
>>
endobj
4 0 obj <<
/Type /Page
/Parent 2 0 R
/Contents 7 0 R
/Rotate 90
>>
endobj
5 0 obj <<
/Type /Page
/Parent 2 0 R
/Contents 7 0 R
/Rotate 180
>>
endobj
6 0 obj <<
/Type /Page
/Parent 2 0 R
/Contents 7 0 R
/Rotate 270
>>
endobj
7 0 obj <<
/Length 32
>>
stream
q
1 1 0 rg
1 201 98 298 re B*
Q
endstream
endobj
xref
0 8
0000000000 65535 f
0000000015 00000 n
0000000068 00000 n
0000000204 00000 n
0000000273 00000 n
0000000355 00000 n
0000000438 00000 n
0000000521 00000 n
trailer <<
/Root 1 0 R
/Size 8
>>
startxref
604
%%EOF