Merge pull request #20626 from nicolo-ribaudo/images-right-click

Add support for right-clicking on images (bug 1012805)
This commit is contained in:
calixteman 2026-03-11 11:45:51 +01:00 committed by GitHub
commit 9d093d9607
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1193 additions and 253 deletions

View File

@ -38,6 +38,11 @@ import {
PrintAnnotationStorage,
SerializableEmpty,
} from "./annotation_storage.js";
import {
CanvasBBoxTracker,
CanvasDependencyTracker,
CanvasImagesTracker,
} from "./canvas_dependency_tracker.js";
import {
deprecated,
isDataScheme,
@ -68,7 +73,6 @@ import {
NodeStandardFontDataFactory,
NodeWasmFactory,
} from "display-node_utils";
import { CanvasDependencyTracker } from "./canvas_dependency_tracker.js";
import { CanvasGraphics } from "./canvas.js";
import { DOMCanvasFactory } from "./canvas_factory.js";
import { DOMCMapReaderFactory } from "display-cmap_reader_factory";
@ -1273,6 +1277,7 @@ class PDFDocumentProxy {
* annotation ids with canvases used to render them.
* @property {PrintAnnotationStorage} [printAnnotationStorage]
* @property {boolean} [isEditing] - Render the page in editing mode.
* @property {boolean} [recordImages] - Record the location of images in the PDF
* @property {boolean} [recordOperations] - Record the dependencies and bounding
* boxes of all PDF operations that render onto the canvas.
* @property {OperationsFilter} [operationsFilter] - If provided, only
@ -1357,6 +1362,7 @@ class PDFPageProxy {
this.destroyed = false;
this.recordedBBoxes = null;
this.#pagesMapper = pagesMapper;
this.imageCoordinates = null;
}
/**
@ -1488,6 +1494,7 @@ class PDFPageProxy {
pageColors = null,
printAnnotationStorage = null,
isEditing = false,
recordImages = false,
recordOperations = false,
operationsFilter = null,
}) {
@ -1539,6 +1546,7 @@ class PDFPageProxy {
);
const shouldRecordOperations =
!this.recordedBBoxes && (recordOperations || recordForDebugger);
const shouldRecordImages = !this.imageCoordinates && recordImages;
const complete = error => {
intentState.renderTasks.delete(internalRenderTask);
@ -1557,6 +1565,10 @@ class PDFPageProxy {
}
}
if (shouldRecordImages && !error) {
this.imageCoordinates = internalRenderTask.gfx?.imagesTracker.take();
}
// Attempt to reduce memory usage during *printing*, by always running
// cleanup immediately once rendering has finished.
if (intentPrint) {
@ -1585,18 +1597,30 @@ class PDFPageProxy {
}
};
let dependencyTracker = null;
let bboxTracker = null;
if (shouldRecordOperations || shouldRecordImages) {
bboxTracker = new CanvasBBoxTracker(
canvas,
intentState.operatorList.length
);
}
if (shouldRecordOperations) {
dependencyTracker = new CanvasDependencyTracker(
bboxTracker,
recordForDebugger
);
}
const internalRenderTask = new InternalRenderTask({
callback: complete,
// Only include the required properties, and *not* the entire object.
params: {
canvas,
canvasContext,
dependencyTracker: shouldRecordOperations
? new CanvasDependencyTracker(
canvas,
intentState.operatorList.length,
recordForDebugger
)
dependencyTracker: dependencyTracker ?? bboxTracker,
imagesTracker: shouldRecordImages
? new CanvasImagesTracker(canvas)
: null,
viewport,
transform,
@ -3288,6 +3312,10 @@ class RenderTask {
(separateAnnots.canvas && annotationCanvasMap?.size > 0)
);
}
get imageCoordinates() {
return this._internalRenderTask.imageCoordinates || null;
}
}
/**
@ -3345,6 +3373,7 @@ class InternalRenderTask {
this._canvasContext = params.canvas ? null : params.canvasContext;
this._enableHWA = enableHWA;
this._dependencyTracker = params.dependencyTracker;
this._imagesTracker = params.imagesTracker;
this._operationsFilter = operationsFilter;
}
@ -3375,7 +3404,13 @@ class InternalRenderTask {
this.stepper.init(this.operatorList);
this.stepper.nextBreakPoint = this.stepper.getNextBreakPoint();
}
const { viewport, transform, background, dependencyTracker } = this.params;
const {
viewport,
transform,
background,
dependencyTracker,
imagesTracker,
} = this.params;
// When printing in Firefox, we get a specific context in mozPrintCallback
// which cannot be created from the canvas itself.
@ -3395,7 +3430,8 @@ class InternalRenderTask {
{ optionalContentConfig },
this.annotationCanvasMap,
this.pageColors,
dependencyTracker
dependencyTracker,
imagesTracker
);
this.gfx.beginDrawing({
transform,

View File

@ -658,7 +658,8 @@ class CanvasGraphics {
{ optionalContentConfig, markedContentStack = null },
annotationCanvasMap,
pageColors,
dependencyTracker
dependencyTracker,
imagesTracker
) {
this.ctx = canvasCtx;
this.current = new CanvasExtraState(
@ -698,6 +699,7 @@ class CanvasGraphics {
this._cachedBitmapsMap = new Map();
this.dependencyTracker = dependencyTracker ?? null;
this.imagesTracker = imagesTracker ?? null;
}
getObject(opIdx, data, fallback = null) {
@ -3064,11 +3066,19 @@ class CanvasGraphics {
imgData.interpolate
);
this.dependencyTracker
?.resetBBox(opIdx)
.recordBBox(opIdx, ctx, 0, width, -height, 0)
.recordDependencies(opIdx, Dependencies.imageXObject)
.recordOperation(opIdx);
if (this.dependencyTracker) {
this.dependencyTracker
.resetBBox(opIdx)
.recordBBox(opIdx, ctx, 0, width, -height, 0)
.recordDependencies(opIdx, Dependencies.imageXObject)
.recordOperation(opIdx);
this.imagesTracker?.record(
ctx,
width,
height,
this.dependencyTracker.clipBox
);
}
drawImageAtIntegerCoords(
ctx,

View File

@ -13,7 +13,7 @@
* limitations under the License.
*/
import { Util } from "../shared/util.js";
import { FeatureTest, Util } from "../shared/util.js";
const FORCED_DEPENDENCY_LABEL = "__forcedDependency";
@ -71,6 +71,323 @@ const ensureDebugMetadata = (map, key) =>
isRenderingOperation: false,
}));
// NOTE: CanvasBBoxTracker, CanvasDependencyTracker and
// CanvasNestedDependencyTracker must all have the same interface.
class CanvasBBoxTracker {
#baseTransformStack = [[1, 0, 0, 1, 0, 0]];
#clipBox = [-Infinity, -Infinity, Infinity, Infinity];
// Float32Array<minX, minY, maxX, maxY>
#pendingBBox = new Float64Array([Infinity, Infinity, -Infinity, -Infinity]);
_pendingBBoxIdx = -1;
#canvasWidth;
#canvasHeight;
// Uint8ClampedArray<minX, minY, maxX, maxY>
#bboxesCoords;
#bboxes;
_savesStack = [];
_markedContentStack = [];
constructor(canvas, operationsCount) {
this.#canvasWidth = canvas.width;
this.#canvasHeight = canvas.height;
this.#initializeBBoxes(operationsCount);
}
growOperationsCount(operationsCount) {
if (operationsCount >= this.#bboxes.length) {
this.#initializeBBoxes(operationsCount, this.#bboxes);
}
}
#initializeBBoxes(operationsCount, oldBBoxes) {
const buffer = new ArrayBuffer(operationsCount * 4);
this.#bboxesCoords = new Uint8ClampedArray(buffer);
this.#bboxes = new Uint32Array(buffer);
if (oldBBoxes && oldBBoxes.length > 0) {
this.#bboxes.set(oldBBoxes);
this.#bboxes.fill(EMPTY_BBOX, oldBBoxes.length);
} else {
this.#bboxes.fill(EMPTY_BBOX);
}
}
get clipBox() {
return this.#clipBox;
}
save(opIdx) {
this.#clipBox = { __proto__: this.#clipBox };
this._savesStack.push(opIdx);
return this;
}
restore(opIdx, onSavePopped) {
const previous = Object.getPrototypeOf(this.#clipBox);
if (previous === null) {
// Sometimes we call more .restore() than .save(), for
// example when using CanvasGraphics' #restoreInitialState()
return this;
}
this.#clipBox = previous;
const lastSave = this._savesStack.pop();
if (lastSave !== undefined) {
onSavePopped?.(lastSave, opIdx);
this.#bboxes[opIdx] = this.#bboxes[lastSave];
}
return this;
}
/**
* @param {number} idx
*/
recordOpenMarker(idx) {
this._savesStack.push(idx);
return this;
}
getOpenMarker() {
if (this._savesStack.length === 0) {
return null;
}
return this._savesStack.at(-1);
}
recordCloseMarker(opIdx, onSavePopped) {
const lastSave = this._savesStack.pop();
if (lastSave !== undefined) {
onSavePopped?.(lastSave, opIdx);
this.#bboxes[opIdx] = this.#bboxes[lastSave];
}
return this;
}
// Marked content needs a separate stack from save/restore, because they
// form two independent trees.
beginMarkedContent(opIdx) {
this._markedContentStack.push(opIdx);
return this;
}
endMarkedContent(opIdx, onSavePopped) {
const lastSave = this._markedContentStack.pop();
if (lastSave !== undefined) {
onSavePopped?.(lastSave, opIdx);
this.#bboxes[opIdx] = this.#bboxes[lastSave];
}
return this;
}
pushBaseTransform(ctx) {
this.#baseTransformStack.push(
Util.multiplyByDOMMatrix(
this.#baseTransformStack.at(-1),
ctx.getTransform()
)
);
return this;
}
popBaseTransform() {
if (this.#baseTransformStack.length > 1) {
this.#baseTransformStack.pop();
}
return this;
}
resetBBox(idx) {
if (this._pendingBBoxIdx !== idx) {
this._pendingBBoxIdx = idx;
this.#pendingBBox[0] = Infinity;
this.#pendingBBox[1] = Infinity;
this.#pendingBBox[2] = -Infinity;
this.#pendingBBox[3] = -Infinity;
}
return this;
}
recordClipBox(idx, ctx, minX, maxX, minY, maxY) {
const transform = Util.multiplyByDOMMatrix(
this.#baseTransformStack.at(-1),
ctx.getTransform()
);
const clipBox = [Infinity, Infinity, -Infinity, -Infinity];
Util.axialAlignedBoundingBox([minX, minY, maxX, maxY], transform, clipBox);
const intersection = Util.intersect(this.#clipBox, clipBox);
if (intersection) {
this.#clipBox[0] = intersection[0];
this.#clipBox[1] = intersection[1];
this.#clipBox[2] = intersection[2];
this.#clipBox[3] = intersection[3];
} else {
this.#clipBox[0] = this.#clipBox[1] = Infinity;
this.#clipBox[2] = this.#clipBox[3] = -Infinity;
}
return this;
}
recordBBox(idx, ctx, minX, maxX, minY, maxY) {
const clipBox = this.#clipBox;
if (clipBox[0] === Infinity) {
return this;
}
const transform = Util.multiplyByDOMMatrix(
this.#baseTransformStack.at(-1),
ctx.getTransform()
);
if (clipBox[0] === -Infinity) {
Util.axialAlignedBoundingBox(
[minX, minY, maxX, maxY],
transform,
this.#pendingBBox
);
return this;
}
const bbox = [Infinity, Infinity, -Infinity, -Infinity];
Util.axialAlignedBoundingBox([minX, minY, maxX, maxY], transform, bbox);
this.#pendingBBox[0] = Math.min(
this.#pendingBBox[0],
Math.max(bbox[0], clipBox[0])
);
this.#pendingBBox[1] = Math.min(
this.#pendingBBox[1],
Math.max(bbox[1], clipBox[1])
);
this.#pendingBBox[2] = Math.max(
this.#pendingBBox[2],
Math.min(bbox[2], clipBox[2])
);
this.#pendingBBox[3] = Math.max(
this.#pendingBBox[3],
Math.min(bbox[3], clipBox[3])
);
return this;
}
recordFullPageBBox(idx) {
this.#pendingBBox[0] = Math.max(0, this.#clipBox[0]);
this.#pendingBBox[1] = Math.max(0, this.#clipBox[1]);
this.#pendingBBox[2] = Math.min(this.#canvasWidth, this.#clipBox[2]);
this.#pendingBBox[3] = Math.min(this.#canvasHeight, this.#clipBox[3]);
return this;
}
/**
* @param {number} idx
*/
recordOperation(idx, preserve = false, dependencyLists) {
if (this._pendingBBoxIdx !== idx) {
return this;
}
const minX = floor((this.#pendingBBox[0] * 256) / this.#canvasWidth);
const minY = floor((this.#pendingBBox[1] * 256) / this.#canvasHeight);
const maxX = ceil((this.#pendingBBox[2] * 256) / this.#canvasWidth);
const maxY = ceil((this.#pendingBBox[3] * 256) / this.#canvasHeight);
expandBBox(this.#bboxesCoords, idx, minX, minY, maxX, maxY);
if (dependencyLists) {
for (const dependencies of dependencyLists) {
for (const depIdx of dependencies) {
if (depIdx !== idx) {
expandBBox(this.#bboxesCoords, depIdx, minX, minY, maxX, maxY);
}
}
}
}
if (!preserve) {
this._pendingBBoxIdx = -1;
}
return this;
}
bboxToClipBoxDropOperation(idx) {
if (this._pendingBBoxIdx === idx) {
this._pendingBBoxIdx = -1;
this.#clipBox[0] = Math.max(this.#clipBox[0], this.#pendingBBox[0]);
this.#clipBox[1] = Math.max(this.#clipBox[1], this.#pendingBBox[1]);
this.#clipBox[2] = Math.min(this.#clipBox[2], this.#pendingBBox[2]);
this.#clipBox[3] = Math.min(this.#clipBox[3], this.#pendingBBox[3]);
}
return this;
}
take() {
return new BBoxReader(this.#bboxes, this.#bboxesCoords);
}
takeDebugMetadata() {
throw new Error("Unreachable");
}
recordSimpleData(name, idx) {
return this;
}
recordIncrementalData(name, idx) {
return this;
}
resetIncrementalData(name, idx) {
return this;
}
recordNamedData(name, idx) {
return this;
}
recordSimpleDataFromNamed(name, depName, fallbackIdx) {
return this;
}
recordFutureForcedDependency(name, idx) {
return this;
}
inheritSimpleDataAsFutureForcedDependencies(names) {
return this;
}
inheritPendingDependenciesAsFutureForcedDependencies() {
return this;
}
recordCharacterBBox(idx, ctx, font, scale = 1, x = 0, y = 0, getMeasure) {
return this;
}
getSimpleIndex(dependencyName) {
return undefined;
}
recordDependencies(idx, dependencyNames) {
return this;
}
recordNamedDependency(idx, name) {
return this;
}
recordShowTextOperation(idx, preserve = false) {
return this;
}
}
/**
* @typedef {"lineWidth" | "lineCap" | "lineJoin" | "miterLimit" | "dash" |
* "strokeAlpha" | "fillColor" | "fillAlpha" | "globalCompositeOperation" |
@ -100,61 +417,34 @@ class CanvasDependencyTracker {
#namedDependencies = new Map();
#savesStack = [];
#markedContentStack = [];
#baseTransformStack = [[1, 0, 0, 1, 0, 0]];
#clipBox = [-Infinity, -Infinity, Infinity, Infinity];
// Float32Array<minX, minY, maxX, maxY>
#pendingBBox = new Float64Array([Infinity, Infinity, -Infinity, -Infinity]);
#pendingBBoxIdx = -1;
#pendingDependencies = new Set();
#operations = new Map();
#fontBBoxTrustworthy = new Map();
#canvasWidth;
#canvasHeight;
// Uint8ClampedArray<minX, minY, maxX, maxY>
#bboxesCoords;
#bboxes;
#debugMetadata;
constructor(canvas, operationsCount, recordDebugMetadata = false) {
this.#canvasWidth = canvas.width;
this.#canvasHeight = canvas.height;
this.#initializeBBoxes(operationsCount);
#recordDebugMetadataDepenencyAfterRestore;
#bboxTracker;
constructor(bboxTracker, recordDebugMetadata = false) {
this.#bboxTracker = bboxTracker;
if (recordDebugMetadata) {
this.#debugMetadata = new Map();
this.#recordDebugMetadataDepenencyAfterRestore = (lastSave, opIdx) => {
ensureDebugMetadata(this.#debugMetadata, opIdx).dependencies.add(
lastSave
);
};
}
}
get clipBox() {
return this.#bboxTracker.clipBox;
}
growOperationsCount(operationsCount) {
if (operationsCount >= this.#bboxes.length) {
this.#initializeBBoxes(operationsCount, this.#bboxes);
}
}
#initializeBBoxes(operationsCount, oldBBoxes) {
const buffer = new ArrayBuffer(operationsCount * 4);
this.#bboxesCoords = new Uint8ClampedArray(buffer);
this.#bboxes = new Uint32Array(buffer);
if (oldBBoxes && oldBBoxes.length > 0) {
this.#bboxes.set(oldBBoxes);
this.#bboxes.fill(EMPTY_BBOX, oldBBoxes.length);
} else {
this.#bboxes.fill(EMPTY_BBOX);
}
this.#bboxTracker.growOperationsCount(operationsCount);
}
save(opIdx) {
@ -168,13 +458,17 @@ class CanvasDependencyTracker {
__proto__: this.#incremental[FORCED_DEPENDENCY_LABEL],
},
};
this.#clipBox = { __proto__: this.#clipBox };
this.#savesStack.push(opIdx);
this.#bboxTracker.save(opIdx);
return this;
}
restore(opIdx) {
this.#bboxTracker.restore(
opIdx,
this.#recordDebugMetadataDepenencyAfterRestore
);
const previous = Object.getPrototypeOf(this.#simple);
if (previous === null) {
// Sometimes we call more .restore() than .save(), for
@ -183,77 +477,53 @@ class CanvasDependencyTracker {
}
this.#simple = previous;
this.#incremental = Object.getPrototypeOf(this.#incremental);
this.#clipBox = Object.getPrototypeOf(this.#clipBox);
const lastSave = this.#savesStack.pop();
if (lastSave !== undefined) {
ensureDebugMetadata(this.#debugMetadata, opIdx)?.dependencies.add(
lastSave
);
this.#bboxes[opIdx] = this.#bboxes[lastSave];
}
return this;
}
/**
* @param {number} idx
*/
recordOpenMarker(idx) {
this.#savesStack.push(idx);
return this;
}
getOpenMarker() {
if (this.#savesStack.length === 0) {
return null;
}
return this.#savesStack.at(-1);
}
recordCloseMarker(opIdx) {
const lastSave = this.#savesStack.pop();
if (lastSave !== undefined) {
ensureDebugMetadata(this.#debugMetadata, opIdx)?.dependencies.add(
lastSave
);
this.#bboxes[opIdx] = this.#bboxes[lastSave];
}
return this;
}
// Marked content needs a separate stack from save/restore, because they
// form two independent trees.
beginMarkedContent(opIdx) {
this.#markedContentStack.push(opIdx);
return this;
}
endMarkedContent(opIdx) {
const lastSave = this.#markedContentStack.pop();
if (lastSave !== undefined) {
ensureDebugMetadata(this.#debugMetadata, opIdx)?.dependencies.add(
lastSave
);
this.#bboxes[opIdx] = this.#bboxes[lastSave];
}
return this;
}
pushBaseTransform(ctx) {
this.#baseTransformStack.push(
Util.multiplyByDOMMatrix(
this.#baseTransformStack.at(-1),
ctx.getTransform()
)
recordOpenMarker(opIdx) {
this.#bboxTracker.recordOpenMarker(
opIdx,
this.#recordDebugMetadataDepenencyAfterRestore
);
return this;
}
getOpenMarker() {
return this.#bboxTracker.getOpenMarker();
}
recordCloseMarker(opIdx) {
this.#bboxTracker.recordCloseMarker(
opIdx,
this.#recordDebugMetadataDepenencyAfterRestore
);
return this;
}
/**
* @param {number} opIdx
*/
beginMarkedContent(opIdx) {
this.#bboxTracker.beginMarkedContent(opIdx);
return this;
}
endMarkedContent(opIdx) {
this.#bboxTracker.endMarkedContent(
opIdx,
this.#recordDebugMetadataDepenencyAfterRestore
);
return this;
}
pushBaseTransform(ctx) {
this.#bboxTracker.pushBaseTransform(ctx);
return this;
}
popBaseTransform() {
if (this.#baseTransformStack.length > 1) {
this.#baseTransformStack.pop();
}
this.#bboxTracker.popBaseTransform();
return this;
}
@ -323,73 +593,17 @@ class CanvasDependencyTracker {
}
resetBBox(idx) {
if (this.#pendingBBoxIdx !== idx) {
this.#pendingBBoxIdx = idx;
this.#pendingBBox[0] = Infinity;
this.#pendingBBox[1] = Infinity;
this.#pendingBBox[2] = -Infinity;
this.#pendingBBox[3] = -Infinity;
}
this.#bboxTracker.resetBBox(idx);
return this;
}
recordClipBox(idx, ctx, minX, maxX, minY, maxY) {
const transform = Util.multiplyByDOMMatrix(
this.#baseTransformStack.at(-1),
ctx.getTransform()
);
const clipBox = [Infinity, Infinity, -Infinity, -Infinity];
Util.axialAlignedBoundingBox([minX, minY, maxX, maxY], transform, clipBox);
const intersection = Util.intersect(this.#clipBox, clipBox);
if (intersection) {
this.#clipBox[0] = intersection[0];
this.#clipBox[1] = intersection[1];
this.#clipBox[2] = intersection[2];
this.#clipBox[3] = intersection[3];
} else {
this.#clipBox[0] = this.#clipBox[1] = Infinity;
this.#clipBox[2] = this.#clipBox[3] = -Infinity;
}
this.#bboxTracker.recordClipBox(idx, ctx, minX, maxX, minY, maxY);
return this;
}
recordBBox(idx, ctx, minX, maxX, minY, maxY) {
const clipBox = this.#clipBox;
if (clipBox[0] === Infinity) {
return this;
}
const transform = Util.multiplyByDOMMatrix(
this.#baseTransformStack.at(-1),
ctx.getTransform()
);
if (clipBox[0] === -Infinity) {
Util.axialAlignedBoundingBox(
[minX, minY, maxX, maxY],
transform,
this.#pendingBBox
);
return this;
}
const bbox = [Infinity, Infinity, -Infinity, -Infinity];
Util.axialAlignedBoundingBox([minX, minY, maxX, maxY], transform, bbox);
this.#pendingBBox[0] = Math.min(
this.#pendingBBox[0],
Math.max(bbox[0], clipBox[0])
);
this.#pendingBBox[1] = Math.min(
this.#pendingBBox[1],
Math.max(bbox[1], clipBox[1])
);
this.#pendingBBox[2] = Math.max(
this.#pendingBBox[2],
Math.min(bbox[2], clipBox[2])
);
this.#pendingBBox[3] = Math.max(
this.#pendingBBox[3],
Math.min(bbox[3], clipBox[3])
);
this.#bboxTracker.recordBBox(idx, ctx, minX, maxX, minY, maxY);
return this;
}
@ -471,11 +685,7 @@ class CanvasDependencyTracker {
}
recordFullPageBBox(idx) {
this.#pendingBBox[0] = Math.max(0, this.#clipBox[0]);
this.#pendingBBox[1] = Math.max(0, this.#clipBox[1]);
this.#pendingBBox[2] = Math.min(this.#canvasWidth, this.#clipBox[2]);
this.#pendingBBox[3] = Math.min(this.#canvasHeight, this.#clipBox[3]);
this.#bboxTracker.recordFullPageBBox(idx);
return this;
}
@ -516,39 +726,25 @@ class CanvasDependencyTracker {
const metadata = ensureDebugMetadata(this.#debugMetadata, idx);
const { dependencies } = metadata;
this.#pendingDependencies.forEach(dependencies.add, dependencies);
this.#savesStack.forEach(dependencies.add, dependencies);
this.#markedContentStack.forEach(dependencies.add, dependencies);
this.#bboxTracker._savesStack.forEach(dependencies.add, dependencies);
this.#bboxTracker._markedContentStack.forEach(
dependencies.add,
dependencies
);
dependencies.delete(idx);
metadata.isRenderingOperation = true;
}
if (this.#pendingBBoxIdx === idx) {
const minX = floor((this.#pendingBBox[0] * 256) / this.#canvasWidth);
const minY = floor((this.#pendingBBox[1] * 256) / this.#canvasHeight);
const maxX = ceil((this.#pendingBBox[2] * 256) / this.#canvasWidth);
const maxY = ceil((this.#pendingBBox[3] * 256) / this.#canvasHeight);
const needsCleanup = !preserve && idx === this.#bboxTracker._pendingBBoxIdx;
expandBBox(this.#bboxesCoords, idx, minX, minY, maxX, maxY);
for (const depIdx of this.#pendingDependencies) {
if (depIdx !== idx) {
expandBBox(this.#bboxesCoords, depIdx, minX, minY, maxX, maxY);
}
}
for (const saveIdx of this.#savesStack) {
if (saveIdx !== idx) {
expandBBox(this.#bboxesCoords, saveIdx, minX, minY, maxX, maxY);
}
}
for (const saveIdx of this.#markedContentStack) {
if (saveIdx !== idx) {
expandBBox(this.#bboxesCoords, saveIdx, minX, minY, maxX, maxY);
}
}
this.#bboxTracker.recordOperation(idx, preserve, [
this.#pendingDependencies,
this.#bboxTracker._savesStack,
this.#bboxTracker._markedContentStack,
]);
if (!preserve) {
this.#pendingDependencies.clear();
this.#pendingBBoxIdx = -1;
}
if (needsCleanup) {
this.#pendingDependencies.clear();
}
return this;
@ -565,42 +761,17 @@ class CanvasDependencyTracker {
}
bboxToClipBoxDropOperation(idx, preserve = false) {
if (this.#pendingBBoxIdx === idx) {
this.#pendingBBoxIdx = -1;
this.#clipBox[0] = Math.max(this.#clipBox[0], this.#pendingBBox[0]);
this.#clipBox[1] = Math.max(this.#clipBox[1], this.#pendingBBox[1]);
this.#clipBox[2] = Math.min(this.#clipBox[2], this.#pendingBBox[2]);
this.#clipBox[3] = Math.min(this.#clipBox[3], this.#pendingBBox[3]);
if (!preserve) {
this.#pendingDependencies.clear();
}
const needsCleanup = !preserve && idx === this.#bboxTracker._pendingBBoxIdx;
this.#bboxTracker.bboxToClipBoxDropOperation(idx);
if (needsCleanup) {
this.#pendingDependencies.clear();
}
return this;
}
_takePendingDependencies() {
const pendingDependencies = this.#pendingDependencies;
this.#pendingDependencies = new Set();
return pendingDependencies;
}
_extractOperation(idx) {
const operation = this.#operations.get(idx);
this.#operations.delete(idx);
return operation;
}
_pushPendingDependencies(dependencies) {
for (const dep of dependencies) {
this.#pendingDependencies.add(dep);
}
}
take() {
this.#fontBBoxTrustworthy.clear();
return new BBoxReader(this.#bboxes, this.#bboxesCoords);
return this.#bboxTracker.take();
}
takeDebugMetadata() {
@ -644,6 +815,10 @@ class CanvasNestedDependencyTracker {
this.#ignoreBBoxes = !!ignoreBBoxes;
}
get clipBox() {
return this.#dependencyTracker.clipBox;
}
growOperationsCount() {
throw new Error("Unreachable");
}
@ -918,4 +1093,156 @@ const Dependencies = {
transformAndFill: ["transform", "fillColor"],
};
export { CanvasDependencyTracker, CanvasNestedDependencyTracker, Dependencies };
/**
* Track the locations of images in the canvas. For each image it computes
* a bounding box as a potentially rotated rectangle, matching the rotation of
* the current canvas transform.
*/
class CanvasImagesTracker {
#canvasWidth;
#canvasHeight;
#capacity = 4;
#count = 0;
// Array of [x1, y1, x2, y2, x3, y3] coordinates.
// We need three points to be able to represent a rectangle with a transform
// applied.
#coords = new CanvasImagesTracker.#CoordsArray(this.#capacity * 6);
static #CoordsArray =
(typeof PDFJSDev !== "undefined" && PDFJSDev.test("MOZCENTRAL")) ||
FeatureTest.isFloat16ArraySupported
? Float16Array
: Float32Array;
constructor(canvas) {
this.#canvasWidth = canvas.width;
this.#canvasHeight = canvas.height;
}
record(ctx, width, height, clipBox) {
if (this.#count === this.#capacity) {
this.#capacity *= 2;
const newCoords = new CanvasImagesTracker.#CoordsArray(
this.#capacity * 6
);
newCoords.set(this.#coords);
this.#coords = newCoords;
}
const transform = Util.domMatrixToTransform(ctx.getTransform());
// We want top left, bottom left, top right.
// (0, 0) is the bottom left corner.
let coords;
if (clipBox[0] !== Infinity) {
const bbox = [Infinity, Infinity, -Infinity, -Infinity];
Util.axialAlignedBoundingBox([0, -height, width, 0], transform, bbox);
const finalBBox = Util.intersect(clipBox, bbox);
if (!finalBBox) {
// The image is fully clipped out.
return;
}
const [minX, minY, maxX, maxY] = finalBBox;
if (
minX !== bbox[0] ||
minY !== bbox[1] ||
maxX !== bbox[2] ||
maxY !== bbox[3]
) {
// The clip box affects the image drawing. We need to compute a
// transform that takes the image bbox and fits it into the final bbox,
// so that we can then apply it to the original image shape (the
// non-axially-aligned rectangle).
const rotationAngle = Math.atan2(transform[1], transform[0]);
// Normalize the angle to be between 0 and 90 degrees.
const sin = Math.abs(Math.sin(rotationAngle));
const cos = Math.abs(Math.cos(rotationAngle));
if (
sin < 1e-6 ||
cos < 1e-6 ||
// The logic in the `else` case gives more accurate bounding boxes for
// rotated images, but the equation it uses does not give a result
// when the rotation is exactly 45 degrees, because there are infinite
// possible rectangles that can fit into the same bbox with that same
// 45deg rotation. Fallback to returning the whole bbox.
Math.abs(sin - cos) < 1e-6
) {
coords = [minX, minY, minX, maxY, maxX, minY];
} else {
// We cannot just scale the bbox into the original bbox, because that
// would not preserve the 90deg corners if they have been rotated.
// We instead need to find the transform that maps the original
// rectangle into the only rectangle that is rotated by the expected
// angle and fits into the final bbox.
//
// This represents the final bbox, with the top-left corner having
// coordinates (minX, minY) and the bottom-right corner having
// coordinates (maxX, maxY). Alpha is the rotation angle, and a and b
// are helper variables used to compute the effective transform.
//
// ------------b----------
// +-----------------------*----+
// | | _ -‾ \ |
// a | _ -‾ \ |
// | |alpha _ -‾ \ |
// | | _ -‾ \|
// |\ _ -‾|
// | \ _ -‾ |
// | \ _ -‾ |
// | \ _ -‾ |
// +----*-----------------------+
const finalBBoxWidth = maxX - minX;
const finalBBoxHeight = maxY - minY;
const sin2 = sin * sin;
const cos2 = cos * cos;
const cosSin = cos * sin;
const denom = cos2 - sin2;
const a = (finalBBoxHeight * cos2 - finalBBoxWidth * cosSin) / denom;
const b = (finalBBoxHeight * cosSin - finalBBoxWidth * sin2) / denom;
coords = [minX + b, minY, minX, minY + a, maxX, maxY - a];
}
}
}
if (!coords) {
coords = [0, -height, 0, 0, width, -height];
Util.applyTransform(coords, transform, 0);
Util.applyTransform(coords, transform, 2);
Util.applyTransform(coords, transform, 4);
}
coords[0] /= this.#canvasWidth;
coords[1] /= this.#canvasHeight;
coords[2] /= this.#canvasWidth;
coords[3] /= this.#canvasHeight;
coords[4] /= this.#canvasWidth;
coords[5] /= this.#canvasHeight;
this.#coords.set(coords, this.#count * 6);
this.#count++;
}
take() {
return this.#coords.subarray(0, this.#count * 6);
}
}
export {
CanvasBBoxTracker,
CanvasDependencyTracker,
CanvasImagesTracker,
CanvasNestedDependencyTracker,
Dependencies,
};

View File

@ -15,6 +15,7 @@
/** @typedef {import("./display_utils").PageViewport} PageViewport */
/** @typedef {import("./api").TextContent} TextContent */
/** @typedef {import("./text_layer_images").TextLayerImages} TextLayerImages */
import {
AbortException,
@ -34,6 +35,8 @@ import { OutputScale, setLayerDimensions } from "./display_utils.js";
* runs.
* @property {PageViewport} viewport - The target viewport to properly layout
* the text runs.
* @property {TextLayerImages} [images] - An optional TextLayerImages instance
* that handles right clicking on images.
*/
/**
@ -56,6 +59,8 @@ class TextLayer {
#fontInspectorEnabled = !!globalThis.FontInspector?.enabled;
#imagesHandler = null;
#lang = null;
#layoutTextParams = null;
@ -97,7 +102,7 @@ class TextLayer {
/**
* @param {TextLayerParameters} options
*/
constructor({ textContentSource, container, viewport }) {
constructor({ textContentSource, images, container, viewport }) {
if (textContentSource instanceof ReadableStream) {
this.#textContentSource = textContentSource;
} else if (
@ -115,6 +120,8 @@ class TextLayer {
}
this.#container = this.#rootContainer = container;
this.#imagesHandler = images;
this.#scale = viewport.scale * OutputScale.pixelRatio;
this.#rotation = viewport.rotation;
this.#layoutTextParams = {
@ -181,6 +188,10 @@ class TextLayer {
* @returns {Promise}
*/
render() {
if (this.#imagesHandler) {
this.#container.append(this.#imagesHandler.render());
}
const pump = () => {
this.#reader.read().then(({ value, done }) => {
if (done) {

View File

@ -0,0 +1,160 @@
/* Copyright 2026 Mozilla Foundation
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Util } from "../shared/util.js";
function percentage(value) {
return `${(value * 100).toFixed(2)}%`;
}
/**
* Used to manage paceholder <canvas> elements that, when right-clicked on,
* are populated with the corresponding image extracted from the PDF page.
*/
class TextLayerImages {
#coordinates = [];
#coordinatesByElement = new Map();
#getPageCanvas = null;
#minSize = 0;
#pageWidth = 0;
#pageHeight = 0;
static #activeImage = null;
constructor(minSize, coordinates, viewport, getPageCanvas) {
this.#minSize = minSize;
this.#coordinates = coordinates;
this.#pageWidth = viewport.rawDims.pageWidth;
this.#pageHeight = viewport.rawDims.pageHeight;
this.#getPageCanvas = getPageCanvas;
}
render() {
const container = document.createElement("div");
container.className = "textLayerImages";
for (let i = 0; i < this.#coordinates.length; i += 6) {
const el = this.#createImagePlaceholder(
this.#coordinates.subarray(i, i + 6)
);
if (el) {
container.append(el);
}
}
container.addEventListener("contextmenu", event => {
if (!(event.target instanceof HTMLCanvasElement)) {
return;
}
const imgElement = event.target;
const coords = this.#coordinatesByElement.get(imgElement);
if (!coords) {
return;
}
const activeImage = TextLayerImages.#activeImage?.deref();
if (activeImage === imgElement) {
return;
}
if (activeImage) {
activeImage.width = 0;
activeImage.height = 0;
}
TextLayerImages.#activeImage = new WeakRef(imgElement);
const { inverseTransform, x1, y1, width, height } = coords;
const pageCanvas = this.#getPageCanvas();
const imageX1 = Math.ceil(x1 * pageCanvas.width);
const imageY1 = Math.ceil(y1 * pageCanvas.height);
const imageX2 = Math.floor(
(x1 + width / this.#pageWidth) * pageCanvas.width
);
const imageY2 = Math.floor(
(y1 + height / this.#pageHeight) * pageCanvas.height
);
imgElement.width = imageX2 - imageX1;
imgElement.height = imageY2 - imageY1;
const ctx = imgElement.getContext("2d");
ctx.setTransform(...inverseTransform);
ctx.translate(-imageX1, -imageY1);
ctx.drawImage(pageCanvas, 0, 0);
});
return container;
}
#createImagePlaceholder(
[x1, y1, x2, y2, x3, y3] // top left, bottom left, top right
) {
const width = Math.hypot(
(x3 - x1) * this.#pageWidth,
(y3 - y1) * this.#pageHeight
);
const height = Math.hypot(
(x2 - x1) * this.#pageWidth,
(y2 - y1) * this.#pageHeight
);
if (width < this.#minSize || height < this.#minSize) {
return null;
}
const transform = [
((x3 - x1) * this.#pageWidth) / width,
((y3 - y1) * this.#pageHeight) / width,
((x2 - x1) * this.#pageWidth) / height,
((y2 - y1) * this.#pageHeight) / height,
0,
0,
];
const inverseTransform = Util.inverseTransform(transform);
const imgElement = document.createElement("canvas");
imgElement.className = "textLayerImagePlaceholder";
imgElement.width = 0;
imgElement.height = 0;
Object.assign(imgElement.style, {
opacity: 0,
position: "absolute",
left: percentage(x1),
top: percentage(y1),
width: percentage(width / this.#pageWidth),
height: percentage(height / this.#pageHeight),
transformOrigin: "0% 0%",
transform: `matrix(${transform.join(",")})`,
});
this.#coordinatesByElement.set(imgElement, {
inverseTransform,
width,
height,
x1,
y1,
});
return imgElement;
}
}
export { TextLayerImages };

View File

@ -86,6 +86,7 @@ import { HighlightOutliner } from "./display/editor/drawers/highlight.js";
import { isValidExplicitDest } from "./display/api_utils.js";
import { SignatureExtractor } from "./display/editor/drawers/signaturedraw.js";
import { TextLayer } from "./display/text_layer.js";
import { TextLayerImages } from "./display/text_layer_images.js";
import { TouchManager } from "./display/touch_manager.js";
import { XfaLayer } from "./display/xfa_layer.js";
@ -149,6 +150,7 @@ globalThis.pdfjsLib = {
stopEvent,
SupportedImageMimeTypes,
TextLayer,
TextLayerImages,
TouchManager,
updateUrlHash,
Util,
@ -211,6 +213,7 @@ export {
stopEvent,
SupportedImageMimeTypes,
TextLayer,
TextLayerImages,
TouchManager,
updateUrlHash,
Util,

View File

@ -43,6 +43,7 @@ async function runTests(results) {
"stamp_editor_spec.mjs",
"text_field_spec.mjs",
"text_layer_spec.mjs",
"text_layer_images_spec.mjs",
"thumbnail_view_spec.mjs",
"viewer_spec.mjs",
],

View File

@ -0,0 +1,289 @@
/* Copyright 2026 Mozilla Foundation
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { closePages, loadAndWait } from "./test_utils.mjs";
describe("Text layer images", () => {
describe("basic", () => {
let pages;
beforeEach(async () => {
pages = await loadAndWait(
"images.pdf",
`.page[data-page-number = "1"] .endOfContent`,
undefined,
{
// When running Firefox with Puppeteer, setting the
// devicePixelRatio Puppeteer option does not properly set
// the `window.devicePixelRatio` value. Set it manually.
earlySetup: `() => { window.devicePixelRatio = 1 }`,
},
{ imagesRightClickMinSize: 16 },
{ width: 800, height: 600, devicePixelRatio: 1 }
);
});
afterEach(async () => {
await closePages(pages);
});
it("should render images in the text layer", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
const images = await page.$$eval(
`.page[data-page-number="1"] > .textLayer > .textLayerImages > canvas`,
els => els.map(el => JSON.stringify(el.getBoundingClientRect()))
);
expect(images.length).withContext(`In ${browserName}`).toEqual(5);
})
);
});
it("when right-clicking an image it should get the contents", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
const imageCanvas = await page.$(
`.page[data-page-number="1"] > .textLayer > .textLayerImages > canvas`
);
expect(await page.evaluate(el => el.width, imageCanvas))
.withContext(`Initial width, in ${browserName}`)
.toBe(0);
expect(await page.evaluate(el => el.height, imageCanvas))
.withContext(`Initial height, in ${browserName}`)
.toBe(0);
await imageCanvas.click({ button: "right" });
expect(await page.evaluate(el => el.width, imageCanvas))
.withContext(`Final width, in ${browserName}`)
.toBeGreaterThan(0);
expect(await page.evaluate(el => el.height, imageCanvas))
.withContext(`Final height, in ${browserName}`)
.toBeGreaterThan(0);
expect(
await page.evaluate(el => {
const ctx = el.getContext("2d");
const imageData = ctx.getImageData(0, 0, el.width, el.height);
const pixels = new Uint32Array(imageData.data.buffer);
const firstPixel = pixels[0];
return pixels.some(pixel => pixel !== firstPixel);
}, imageCanvas)
)
.withContext(`Image is not all the same pixel, in ${browserName}`)
.toBe(true);
})
);
});
});
describe("transforms", () => {
let pages;
beforeEach(async () => {
pages = await loadAndWait(
"images.pdf",
`.page[data-page-number = "1"] .endOfContent`,
undefined,
{
// When running Firefox with Puppeteer, setting the
// devicePixelRatio Puppeteer option does not properly set
// the `window.devicePixelRatio` value. Set it manually.
earlySetup: `() => { window.devicePixelRatio = 1 }`,
},
{ imagesRightClickMinSize: 16 },
{ width: 800, height: 600, devicePixelRatio: 1 }
);
});
afterEach(async () => {
await closePages(pages);
});
it("the three copies of the PDF.js logo have different rotations", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
const getRotation = async nth =>
page.evaluate(n => {
const canvas = document.querySelectorAll(
`.page[data-page-number="1"] > .textLayer > .textLayerImages > canvas`
)[n];
const cssTransform = getComputedStyle(canvas).transform;
if (cssTransform && cssTransform !== "none") {
const matrixValues = cssTransform
.slice(7, -1)
.split(", ")
.map(parseFloat);
return (
Math.atan2(matrixValues[1], matrixValues[0]) * (180 / Math.PI)
);
}
return 0;
}, nth);
const rotation1 = await getRotation(1);
const rotation2 = await getRotation(2);
const rotation4 = await getRotation(4);
expect(Math.abs(rotation1 - rotation2))
.withContext(`Rotation between 1 and 2, in ${browserName}`)
.toBeGreaterThan(10);
expect(Math.abs(rotation1 - rotation4))
.withContext(`Rotation between 1 and 4, in ${browserName}`)
.toBeGreaterThan(10);
expect(Math.abs(rotation2 - rotation4))
.withContext(`Rotation between 2 and 4, in ${browserName}`)
.toBeGreaterThan(10);
})
);
});
it("the three copies of the PDF.js logo have the same size", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
const getSize = async nth =>
page.evaluate(n => {
const canvas = document.querySelectorAll(
`.page[data-page-number="1"] > .textLayer > .textLayerImages > canvas`
)[n];
return { width: canvas.width, height: canvas.height };
}, nth);
const size1 = await getSize(1);
const size2 = await getSize(2);
const size4 = await getSize(4);
const EPSILON = 3;
expect(size1.width)
.withContext(`1-2 width, in ${browserName}`)
.toBeCloseTo(size2.width, EPSILON);
expect(size1.height)
.withContext(`1-2 height, in ${browserName}`)
.toBeCloseTo(size2.height, EPSILON);
expect(size1.width)
.withContext(`1-4 width, in ${browserName}`)
.toBeCloseTo(size4.width, EPSILON);
expect(size1.height)
.withContext(`1-4 height, in ${browserName}`)
.toBeCloseTo(size4.height, EPSILON);
})
);
});
});
describe("trimming", () => {
let pages;
beforeEach(async () => {
pages = await loadAndWait(
"bug_jpx.pdf",
`.page[data-page-number = "1"] .endOfContent`,
undefined,
{
// When running Firefox with Puppeteer, setting the
// devicePixelRatio Puppeteer option does not properly set
// the `window.devicePixelRatio` value. Set it manually.
earlySetup: `() => { window.devicePixelRatio = 1 }`,
},
{ imagesRightClickMinSize: 16 },
{ width: 800, height: 600, devicePixelRatio: 1 }
);
});
afterEach(async () => {
await closePages(pages);
});
it("no white border around black image", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
const canvasHandle = await page.$(
`.page[data-page-number="1"] > .textLayer > .textLayerImages > canvas`
);
await canvasHandle.click({ button: "right" });
expect(
await page.evaluate(el => {
const ctx = el.getContext("2d");
const imageData = ctx.getImageData(0, 0, el.width, el.height);
const pixels = new Uint32Array(imageData.data.buffer);
return Array.from(pixels.filter(pixel => pixel !== 0xff000000));
}, canvasHandle)
)
.withContext(`Image is all black, in ${browserName}`)
.toEqual([]);
})
);
});
});
describe("trimming after rotation", () => {
let pages;
beforeEach(async () => {
pages = await loadAndWait(
"image-rotated-black-white-ratio.pdf",
`.page[data-page-number = "1"] .endOfContent`,
undefined,
{
// When running Firefox with Puppeteer, setting the
// devicePixelRatio Puppeteer option does not properly set
// the `window.devicePixelRatio` value. Set it manually.
earlySetup: `() => { window.devicePixelRatio = 1 }`,
},
{ imagesRightClickMinSize: 16 },
{ width: 800, height: 600, devicePixelRatio: 1 }
);
});
afterEach(async () => {
await closePages(pages);
});
it("no white extra white around rotated image", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
const canvasHandle = await page.$(
`.page[data-page-number="1"] > .textLayer > .textLayerImages > canvas`
);
await canvasHandle.click({ button: "right" });
expect(
await page.evaluate(el => {
const ctx = el.getContext("2d");
const imageData = ctx.getImageData(0, 0, el.width, el.height);
const pixels = new Uint32Array(imageData.data.buffer);
const blackPixels = pixels.filter(
pixel => pixel === 0xff000000
).length;
const whitePixels = pixels.filter(
pixel => pixel === 0xffffffff
).length;
return blackPixels / (blackPixels + whitePixels);
}, canvasHandle)
)
.withContext(`Image is 75% black, in ${browserName}`)
.toBeCloseTo(0.75);
})
);
});
});
});

View File

@ -114,7 +114,15 @@ describe("Text layer", () => {
beforeEach(async () => {
pages = await loadAndWait(
"tracemonkey.pdf",
`.page[data-page-number = "1"] .endOfContent`
`.page[data-page-number = "1"] .endOfContent`,
undefined,
undefined,
(_page, browserName) => ({
// Enable images in Firefox, to ensure that they do not interfere
// with text selection. We do not test it in Chrome because we
// know that they do degrate the text selection experience there.
imagesRightClickMinSize: browserName === "firefox" ? 16 : -1,
})
);
});
@ -224,7 +232,15 @@ describe("Text layer", () => {
beforeEach(async () => {
pages = await loadAndWait(
"chrome-text-selection-markedContent.pdf",
`.page[data-page-number = "1"] .endOfContent`
`.page[data-page-number = "1"] .endOfContent`,
undefined,
undefined,
(_page, browserName) => ({
// Enable images in Firefox, to ensure that they do not interfere
// with text selection. We do not test it in Chrome because we
// know that they do degrate the text selection experience there.
imagesRightClickMinSize: browserName === "firefox" ? 16 : -1,
})
);
});
@ -314,7 +330,15 @@ describe("Text layer", () => {
beforeEach(async () => {
pages = await loadAndWait(
"annotation-link-text-popup.pdf",
`.page[data-page-number = "1"] .endOfContent`
`.page[data-page-number = "1"] .endOfContent`,
undefined,
undefined,
(_page, browserName) => ({
// Enable images in Firefox, to ensure that they do not interfere
// with text selection. We do not test it in Chrome because we
// know that they do degrate the text selection experience there.
imagesRightClickMinSize: browserName === "firefox" ? 16 : -1,
})
);
});
@ -437,7 +461,18 @@ describe("Text layer", () => {
let pages;
beforeEach(async () => {
pages = await loadAndWait("find_all.pdf", ".textLayer", 100);
pages = await loadAndWait(
"find_all.pdf",
".textLayer",
100,
undefined,
(_page, browserName) => ({
// Enable images in Firefox, to ensure that they do not interfere
// with text selection. We do not test it in Chrome because we
// know that they do degrate the text selection experience there.
imagesRightClickMinSize: browserName === "firefox" ? 16 : -1,
})
);
});
afterEach(async () => {

View File

@ -873,6 +873,9 @@
!page_with_number.pdf
!page_with_number_and_link.pdf
!Brotli-Prototype-FileA.pdf
!images.pdf
!bug_jpx.pdf
!image-rotated-black-white-ratio.pdf
!bug2013793.pdf
!bug2014080.pdf
!two_pages.pdf

BIN
test/pdfs/bug_jpx.pdf Normal file

Binary file not shown.

Binary file not shown.

BIN
test/pdfs/images.pdf Normal file

Binary file not shown.

View File

@ -76,6 +76,7 @@ import { GlobalWorkerOptions } from "../../src/display/worker_options.js";
import { isValidExplicitDest } from "../../src/display/api_utils.js";
import { SignatureExtractor } from "../../src/display/editor/drawers/signaturedraw.js";
import { TextLayer } from "../../src/display/text_layer.js";
import { TextLayerImages } from "../../src/display/text_layer_images.js";
import { TouchManager } from "../../src/display/touch_manager.js";
import { XfaLayer } from "../../src/display/xfa_layer.js";
@ -133,6 +134,7 @@ const expectedAPI = Object.freeze({
stopEvent,
SupportedImageMimeTypes,
TextLayer,
TextLayerImages,
TouchManager,
updateUrlHash,
Util,

View File

@ -377,6 +377,7 @@ const PDFViewerApplication = {
enableSplitMerge: x => x === "true",
enableUpdatedAddImage: x => x === "true",
highlightEditorColors: x => x,
imagesRightClickMinSize: x => parseInt(x),
maxCanvasPixels: x => parseInt(x),
spreadModeOnLoad: x => parseInt(x),
supportsCaretBrowsingMode: x => x === "true",
@ -575,6 +576,7 @@ const PDFViewerApplication = {
enableOptimizedPartialRendering: AppOptions.get(
"enableOptimizedPartialRendering"
),
imagesRightClickMinSize: AppOptions.get("imagesRightClickMinSize"),
pageColors,
mlManager,
abortSignal,

View File

@ -323,6 +323,20 @@ const defaultOptions = {
: "./images/",
kind: OptionKind.VIEWER,
},
imagesRightClickMinSize: {
/** @type {number} */
value:
typeof PDFJSDev !== "undefined" &&
// Firefox mobile does not support right-clicking on images,
// see https://bugzilla.mozilla.org/show_bug.cgi?id=2014081.
// This option is disabled by default outside of MOZCENTRAL
// because it degrades the text selection experience in Chrome
// and Safari.
PDFJSDev.test("MOZCENTRAL && !GECKOVIEW")
? 16
: -1,
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
},
maxCanvasPixels: {
/** @type {number} */
value: 2 ** 25,

View File

@ -36,10 +36,14 @@ class BasePDFPageView extends RenderableView {
enableOptimizedPartialRendering = false;
imagesRightClickMinSize = -1;
eventBus = null;
id = null;
imageCoordinates = null;
pageColors = null;
recordedBBoxes = null;
@ -54,6 +58,7 @@ class BasePDFPageView extends RenderableView {
this.renderingQueue = options.renderingQueue;
this.enableOptimizedPartialRendering =
options.enableOptimizedPartialRendering ?? false;
this.imagesRightClickMinSize = options.imagesRightClickMinSize ?? -1;
this.minDurationToUpdateCanvas = options.minDurationToUpdateCanvas ?? 500;
}
@ -232,6 +237,9 @@ class BasePDFPageView extends RenderableView {
if (this.enableOptimizedPartialRendering) {
this.recordedBBoxes ??= renderTask.recordedBBoxes;
}
if (this.imagesRightClickMinSize !== -1) {
this.imageCoordinates ??= this.pdfPage.imageCoordinates;
}
}
}
this.renderingState = RenderingStates.FINISHED;

View File

@ -30,6 +30,7 @@ import {
PixelsPerInch,
setLayerDimensions,
shadow,
TextLayerImages,
} from "pdfjs-lib";
import {
approximateFraction,
@ -89,6 +90,9 @@ import { XfaLayerBuilder } from "./xfa_layer_builder.js";
* `maxCanvasDim`, it will draw a second canvas on top of the CSS-zoomed one,
* that only renders the part of the page that is close to the viewport.
* The default value is `true`.
* @property {number} [imagesRightClickMinSize] - All images whose width and
* height are at least this value (in pixels) will be lazily inserted in the
* dom to allow right-clicking and saving them. Use `-1` to disable this.
* @property {boolean} [enableOptimizedPartialRendering] - When enabled, PDF
* rendering will keep track of which areas of the page each PDF operation
* affects. Then, when rendering a partial page (if `enableDetailCanvas` is
@ -522,6 +526,14 @@ class PDFPageView extends BasePDFPageView {
try {
await this.textLayer.render({
viewport: this.viewport,
images: this.imageCoordinates
? new TextLayerImages(
this.imagesRightClickMinSize,
this.imageCoordinates,
this.viewport,
() => this.canvas
)
: null,
});
} catch (ex) {
if (ex instanceof AbortException) {
@ -707,6 +719,7 @@ class PDFPageView extends BasePDFPageView {
this.detailView ??= new PDFPageDetailView({
pageView: this,
enableOptimizedPartialRendering: this.enableOptimizedPartialRendering,
imagesRightClickMinSize: -1,
});
this.detailView.update({ visibleArea });
} else if (this.detailView) {
@ -993,7 +1006,7 @@ class PDFPageView extends BasePDFPageView {
return canvasWrapper;
}
_getRenderingContext(canvas, transform, recordOperations) {
_getRenderingContext(canvas, transform, recordOperations, recordImages) {
return {
canvas,
transform,
@ -1004,6 +1017,7 @@ class PDFPageView extends BasePDFPageView {
pageColors: this.pageColors,
isEditing: this.#isEditing,
recordOperations,
recordImages,
};
}
@ -1127,12 +1141,15 @@ class PDFPageView extends BasePDFPageView {
this.#hasRestrictedScaling &&
!this.recordedBBoxes;
const recordImages =
this.imagesRightClickMinSize !== -1 && !this.imageCoordinates;
// Rendering area
const transform = outputScale.scaled
? [outputScale.sx, 0, 0, outputScale.sy, 0, 0]
: null;
const resultPromise = this._drawCanvas(
this._getRenderingContext(canvas, transform, recordBBoxes),
this._getRenderingContext(canvas, transform, recordBBoxes, recordImages),
() => {
prevCanvas?.remove();
this._resetCanvas();

View File

@ -132,6 +132,9 @@ function isValidAnnotationEditorMode(mode) {
* `maxCanvasDim`, it will draw a second canvas on top of the CSS-zoomed one,
* that only renders the part of the page that is close to the viewport.
* The default value is `true`.
* @property {number} [imagesRightClickMinSize] - All images whose width and
* height are at least this value (in pixels) will be lazily inserted in the
* dom to allow right-clicking and saving them. Use `-1` to disable this.
* @property {boolean} [enableOptimizedPartialRendering] - When enabled, PDF
* rendering will keep track of which areas of the page each PDF operation
* affects. Then, when rendering a partial page (if `enableDetailCanvas` is
@ -361,6 +364,7 @@ class PDFViewer {
this.enableDetailCanvas = options.enableDetailCanvas ?? true;
this.enableOptimizedPartialRendering =
options.enableOptimizedPartialRendering ?? false;
this.imagesRightClickMinSize = options.imagesRightClickMinSize ?? -1;
this.l10n = options.l10n;
if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) {
this.l10n ||= new GenericL10n();
@ -1062,6 +1066,7 @@ class PDFViewer {
enableDetailCanvas: this.enableDetailCanvas,
enableOptimizedPartialRendering:
this.enableOptimizedPartialRendering,
imagesRightClickMinSize: this.imagesRightClickMinSize,
pageColors,
l10n: this.l10n,
layerProperties: this._layerProperties,

View File

@ -67,6 +67,7 @@ const {
stopEvent,
SupportedImageMimeTypes,
TextLayer,
TextLayerImages,
TouchManager,
updateUrlHash,
Util,
@ -129,6 +130,7 @@ export {
stopEvent,
SupportedImageMimeTypes,
TextLayer,
TextLayerImages,
TouchManager,
updateUrlHash,
Util,

View File

@ -142,3 +142,14 @@
top: 0;
}
}
.textLayerImages {
position: absolute;
inset: 0;
user-select: none;
canvas {
position: absolute;
transform-origin: 0% 0%;
}
}

View File

@ -16,6 +16,8 @@
/** @typedef {import("../src/display/api").PDFPageProxy} PDFPageProxy */
// eslint-disable-next-line max-len
/** @typedef {import("../src/display/display_utils").PageViewport} PageViewport */
// eslint-disable-next-line max-len
/** @typedef {import("../src/display/text_layer_images.js").TextLayerImages} TextLayerImages */
/** @typedef {import("./text_highlighter").TextHighlighter} TextHighlighter */
// eslint-disable-next-line max-len
/** @typedef {import("./text_accessibility.js").TextAccessibilityManager} TextAccessibilityManager */
@ -36,6 +38,7 @@ import { removeNullCharacters } from "./ui_utils.js";
/**
* @typedef {Object} TextLayerBuilderRenderOptions
* @property {PageViewport} viewport
* @property {TextLayerImages} images
* @property {Object} [textContentParams]
*/
@ -83,7 +86,7 @@ class TextLayerBuilder {
* @param {TextLayerBuilderRenderOptions} options
* @returns {Promise<void>}
*/
async render({ viewport, textContentParams = null }) {
async render({ viewport, images, textContentParams = null }) {
if (this.#renderingDone && this.#textLayer) {
this.#textLayer.update({
viewport,
@ -101,6 +104,7 @@ class TextLayerBuilder {
disableNormalization: true,
}
),
images,
container: this.div,
viewport,
});