mirror of
https://github.com/mozilla/pdf.js.git
synced 2026-07-03 21:55:49 +02:00
Add support for right-clicking on images
This patch adds right-click support for images in the PDF, allowing users to download them. To minimize memory consumption, we: - Do not store the images separately, and instead crop them out of the PDF page canvas - Only extract the images when needed (i.e. when the user right-clicks on them), rather than eagery having all of them available. To do so, we layer one empty 0x0 canvas per image, stretched to cover the whole image, and only populate its contents on right click. These images need to be inside the text layer: they cannot be _behind_ it, otherwise they would be covered by the text layer's container and not be clickable, and they cannot be in front of it, otherwise they would make the text spans unselectable. This feature is managed by a new preference, `imagesRightClickMinSize`: - when it's set to `-1`, right-click support is disabled - when set to `0`, all images are available for right click - when set to a positive integer, only images whose width and height are greater than or equal to that value (in the PDF page frame of reference) are available for right click. This features is disabled by default outside of MOZCENTRAL, as it significantly degrades the text selection experience in non-Firefox browsers.
This commit is contained in:
parent
a4fcd830cc
commit
886c90d1a5
@ -38,6 +38,10 @@ import {
|
||||
PrintAnnotationStorage,
|
||||
SerializableEmpty,
|
||||
} from "./annotation_storage.js";
|
||||
import {
|
||||
CanvasDependencyTracker,
|
||||
CanvasImagesTracker,
|
||||
} from "./canvas_dependency_tracker.js";
|
||||
import {
|
||||
deprecated,
|
||||
isDataScheme,
|
||||
@ -68,7 +72,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";
|
||||
@ -1269,6 +1272,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
|
||||
@ -1353,6 +1357,7 @@ class PDFPageProxy {
|
||||
this.destroyed = false;
|
||||
this.recordedBBoxes = null;
|
||||
this.#pagesMapper = pagesMapper;
|
||||
this.imageCoordinates = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1484,6 +1489,7 @@ class PDFPageProxy {
|
||||
pageColors = null,
|
||||
printAnnotationStorage = null,
|
||||
isEditing = false,
|
||||
recordImages = false,
|
||||
recordOperations = false,
|
||||
operationsFilter = null,
|
||||
}) {
|
||||
@ -1536,6 +1542,7 @@ class PDFPageProxy {
|
||||
|
||||
const shouldRecordOperations =
|
||||
!this.recordedBBoxes && (recordOperations || recordForDebugger);
|
||||
const shouldRecordImages = !this.imageCoordinates && recordImages;
|
||||
|
||||
const complete = error => {
|
||||
intentState.renderTasks.delete(internalRenderTask);
|
||||
@ -1555,6 +1562,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) {
|
||||
@ -1589,12 +1600,16 @@ class PDFPageProxy {
|
||||
params: {
|
||||
canvas,
|
||||
canvasContext,
|
||||
dependencyTracker: shouldRecordOperations
|
||||
? new CanvasDependencyTracker(
|
||||
canvas,
|
||||
intentState.operatorList.length,
|
||||
recordForDebugger
|
||||
)
|
||||
dependencyTracker:
|
||||
shouldRecordOperations || shouldRecordImages
|
||||
? new CanvasDependencyTracker(
|
||||
canvas,
|
||||
intentState.operatorList.length,
|
||||
recordForDebugger
|
||||
)
|
||||
: null,
|
||||
imagesTracker: shouldRecordImages
|
||||
? new CanvasImagesTracker(canvas)
|
||||
: null,
|
||||
viewport,
|
||||
transform,
|
||||
@ -3255,6 +3270,10 @@ class RenderTask {
|
||||
(separateAnnots.canvas && annotationCanvasMap?.size > 0)
|
||||
);
|
||||
}
|
||||
|
||||
get imageCoordinates() {
|
||||
return this._internalRenderTask.imageCoordinates || null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -3312,6 +3331,7 @@ class InternalRenderTask {
|
||||
this._canvasContext = params.canvas ? null : params.canvasContext;
|
||||
this._enableHWA = enableHWA;
|
||||
this._dependencyTracker = params.dependencyTracker;
|
||||
this._imagesTracker = params.imagesTracker;
|
||||
this._operationsFilter = operationsFilter;
|
||||
}
|
||||
|
||||
@ -3342,7 +3362,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.
|
||||
@ -3362,7 +3388,8 @@ class InternalRenderTask {
|
||||
{ optionalContentConfig },
|
||||
this.annotationCanvasMap,
|
||||
this.pageColors,
|
||||
dependencyTracker
|
||||
dependencyTracker,
|
||||
imagesTracker
|
||||
);
|
||||
this.gfx.beginDrawing({
|
||||
transform,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -139,6 +139,10 @@ class CanvasDependencyTracker {
|
||||
}
|
||||
}
|
||||
|
||||
get clipBox() {
|
||||
return this.#clipBox;
|
||||
}
|
||||
|
||||
growOperationsCount(operationsCount) {
|
||||
if (operationsCount >= this.#bboxes.length) {
|
||||
this.#initializeBBoxes(operationsCount, this.#bboxes);
|
||||
@ -644,6 +648,10 @@ class CanvasNestedDependencyTracker {
|
||||
this.#ignoreBBoxes = !!ignoreBBoxes;
|
||||
}
|
||||
|
||||
get clipBox() {
|
||||
return this.#dependencyTracker.clipBox;
|
||||
}
|
||||
|
||||
growOperationsCount() {
|
||||
throw new Error("Unreachable");
|
||||
}
|
||||
@ -918,4 +926,155 @@ 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 {
|
||||
CanvasDependencyTracker,
|
||||
CanvasImagesTracker,
|
||||
CanvasNestedDependencyTracker,
|
||||
Dependencies,
|
||||
};
|
||||
|
||||
@ -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) {
|
||||
|
||||
160
src/display/text_layer_images.js
Normal file
160
src/display/text_layer_images.js
Normal 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 };
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
],
|
||||
|
||||
289
test/integration/text_layer_images_spec.mjs
Normal file
289
test/integration/text_layer_images_spec.mjs
Normal 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);
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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 () => {
|
||||
|
||||
3
test/pdfs/.gitignore
vendored
3
test/pdfs/.gitignore
vendored
@ -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
BIN
test/pdfs/bug_jpx.pdf
Normal file
Binary file not shown.
BIN
test/pdfs/image-rotated-black-white-ratio.pdf
Normal file
BIN
test/pdfs/image-rotated-black-white-ratio.pdf
Normal file
Binary file not shown.
BIN
test/pdfs/images.pdf
Normal file
BIN
test/pdfs/images.pdf
Normal file
Binary file not shown.
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
@ -357,6 +360,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();
|
||||
@ -1058,6 +1062,7 @@ class PDFViewer {
|
||||
enableDetailCanvas: this.enableDetailCanvas,
|
||||
enableOptimizedPartialRendering:
|
||||
this.enableOptimizedPartialRendering,
|
||||
imagesRightClickMinSize: this.imagesRightClickMinSize,
|
||||
pageColors,
|
||||
l10n: this.l10n,
|
||||
layerProperties: this._layerProperties,
|
||||
|
||||
@ -67,6 +67,7 @@ const {
|
||||
stopEvent,
|
||||
SupportedImageMimeTypes,
|
||||
TextLayer,
|
||||
TextLayerImages,
|
||||
TouchManager,
|
||||
updateUrlHash,
|
||||
Util,
|
||||
@ -129,6 +130,7 @@ export {
|
||||
stopEvent,
|
||||
SupportedImageMimeTypes,
|
||||
TextLayer,
|
||||
TextLayerImages,
|
||||
TouchManager,
|
||||
updateUrlHash,
|
||||
Util,
|
||||
|
||||
@ -142,3 +142,14 @@
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.textLayerImages {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
user-select: none;
|
||||
|
||||
canvas {
|
||||
position: absolute;
|
||||
transform-origin: 0% 0%;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user