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

View File

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

View File

@ -13,7 +13,7 @@
* limitations under the License. * limitations under the License.
*/ */
import { Util } from "../shared/util.js"; import { FeatureTest, Util } from "../shared/util.js";
const FORCED_DEPENDENCY_LABEL = "__forcedDependency"; const FORCED_DEPENDENCY_LABEL = "__forcedDependency";
@ -71,6 +71,323 @@ const ensureDebugMetadata = (map, key) =>
isRenderingOperation: false, 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" | * @typedef {"lineWidth" | "lineCap" | "lineJoin" | "miterLimit" | "dash" |
* "strokeAlpha" | "fillColor" | "fillAlpha" | "globalCompositeOperation" | * "strokeAlpha" | "fillColor" | "fillAlpha" | "globalCompositeOperation" |
@ -100,61 +417,34 @@ class CanvasDependencyTracker {
#namedDependencies = new Map(); #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(); #pendingDependencies = new Set();
#operations = new Map();
#fontBBoxTrustworthy = new Map(); #fontBBoxTrustworthy = new Map();
#canvasWidth;
#canvasHeight;
// Uint8ClampedArray<minX, minY, maxX, maxY>
#bboxesCoords;
#bboxes;
#debugMetadata; #debugMetadata;
constructor(canvas, operationsCount, recordDebugMetadata = false) { #recordDebugMetadataDepenencyAfterRestore;
this.#canvasWidth = canvas.width;
this.#canvasHeight = canvas.height; #bboxTracker;
this.#initializeBBoxes(operationsCount);
constructor(bboxTracker, recordDebugMetadata = false) {
this.#bboxTracker = bboxTracker;
if (recordDebugMetadata) { if (recordDebugMetadata) {
this.#debugMetadata = new Map(); this.#debugMetadata = new Map();
this.#recordDebugMetadataDepenencyAfterRestore = (lastSave, opIdx) => {
ensureDebugMetadata(this.#debugMetadata, opIdx).dependencies.add(
lastSave
);
};
} }
} }
get clipBox() {
return this.#bboxTracker.clipBox;
}
growOperationsCount(operationsCount) { growOperationsCount(operationsCount) {
if (operationsCount >= this.#bboxes.length) { this.#bboxTracker.growOperationsCount(operationsCount);
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);
}
} }
save(opIdx) { save(opIdx) {
@ -168,13 +458,17 @@ class CanvasDependencyTracker {
__proto__: this.#incremental[FORCED_DEPENDENCY_LABEL], __proto__: this.#incremental[FORCED_DEPENDENCY_LABEL],
}, },
}; };
this.#clipBox = { __proto__: this.#clipBox }; this.#bboxTracker.save(opIdx);
this.#savesStack.push(opIdx);
return this; return this;
} }
restore(opIdx) { restore(opIdx) {
this.#bboxTracker.restore(
opIdx,
this.#recordDebugMetadataDepenencyAfterRestore
);
const previous = Object.getPrototypeOf(this.#simple); const previous = Object.getPrototypeOf(this.#simple);
if (previous === null) { if (previous === null) {
// Sometimes we call more .restore() than .save(), for // Sometimes we call more .restore() than .save(), for
@ -183,77 +477,53 @@ class CanvasDependencyTracker {
} }
this.#simple = previous; this.#simple = previous;
this.#incremental = Object.getPrototypeOf(this.#incremental); 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; return this;
} }
/** recordOpenMarker(opIdx) {
* @param {number} idx this.#bboxTracker.recordOpenMarker(
*/ opIdx,
recordOpenMarker(idx) { this.#recordDebugMetadataDepenencyAfterRestore
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()
)
); );
return this; 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() { popBaseTransform() {
if (this.#baseTransformStack.length > 1) { this.#bboxTracker.popBaseTransform();
this.#baseTransformStack.pop();
}
return this; return this;
} }
@ -323,73 +593,17 @@ class CanvasDependencyTracker {
} }
resetBBox(idx) { resetBBox(idx) {
if (this.#pendingBBoxIdx !== idx) { this.#bboxTracker.resetBBox(idx);
this.#pendingBBoxIdx = idx;
this.#pendingBBox[0] = Infinity;
this.#pendingBBox[1] = Infinity;
this.#pendingBBox[2] = -Infinity;
this.#pendingBBox[3] = -Infinity;
}
return this; return this;
} }
recordClipBox(idx, ctx, minX, maxX, minY, maxY) { recordClipBox(idx, ctx, minX, maxX, minY, maxY) {
const transform = Util.multiplyByDOMMatrix( this.#bboxTracker.recordClipBox(idx, ctx, minX, maxX, minY, maxY);
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; return this;
} }
recordBBox(idx, ctx, minX, maxX, minY, maxY) { recordBBox(idx, ctx, minX, maxX, minY, maxY) {
const clipBox = this.#clipBox; this.#bboxTracker.recordBBox(idx, ctx, minX, maxX, minY, maxY);
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; return this;
} }
@ -471,11 +685,7 @@ class CanvasDependencyTracker {
} }
recordFullPageBBox(idx) { recordFullPageBBox(idx) {
this.#pendingBBox[0] = Math.max(0, this.#clipBox[0]); this.#bboxTracker.recordFullPageBBox(idx);
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; return this;
} }
@ -516,39 +726,25 @@ class CanvasDependencyTracker {
const metadata = ensureDebugMetadata(this.#debugMetadata, idx); const metadata = ensureDebugMetadata(this.#debugMetadata, idx);
const { dependencies } = metadata; const { dependencies } = metadata;
this.#pendingDependencies.forEach(dependencies.add, dependencies); this.#pendingDependencies.forEach(dependencies.add, dependencies);
this.#savesStack.forEach(dependencies.add, dependencies); this.#bboxTracker._savesStack.forEach(dependencies.add, dependencies);
this.#markedContentStack.forEach(dependencies.add, dependencies); this.#bboxTracker._markedContentStack.forEach(
dependencies.add,
dependencies
);
dependencies.delete(idx); dependencies.delete(idx);
metadata.isRenderingOperation = true; metadata.isRenderingOperation = true;
} }
if (this.#pendingBBoxIdx === idx) { const needsCleanup = !preserve && idx === this.#bboxTracker._pendingBBoxIdx;
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); this.#bboxTracker.recordOperation(idx, preserve, [
for (const depIdx of this.#pendingDependencies) { this.#pendingDependencies,
if (depIdx !== idx) { this.#bboxTracker._savesStack,
expandBBox(this.#bboxesCoords, depIdx, minX, minY, maxX, maxY); this.#bboxTracker._markedContentStack,
} ]);
}
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);
}
}
if (!preserve) { if (needsCleanup) {
this.#pendingDependencies.clear(); this.#pendingDependencies.clear();
this.#pendingBBoxIdx = -1;
}
} }
return this; return this;
@ -565,42 +761,17 @@ class CanvasDependencyTracker {
} }
bboxToClipBoxDropOperation(idx, preserve = false) { bboxToClipBoxDropOperation(idx, preserve = false) {
if (this.#pendingBBoxIdx === idx) { const needsCleanup = !preserve && idx === this.#bboxTracker._pendingBBoxIdx;
this.#pendingBBoxIdx = -1; this.#bboxTracker.bboxToClipBoxDropOperation(idx);
if (needsCleanup) {
this.#clipBox[0] = Math.max(this.#clipBox[0], this.#pendingBBox[0]); this.#pendingDependencies.clear();
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();
}
} }
return this; 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() { take() {
this.#fontBBoxTrustworthy.clear(); this.#fontBBoxTrustworthy.clear();
return new BBoxReader(this.#bboxes, this.#bboxesCoords); return this.#bboxTracker.take();
} }
takeDebugMetadata() { takeDebugMetadata() {
@ -644,6 +815,10 @@ class CanvasNestedDependencyTracker {
this.#ignoreBBoxes = !!ignoreBBoxes; this.#ignoreBBoxes = !!ignoreBBoxes;
} }
get clipBox() {
return this.#dependencyTracker.clipBox;
}
growOperationsCount() { growOperationsCount() {
throw new Error("Unreachable"); throw new Error("Unreachable");
} }
@ -918,4 +1093,156 @@ const Dependencies = {
transformAndFill: ["transform", "fillColor"], 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("./display_utils").PageViewport} PageViewport */
/** @typedef {import("./api").TextContent} TextContent */ /** @typedef {import("./api").TextContent} TextContent */
/** @typedef {import("./text_layer_images").TextLayerImages} TextLayerImages */
import { import {
AbortException, AbortException,
@ -34,6 +35,8 @@ import { OutputScale, setLayerDimensions } from "./display_utils.js";
* runs. * runs.
* @property {PageViewport} viewport - The target viewport to properly layout * @property {PageViewport} viewport - The target viewport to properly layout
* the text runs. * 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; #fontInspectorEnabled = !!globalThis.FontInspector?.enabled;
#imagesHandler = null;
#lang = null; #lang = null;
#layoutTextParams = null; #layoutTextParams = null;
@ -97,7 +102,7 @@ class TextLayer {
/** /**
* @param {TextLayerParameters} options * @param {TextLayerParameters} options
*/ */
constructor({ textContentSource, container, viewport }) { constructor({ textContentSource, images, container, viewport }) {
if (textContentSource instanceof ReadableStream) { if (textContentSource instanceof ReadableStream) {
this.#textContentSource = textContentSource; this.#textContentSource = textContentSource;
} else if ( } else if (
@ -115,6 +120,8 @@ class TextLayer {
} }
this.#container = this.#rootContainer = container; this.#container = this.#rootContainer = container;
this.#imagesHandler = images;
this.#scale = viewport.scale * OutputScale.pixelRatio; this.#scale = viewport.scale * OutputScale.pixelRatio;
this.#rotation = viewport.rotation; this.#rotation = viewport.rotation;
this.#layoutTextParams = { this.#layoutTextParams = {
@ -181,6 +188,10 @@ class TextLayer {
* @returns {Promise} * @returns {Promise}
*/ */
render() { render() {
if (this.#imagesHandler) {
this.#container.append(this.#imagesHandler.render());
}
const pump = () => { const pump = () => {
this.#reader.read().then(({ value, done }) => { this.#reader.read().then(({ value, done }) => {
if (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 { isValidExplicitDest } from "./display/api_utils.js";
import { SignatureExtractor } from "./display/editor/drawers/signaturedraw.js"; import { SignatureExtractor } from "./display/editor/drawers/signaturedraw.js";
import { TextLayer } from "./display/text_layer.js"; import { TextLayer } from "./display/text_layer.js";
import { TextLayerImages } from "./display/text_layer_images.js";
import { TouchManager } from "./display/touch_manager.js"; import { TouchManager } from "./display/touch_manager.js";
import { XfaLayer } from "./display/xfa_layer.js"; import { XfaLayer } from "./display/xfa_layer.js";
@ -149,6 +150,7 @@ globalThis.pdfjsLib = {
stopEvent, stopEvent,
SupportedImageMimeTypes, SupportedImageMimeTypes,
TextLayer, TextLayer,
TextLayerImages,
TouchManager, TouchManager,
updateUrlHash, updateUrlHash,
Util, Util,
@ -211,6 +213,7 @@ export {
stopEvent, stopEvent,
SupportedImageMimeTypes, SupportedImageMimeTypes,
TextLayer, TextLayer,
TextLayerImages,
TouchManager, TouchManager,
updateUrlHash, updateUrlHash,
Util, Util,

View File

@ -43,6 +43,7 @@ async function runTests(results) {
"stamp_editor_spec.mjs", "stamp_editor_spec.mjs",
"text_field_spec.mjs", "text_field_spec.mjs",
"text_layer_spec.mjs", "text_layer_spec.mjs",
"text_layer_images_spec.mjs",
"thumbnail_view_spec.mjs", "thumbnail_view_spec.mjs",
"viewer_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 () => { beforeEach(async () => {
pages = await loadAndWait( pages = await loadAndWait(
"tracemonkey.pdf", "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 () => { beforeEach(async () => {
pages = await loadAndWait( pages = await loadAndWait(
"chrome-text-selection-markedContent.pdf", "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 () => { beforeEach(async () => {
pages = await loadAndWait( pages = await loadAndWait(
"annotation-link-text-popup.pdf", "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; let pages;
beforeEach(async () => { 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 () => { afterEach(async () => {

View File

@ -873,6 +873,9 @@
!page_with_number.pdf !page_with_number.pdf
!page_with_number_and_link.pdf !page_with_number_and_link.pdf
!Brotli-Prototype-FileA.pdf !Brotli-Prototype-FileA.pdf
!images.pdf
!bug_jpx.pdf
!image-rotated-black-white-ratio.pdf
!bug2013793.pdf !bug2013793.pdf
!bug2014080.pdf !bug2014080.pdf
!two_pages.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 { isValidExplicitDest } from "../../src/display/api_utils.js";
import { SignatureExtractor } from "../../src/display/editor/drawers/signaturedraw.js"; import { SignatureExtractor } from "../../src/display/editor/drawers/signaturedraw.js";
import { TextLayer } from "../../src/display/text_layer.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 { TouchManager } from "../../src/display/touch_manager.js";
import { XfaLayer } from "../../src/display/xfa_layer.js"; import { XfaLayer } from "../../src/display/xfa_layer.js";
@ -133,6 +134,7 @@ const expectedAPI = Object.freeze({
stopEvent, stopEvent,
SupportedImageMimeTypes, SupportedImageMimeTypes,
TextLayer, TextLayer,
TextLayerImages,
TouchManager, TouchManager,
updateUrlHash, updateUrlHash,
Util, Util,

View File

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

View File

@ -323,6 +323,20 @@ const defaultOptions = {
: "./images/", : "./images/",
kind: OptionKind.VIEWER, 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: { maxCanvasPixels: {
/** @type {number} */ /** @type {number} */
value: 2 ** 25, value: 2 ** 25,

View File

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

View File

@ -30,6 +30,7 @@ import {
PixelsPerInch, PixelsPerInch,
setLayerDimensions, setLayerDimensions,
shadow, shadow,
TextLayerImages,
} from "pdfjs-lib"; } from "pdfjs-lib";
import { import {
approximateFraction, 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, * `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. * that only renders the part of the page that is close to the viewport.
* The default value is `true`. * 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 * @property {boolean} [enableOptimizedPartialRendering] - When enabled, PDF
* rendering will keep track of which areas of the page each PDF operation * rendering will keep track of which areas of the page each PDF operation
* affects. Then, when rendering a partial page (if `enableDetailCanvas` is * affects. Then, when rendering a partial page (if `enableDetailCanvas` is
@ -522,6 +526,14 @@ class PDFPageView extends BasePDFPageView {
try { try {
await this.textLayer.render({ await this.textLayer.render({
viewport: this.viewport, viewport: this.viewport,
images: this.imageCoordinates
? new TextLayerImages(
this.imagesRightClickMinSize,
this.imageCoordinates,
this.viewport,
() => this.canvas
)
: null,
}); });
} catch (ex) { } catch (ex) {
if (ex instanceof AbortException) { if (ex instanceof AbortException) {
@ -707,6 +719,7 @@ class PDFPageView extends BasePDFPageView {
this.detailView ??= new PDFPageDetailView({ this.detailView ??= new PDFPageDetailView({
pageView: this, pageView: this,
enableOptimizedPartialRendering: this.enableOptimizedPartialRendering, enableOptimizedPartialRendering: this.enableOptimizedPartialRendering,
imagesRightClickMinSize: -1,
}); });
this.detailView.update({ visibleArea }); this.detailView.update({ visibleArea });
} else if (this.detailView) { } else if (this.detailView) {
@ -993,7 +1006,7 @@ class PDFPageView extends BasePDFPageView {
return canvasWrapper; return canvasWrapper;
} }
_getRenderingContext(canvas, transform, recordOperations) { _getRenderingContext(canvas, transform, recordOperations, recordImages) {
return { return {
canvas, canvas,
transform, transform,
@ -1004,6 +1017,7 @@ class PDFPageView extends BasePDFPageView {
pageColors: this.pageColors, pageColors: this.pageColors,
isEditing: this.#isEditing, isEditing: this.#isEditing,
recordOperations, recordOperations,
recordImages,
}; };
} }
@ -1127,12 +1141,15 @@ class PDFPageView extends BasePDFPageView {
this.#hasRestrictedScaling && this.#hasRestrictedScaling &&
!this.recordedBBoxes; !this.recordedBBoxes;
const recordImages =
this.imagesRightClickMinSize !== -1 && !this.imageCoordinates;
// Rendering area // Rendering area
const transform = outputScale.scaled const transform = outputScale.scaled
? [outputScale.sx, 0, 0, outputScale.sy, 0, 0] ? [outputScale.sx, 0, 0, outputScale.sy, 0, 0]
: null; : null;
const resultPromise = this._drawCanvas( const resultPromise = this._drawCanvas(
this._getRenderingContext(canvas, transform, recordBBoxes), this._getRenderingContext(canvas, transform, recordBBoxes, recordImages),
() => { () => {
prevCanvas?.remove(); prevCanvas?.remove();
this._resetCanvas(); 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, * `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. * that only renders the part of the page that is close to the viewport.
* The default value is `true`. * 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 * @property {boolean} [enableOptimizedPartialRendering] - When enabled, PDF
* rendering will keep track of which areas of the page each PDF operation * rendering will keep track of which areas of the page each PDF operation
* affects. Then, when rendering a partial page (if `enableDetailCanvas` is * affects. Then, when rendering a partial page (if `enableDetailCanvas` is
@ -361,6 +364,7 @@ class PDFViewer {
this.enableDetailCanvas = options.enableDetailCanvas ?? true; this.enableDetailCanvas = options.enableDetailCanvas ?? true;
this.enableOptimizedPartialRendering = this.enableOptimizedPartialRendering =
options.enableOptimizedPartialRendering ?? false; options.enableOptimizedPartialRendering ?? false;
this.imagesRightClickMinSize = options.imagesRightClickMinSize ?? -1;
this.l10n = options.l10n; this.l10n = options.l10n;
if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) { if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) {
this.l10n ||= new GenericL10n(); this.l10n ||= new GenericL10n();
@ -1062,6 +1066,7 @@ class PDFViewer {
enableDetailCanvas: this.enableDetailCanvas, enableDetailCanvas: this.enableDetailCanvas,
enableOptimizedPartialRendering: enableOptimizedPartialRendering:
this.enableOptimizedPartialRendering, this.enableOptimizedPartialRendering,
imagesRightClickMinSize: this.imagesRightClickMinSize,
pageColors, pageColors,
l10n: this.l10n, l10n: this.l10n,
layerProperties: this._layerProperties, layerProperties: this._layerProperties,

View File

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

View File

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