Skip to content

Commit 886c90d

Browse files
Add support for right-clicking on images
This patch adds right-click support for images in the PDF, allowing users to download them. To minimize memory consumption, we: - Do not store the images separately, and instead crop them out of the PDF page canvas - Only extract the images when needed (i.e. when the user right-clicks on them), rather than eagery having all of them available. To do so, we layer one empty 0x0 canvas per image, stretched to cover the whole image, and only populate its contents on right click. These images need to be inside the text layer: they cannot be _behind_ it, otherwise they would be covered by the text layer's container and not be clickable, and they cannot be in front of it, otherwise they would make the text spans unselectable. This feature is managed by a new preference, `imagesRightClickMinSize`: - when it's set to `-1`, right-click support is disabled - when set to `0`, all images are available for right click - when set to a positive integer, only images whose width and height are greater than or equal to that value (in the PDF page frame of reference) are available for right click. This features is disabled by default outside of MOZCENTRAL, as it significantly degrades the text selection experience in non-Firefox browsers.
1 parent a4fcd83 commit 886c90d

22 files changed

Lines changed: 788 additions & 25 deletions

src/display/api.js

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ import {
3838
PrintAnnotationStorage,
3939
SerializableEmpty,
4040
} from "./annotation_storage.js";
41+
import {
42+
CanvasDependencyTracker,
43+
CanvasImagesTracker,
44+
} from "./canvas_dependency_tracker.js";
4145
import {
4246
deprecated,
4347
isDataScheme,
@@ -68,7 +72,6 @@ import {
6872
NodeStandardFontDataFactory,
6973
NodeWasmFactory,
7074
} from "display-node_utils";
71-
import { CanvasDependencyTracker } from "./canvas_dependency_tracker.js";
7275
import { CanvasGraphics } from "./canvas.js";
7376
import { DOMCanvasFactory } from "./canvas_factory.js";
7477
import { DOMCMapReaderFactory } from "display-cmap_reader_factory";
@@ -1269,6 +1272,7 @@ class PDFDocumentProxy {
12691272
* annotation ids with canvases used to render them.
12701273
* @property {PrintAnnotationStorage} [printAnnotationStorage]
12711274
* @property {boolean} [isEditing] - Render the page in editing mode.
1275+
* @property {boolean} [recordImages] - Record the location of images in the PDF
12721276
* @property {boolean} [recordOperations] - Record the dependencies and bounding
12731277
* boxes of all PDF operations that render onto the canvas.
12741278
* @property {OperationsFilter} [operationsFilter] - If provided, only
@@ -1353,6 +1357,7 @@ class PDFPageProxy {
13531357
this.destroyed = false;
13541358
this.recordedBBoxes = null;
13551359
this.#pagesMapper = pagesMapper;
1360+
this.imageCoordinates = null;
13561361
}
13571362

13581363
/**
@@ -1484,6 +1489,7 @@ class PDFPageProxy {
14841489
pageColors = null,
14851490
printAnnotationStorage = null,
14861491
isEditing = false,
1492+
recordImages = false,
14871493
recordOperations = false,
14881494
operationsFilter = null,
14891495
}) {
@@ -1536,6 +1542,7 @@ class PDFPageProxy {
15361542

15371543
const shouldRecordOperations =
15381544
!this.recordedBBoxes && (recordOperations || recordForDebugger);
1545+
const shouldRecordImages = !this.imageCoordinates && recordImages;
15391546

15401547
const complete = error => {
15411548
intentState.renderTasks.delete(internalRenderTask);
@@ -1555,6 +1562,10 @@ class PDFPageProxy {
15551562
}
15561563
}
15571564

1565+
if (shouldRecordImages && !error) {
1566+
this.imageCoordinates = internalRenderTask.gfx?.imagesTracker.take();
1567+
}
1568+
15581569
// Attempt to reduce memory usage during *printing*, by always running
15591570
// cleanup immediately once rendering has finished.
15601571
if (intentPrint) {
@@ -1589,12 +1600,16 @@ class PDFPageProxy {
15891600
params: {
15901601
canvas,
15911602
canvasContext,
1592-
dependencyTracker: shouldRecordOperations
1593-
? new CanvasDependencyTracker(
1594-
canvas,
1595-
intentState.operatorList.length,
1596-
recordForDebugger
1597-
)
1603+
dependencyTracker:
1604+
shouldRecordOperations || shouldRecordImages
1605+
? new CanvasDependencyTracker(
1606+
canvas,
1607+
intentState.operatorList.length,
1608+
recordForDebugger
1609+
)
1610+
: null,
1611+
imagesTracker: shouldRecordImages
1612+
? new CanvasImagesTracker(canvas)
15981613
: null,
15991614
viewport,
16001615
transform,
@@ -3255,6 +3270,10 @@ class RenderTask {
32553270
(separateAnnots.canvas && annotationCanvasMap?.size > 0)
32563271
);
32573272
}
3273+
3274+
get imageCoordinates() {
3275+
return this._internalRenderTask.imageCoordinates || null;
3276+
}
32583277
}
32593278

32603279
/**
@@ -3312,6 +3331,7 @@ class InternalRenderTask {
33123331
this._canvasContext = params.canvas ? null : params.canvasContext;
33133332
this._enableHWA = enableHWA;
33143333
this._dependencyTracker = params.dependencyTracker;
3334+
this._imagesTracker = params.imagesTracker;
33153335
this._operationsFilter = operationsFilter;
33163336
}
33173337

@@ -3342,7 +3362,13 @@ class InternalRenderTask {
33423362
this.stepper.init(this.operatorList);
33433363
this.stepper.nextBreakPoint = this.stepper.getNextBreakPoint();
33443364
}
3345-
const { viewport, transform, background, dependencyTracker } = this.params;
3365+
const {
3366+
viewport,
3367+
transform,
3368+
background,
3369+
dependencyTracker,
3370+
imagesTracker,
3371+
} = this.params;
33463372

33473373
// When printing in Firefox, we get a specific context in mozPrintCallback
33483374
// which cannot be created from the canvas itself.
@@ -3362,7 +3388,8 @@ class InternalRenderTask {
33623388
{ optionalContentConfig },
33633389
this.annotationCanvasMap,
33643390
this.pageColors,
3365-
dependencyTracker
3391+
dependencyTracker,
3392+
imagesTracker
33663393
);
33673394
this.gfx.beginDrawing({
33683395
transform,

src/display/canvas.js

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -658,7 +658,8 @@ class CanvasGraphics {
658658
{ optionalContentConfig, markedContentStack = null },
659659
annotationCanvasMap,
660660
pageColors,
661-
dependencyTracker
661+
dependencyTracker,
662+
imagesTracker
662663
) {
663664
this.ctx = canvasCtx;
664665
this.current = new CanvasExtraState(
@@ -698,6 +699,7 @@ class CanvasGraphics {
698699
this._cachedBitmapsMap = new Map();
699700

700701
this.dependencyTracker = dependencyTracker ?? null;
702+
this.imagesTracker = imagesTracker ?? null;
701703
}
702704

703705
getObject(opIdx, data, fallback = null) {
@@ -3064,11 +3066,19 @@ class CanvasGraphics {
30643066
imgData.interpolate
30653067
);
30663068

3067-
this.dependencyTracker
3068-
?.resetBBox(opIdx)
3069-
.recordBBox(opIdx, ctx, 0, width, -height, 0)
3070-
.recordDependencies(opIdx, Dependencies.imageXObject)
3071-
.recordOperation(opIdx);
3069+
if (this.dependencyTracker) {
3070+
this.dependencyTracker
3071+
.resetBBox(opIdx)
3072+
.recordBBox(opIdx, ctx, 0, width, -height, 0)
3073+
.recordDependencies(opIdx, Dependencies.imageXObject)
3074+
.recordOperation(opIdx);
3075+
this.imagesTracker?.record(
3076+
ctx,
3077+
width,
3078+
height,
3079+
this.dependencyTracker.clipBox
3080+
);
3081+
}
30723082

30733083
drawImageAtIntegerCoords(
30743084
ctx,

src/display/canvas_dependency_tracker.js

Lines changed: 161 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
* limitations under the License.
1414
*/
1515

16-
import { Util } from "../shared/util.js";
16+
import { FeatureTest, Util } from "../shared/util.js";
1717

1818
const FORCED_DEPENDENCY_LABEL = "__forcedDependency";
1919

@@ -139,6 +139,10 @@ class CanvasDependencyTracker {
139139
}
140140
}
141141

142+
get clipBox() {
143+
return this.#clipBox;
144+
}
145+
142146
growOperationsCount(operationsCount) {
143147
if (operationsCount >= this.#bboxes.length) {
144148
this.#initializeBBoxes(operationsCount, this.#bboxes);
@@ -644,6 +648,10 @@ class CanvasNestedDependencyTracker {
644648
this.#ignoreBBoxes = !!ignoreBBoxes;
645649
}
646650

651+
get clipBox() {
652+
return this.#dependencyTracker.clipBox;
653+
}
654+
647655
growOperationsCount() {
648656
throw new Error("Unreachable");
649657
}
@@ -918,4 +926,155 @@ const Dependencies = {
918926
transformAndFill: ["transform", "fillColor"],
919927
};
920928

921-
export { CanvasDependencyTracker, CanvasNestedDependencyTracker, Dependencies };
929+
/**
930+
* Track the locations of images in the canvas. For each image it computes
931+
* a bounding box as a potentially rotated rectangle, matching the rotation of
932+
* the current canvas transform.
933+
*/
934+
class CanvasImagesTracker {
935+
#canvasWidth;
936+
937+
#canvasHeight;
938+
939+
#capacity = 4;
940+
941+
#count = 0;
942+
943+
// Array of [x1, y1, x2, y2, x3, y3] coordinates.
944+
// We need three points to be able to represent a rectangle with a transform
945+
// applied.
946+
#coords = new CanvasImagesTracker.#CoordsArray(this.#capacity * 6);
947+
948+
static #CoordsArray =
949+
(typeof PDFJSDev !== "undefined" && PDFJSDev.test("MOZCENTRAL")) ||
950+
FeatureTest.isFloat16ArraySupported
951+
? Float16Array
952+
: Float32Array;
953+
954+
constructor(canvas) {
955+
this.#canvasWidth = canvas.width;
956+
this.#canvasHeight = canvas.height;
957+
}
958+
959+
record(ctx, width, height, clipBox) {
960+
if (this.#count === this.#capacity) {
961+
this.#capacity *= 2;
962+
const newCoords = new CanvasImagesTracker.#CoordsArray(
963+
this.#capacity * 6
964+
);
965+
newCoords.set(this.#coords);
966+
this.#coords = newCoords;
967+
}
968+
969+
const transform = Util.domMatrixToTransform(ctx.getTransform());
970+
971+
// We want top left, bottom left, top right.
972+
// (0, 0) is the bottom left corner.
973+
let coords;
974+
975+
if (clipBox[0] !== Infinity) {
976+
const bbox = [Infinity, Infinity, -Infinity, -Infinity];
977+
Util.axialAlignedBoundingBox([0, -height, width, 0], transform, bbox);
978+
979+
const finalBBox = Util.intersect(clipBox, bbox);
980+
if (!finalBBox) {
981+
// The image is fully clipped out.
982+
return;
983+
}
984+
985+
const [minX, minY, maxX, maxY] = finalBBox;
986+
987+
if (
988+
minX !== bbox[0] ||
989+
minY !== bbox[1] ||
990+
maxX !== bbox[2] ||
991+
maxY !== bbox[3]
992+
) {
993+
// The clip box affects the image drawing. We need to compute a
994+
// transform that takes the image bbox and fits it into the final bbox,
995+
// so that we can then apply it to the original image shape (the
996+
// non-axially-aligned rectangle).
997+
const rotationAngle = Math.atan2(transform[1], transform[0]);
998+
999+
// Normalize the angle to be between 0 and 90 degrees.
1000+
const sin = Math.abs(Math.sin(rotationAngle));
1001+
const cos = Math.abs(Math.cos(rotationAngle));
1002+
1003+
if (
1004+
sin < 1e-6 ||
1005+
cos < 1e-6 ||
1006+
// The logic in the `else` case gives more accurate bounding boxes for
1007+
// rotated images, but the equation it uses does not give a result
1008+
// when the rotation is exactly 45 degrees, because there are infinite
1009+
// possible rectangles that can fit into the same bbox with that same
1010+
// 45deg rotation. Fallback to returning the whole bbox.
1011+
Math.abs(sin - cos) < 1e-6
1012+
) {
1013+
coords = [minX, minY, minX, maxY, maxX, minY];
1014+
} else {
1015+
// We cannot just scale the bbox into the original bbox, because that
1016+
// would not preserve the 90deg corners if they have been rotated.
1017+
// We instead need to find the transform that maps the original
1018+
// rectangle into the only rectangle that is rotated by the expected
1019+
// angle and fits into the final bbox.
1020+
//
1021+
// This represents the final bbox, with the top-left corner having
1022+
// coordinates (minX, minY) and the bottom-right corner having
1023+
// coordinates (maxX, maxY). Alpha is the rotation angle, and a and b
1024+
// are helper variables used to compute the effective transform.
1025+
//
1026+
// ------------b----------
1027+
// +-----------------------*----+
1028+
// | | _ -‾ \ |
1029+
// a | _ -‾ \ |
1030+
// | |alpha _ -‾ \ |
1031+
// | | _ -‾ \|
1032+
// |\ _ -‾|
1033+
// | \ _ -‾ |
1034+
// | \ _ -‾ |
1035+
// | \ _ -‾ |
1036+
// +----*-----------------------+
1037+
1038+
const finalBBoxWidth = maxX - minX;
1039+
const finalBBoxHeight = maxY - minY;
1040+
1041+
const sin2 = sin * sin;
1042+
const cos2 = cos * cos;
1043+
const cosSin = cos * sin;
1044+
const denom = cos2 - sin2;
1045+
1046+
const a = (finalBBoxHeight * cos2 - finalBBoxWidth * cosSin) / denom;
1047+
const b = (finalBBoxHeight * cosSin - finalBBoxWidth * sin2) / denom;
1048+
1049+
coords = [minX + b, minY, minX, minY + a, maxX, maxY - a];
1050+
}
1051+
}
1052+
}
1053+
1054+
if (!coords) {
1055+
coords = [0, -height, 0, 0, width, -height];
1056+
Util.applyTransform(coords, transform, 0);
1057+
Util.applyTransform(coords, transform, 2);
1058+
Util.applyTransform(coords, transform, 4);
1059+
}
1060+
coords[0] /= this.#canvasWidth;
1061+
coords[1] /= this.#canvasHeight;
1062+
coords[2] /= this.#canvasWidth;
1063+
coords[3] /= this.#canvasHeight;
1064+
coords[4] /= this.#canvasWidth;
1065+
coords[5] /= this.#canvasHeight;
1066+
this.#coords.set(coords, this.#count * 6);
1067+
this.#count++;
1068+
}
1069+
1070+
take() {
1071+
return this.#coords.subarray(0, this.#count * 6);
1072+
}
1073+
}
1074+
1075+
export {
1076+
CanvasDependencyTracker,
1077+
CanvasImagesTracker,
1078+
CanvasNestedDependencyTracker,
1079+
Dependencies,
1080+
};

0 commit comments

Comments
 (0)