diff --git a/src/display/api.js b/src/display/api.js index 9ffb982f0a512..1df6f8f4b63c3 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -37,6 +37,10 @@ import { PrintAnnotationStorage, SerializableEmpty, } from "./annotation_storage.js"; +import { + CanvasDependencyTracker, + CanvasImagesTracker, +} from "./canvas_dependency_tracker.js"; import { deprecated, isDataScheme, @@ -67,7 +71,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"; @@ -1339,6 +1342,7 @@ class PDFPageProxy { this._intentStates = new Map(); this.destroyed = false; this.recordedBBoxes = null; + this.imageCoordinates = null; } /** @@ -1470,6 +1474,7 @@ class PDFPageProxy { pageColors = null, printAnnotationStorage = null, isEditing = false, + recordImages = true, recordOperations = false, operationsFilter = null, }) { @@ -1524,6 +1529,7 @@ class PDFPageProxy { const shouldRecordOperations = !this.recordedBBoxes && (recordOperations || recordForDebugger); + const shouldRecordImages = !this.imageCoordinates && recordImages; const complete = error => { intentState.renderTasks.delete(internalRenderTask); @@ -1543,6 +1549,10 @@ class PDFPageProxy { } } + if (shouldRecordImages) { + this.imageCoordinates = internalRenderTask.gfx?.imagesTracker.take(); + } + // Attempt to reduce memory usage during *printing*, by always running // cleanup immediately once rendering has finished. if (intentPrint) { @@ -1577,12 +1587,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, @@ -1758,6 +1772,10 @@ class PDFPageProxy { }); } + getImagesCoordinates() { + return this._transport.getImagesCoordinates(this._pageIndex); + } + /** * @returns {Promise} A promise that is resolved with a * {@link StructTreeNode} object that represents the page's structure tree, @@ -3067,6 +3085,12 @@ class WorkerTransport { }); } + getImagesCoordinates(pageIndex) { + return this.messageHandler.sendWithPromise("GetImagesCoordinates", { + pageIndex, + }); + } + getStructTree(pageIndex) { return this.messageHandler.sendWithPromise("GetStructTree", { pageIndex: this.#pagesMapper.getPageId(pageIndex + 1) - 1, @@ -3215,6 +3239,10 @@ class RenderTask { (separateAnnots.canvas && annotationCanvasMap?.size > 0) ); } + + get imageCoordinates() { + return this._internalRenderTask.imageCoordinates || null; + } } /** @@ -3272,6 +3300,7 @@ class InternalRenderTask { this._canvasContext = params.canvas ? null : params.canvasContext; this._enableHWA = enableHWA; this._dependencyTracker = params.dependencyTracker; + this._imagesTracker = params.imagesTracker; this._operationsFilter = operationsFilter; } @@ -3302,7 +3331,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. @@ -3322,7 +3357,8 @@ class InternalRenderTask { { optionalContentConfig }, this.annotationCanvasMap, this.pageColors, - dependencyTracker + dependencyTracker, + imagesTracker ); this.gfx.beginDrawing({ transform, diff --git a/src/display/canvas.js b/src/display/canvas.js index b364a2910e297..47b98b5e83fe3 100644 --- a/src/display/canvas.js +++ b/src/display/canvas.js @@ -657,7 +657,8 @@ class CanvasGraphics { { optionalContentConfig, markedContentStack = null }, annotationCanvasMap, pageColors, - dependencyTracker + dependencyTracker, + imagesTracker ) { this.ctx = canvasCtx; this.current = new CanvasExtraState( @@ -699,6 +700,7 @@ class CanvasGraphics { this._cachedBitmapsMap = new Map(); this.dependencyTracker = dependencyTracker ?? null; + this.imagesTracker = imagesTracker ?? null; } getObject(opIdx, data, fallback = null) { @@ -3068,11 +3070,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, diff --git a/src/display/canvas_dependency_tracker.js b/src/display/canvas_dependency_tracker.js index 0d391c40655de..08175948bd3e3 100644 --- a/src/display/canvas_dependency_tracker.js +++ b/src/display/canvas_dependency_tracker.js @@ -1,4 +1,4 @@ -import { Util } from "../shared/util.js"; +import { FeatureTest, Util } from "../shared/util.js"; const FORCED_DEPENDENCY_LABEL = "__forcedDependency"; @@ -130,6 +130,10 @@ class CanvasDependencyTracker { } } + get clipBox() { + return this.#clipBox; + } + growOperationsCount(operationsCount) { if (operationsCount >= this.#bboxes.length) { this.#initializeBBoxes(operationsCount, this.#bboxes); @@ -635,6 +639,10 @@ class CanvasNestedDependencyTracker { this.#ignoreBBoxes = !!ignoreBBoxes; } + get clipBox() { + return this.#dependencyTracker.clipBox; + } + growOperationsCount() { throw new Error("Unreachable"); } @@ -909,4 +917,141 @@ const Dependencies = { transformAndFill: ["transform", "fillColor"], }; -export { CanvasDependencyTracker, CanvasNestedDependencyTracker, Dependencies }; +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) { + 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, +}; diff --git a/src/display/text_layer.js b/src/display/text_layer.js index a1e9e82abe298..503613b8806b6 100644 --- a/src/display/text_layer.js +++ b/src/display/text_layer.js @@ -34,6 +34,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 +58,8 @@ class TextLayer { #fontInspectorEnabled = !!globalThis.FontInspector?.enabled; + #imagesHandler = null; + #lang = null; #layoutTextParams = null; @@ -97,7 +101,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 +119,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 +187,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) { diff --git a/src/display/text_layer_images.js b/src/display/text_layer_images.js new file mode 100644 index 0000000000000..a20261392014a --- /dev/null +++ b/src/display/text_layer_images.js @@ -0,0 +1,133 @@ +import { Util } from "../shared/util.js"; + +function percentage(value) { + return `${(value * 100).toFixed(2)}%`; +} + +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; + } + + if (TextLayerImages.#activeImage === imgElement) { + return; + } + if (TextLayerImages.#activeImage) { + TextLayerImages.#activeImage.width = 0; + TextLayerImages.#activeImage.height = 0; + } + TextLayerImages.#activeImage = imgElement; + + const { inverseTransform, x1, y1, width, height } = coords; + + const pageCanvas = this.#getPageCanvas(); + + const widthRatio = pageCanvas.width / this.#pageWidth; + const heightRatio = pageCanvas.height / this.#pageHeight; + + imgElement.width = width * widthRatio; + imgElement.height = height * heightRatio; + const ctx = imgElement.getContext("2d"); + ctx.setTransform(...inverseTransform); + ctx.translate(-x1 * pageCanvas.width, -y1 * pageCanvas.height); + 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 }; diff --git a/src/pdf.js b/src/pdf.js index c5fd11f9f9070..5a9a21300f687 100644 --- a/src/pdf.js +++ b/src/pdf.js @@ -84,6 +84,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"; @@ -145,6 +146,7 @@ globalThis.pdfjsLib = { stopEvent, SupportedImageMimeTypes, TextLayer, + TextLayerImages, TouchManager, updateUrlHash, Util, @@ -205,6 +207,7 @@ export { stopEvent, SupportedImageMimeTypes, TextLayer, + TextLayerImages, TouchManager, updateUrlHash, Util, diff --git a/test/integration/jasmine-boot.js b/test/integration/jasmine-boot.js index 44cb39bdb862c..af71a743de3c6 100644 --- a/test/integration/jasmine-boot.js +++ b/test/integration/jasmine-boot.js @@ -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", ], diff --git a/test/integration/text_layer_images_spec.mjs b/test/integration/text_layer_images_spec.mjs new file mode 100644 index 0000000000000..17db4f4c67fe8 --- /dev/null +++ b/test/integration/text_layer_images_spec.mjs @@ -0,0 +1,159 @@ +/* Copyright 2024 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", () => { + describe("Images", () => { + let pages; + + beforeEach(async () => { + pages = await loadAndWait( + "images.pdf", + `.page[data-page-number = "1"] .endOfContent`, + undefined, + undefined, + { enableOptimizedPartialRendering: true } + ); + }); + + 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); + }) + ); + }); + + 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); + }) + ); + }); + }); +}); diff --git a/test/pdfs/.gitignore b/test/pdfs/.gitignore index f95ab95a7065b..ab6811476d4c8 100644 --- a/test/pdfs/.gitignore +++ b/test/pdfs/.gitignore @@ -871,3 +871,4 @@ !page_with_number.pdf !page_with_number_and_link.pdf !Brotli-Prototype-FileA.pdf +!images.pdf diff --git a/test/pdfs/images.pdf b/test/pdfs/images.pdf new file mode 100644 index 0000000000000..0e051eff740d1 Binary files /dev/null and b/test/pdfs/images.pdf differ diff --git a/test/unit/pdf_spec.js b/test/unit/pdf_spec.js index 1a3b32581c2f7..bab8bcc64bde9 100644 --- a/test/unit/pdf_spec.js +++ b/test/unit/pdf_spec.js @@ -74,6 +74,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"; @@ -129,6 +130,7 @@ const expectedAPI = Object.freeze({ stopEvent, SupportedImageMimeTypes, TextLayer, + TextLayerImages, TouchManager, updateUrlHash, Util, diff --git a/test/webserver.mjs b/test/webserver.mjs index 52511ed637ce2..24b34ab5e1b22 100644 --- a/test/webserver.mjs +++ b/test/webserver.mjs @@ -46,7 +46,7 @@ class WebServer { constructor({ root, host, port, cacheExpirationTime }) { const cwdURL = pathToFileURL(process.cwd()) + "/"; this.rootURL = new URL(`${root || "."}/`, cwdURL); - this.host = host || "localhost"; + this.host = host; // || "localhost"; this.port = port || 0; this.server = null; this.verbose = false; diff --git a/web/app.js b/web/app.js index 6da406881946f..0354494a422c4 100644 --- a/web/app.js +++ b/web/app.js @@ -578,6 +578,7 @@ const PDFViewerApplication = { enableOptimizedPartialRendering: AppOptions.get( "enableOptimizedPartialRendering" ), + imagesRightClickMinSize: AppOptions.get("imagesRightClickMinSize"), pageColors, mlManager, abortSignal, diff --git a/web/app_options.js b/web/app_options.js index 5101be5e5bb17..f58c51b245e87 100644 --- a/web/app_options.js +++ b/web/app_options.js @@ -245,6 +245,12 @@ const defaultOptions = { value: typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING"), kind: OptionKind.VIEWER + OptionKind.PREFERENCE, }, + imagesRightClickMinSize: { + /** @type {number} */ + value: + typeof PDFJSDev !== "undefined" && PDFJSDev.test("GECKOVIEW") ? -1 : 16, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE, + }, enableNewAltTextWhenAddingImage: { /** @type {boolean} */ value: true, diff --git a/web/base_pdf_page_view.js b/web/base_pdf_page_view.js index c37b5e4e22c51..63855421dee8e 100644 --- a/web/base_pdf_page_view.js +++ b/web/base_pdf_page_view.js @@ -38,10 +38,14 @@ class BasePDFPageView extends RenderableView { enableOptimizedPartialRendering = false; + imagesRightClickMinSize = -1; + eventBus = null; id = null; + imageCoordinates = null; + pageColors = null; recordedBBoxes = null; @@ -56,6 +60,7 @@ class BasePDFPageView extends RenderableView { this.renderingQueue = options.renderingQueue; this.enableOptimizedPartialRendering = options.enableOptimizedPartialRendering ?? false; + this.imagesRightClickMinSize = options.imagesRightClickMinSize ?? -1; this.#minDurationToUpdateCanvas = options.minDurationToUpdateCanvas ?? 500; } @@ -234,6 +239,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; diff --git a/web/pdf_page_view.js b/web/pdf_page_view.js index 37e888ffb6f91..92365788dc17a 100644 --- a/web/pdf_page_view.js +++ b/web/pdf_page_view.js @@ -29,6 +29,7 @@ import { PixelsPerInch, setLayerDimensions, shadow, + TextLayerImages, } from "pdfjs-lib"; import { approximateFraction, @@ -88,6 +89,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 @@ -479,6 +483,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) { @@ -664,6 +676,7 @@ class PDFPageView extends BasePDFPageView { this.detailView ??= new PDFPageDetailView({ pageView: this, enableOptimizedPartialRendering: this.enableOptimizedPartialRendering, + imagesRightClickMinSize: false, }); this.detailView.update({ visibleArea }); } else if (this.detailView) { @@ -950,7 +963,7 @@ class PDFPageView extends BasePDFPageView { return canvasWrapper; } - _getRenderingContext(canvas, transform, recordOperations) { + _getRenderingContext(canvas, transform, recordOperations, recordImages) { return { canvas, transform, @@ -961,6 +974,7 @@ class PDFPageView extends BasePDFPageView { pageColors: this.pageColors, isEditing: this.#isEditing, recordOperations, + recordImages, }; } @@ -1084,12 +1098,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(); diff --git a/web/pdf_viewer.js b/web/pdf_viewer.js index 1d97482d13cdb..f0c44c5f20c97 100644 --- a/web/pdf_viewer.js +++ b/web/pdf_viewer.js @@ -128,6 +128,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 @@ -358,6 +361,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(); @@ -1056,6 +1060,7 @@ class PDFViewer { enableDetailCanvas: this.enableDetailCanvas, enableOptimizedPartialRendering: this.enableOptimizedPartialRendering, + imagesRightClickMinSize: this.imagesRightClickMinSize, pageColors, l10n: this.l10n, layerProperties: this._layerProperties, diff --git a/web/pdfjs.js b/web/pdfjs.js index f85e5bcede613..5b60e39347581 100644 --- a/web/pdfjs.js +++ b/web/pdfjs.js @@ -65,6 +65,7 @@ const { stopEvent, SupportedImageMimeTypes, TextLayer, + TextLayerImages, TouchManager, updateUrlHash, Util, @@ -125,6 +126,7 @@ export { stopEvent, SupportedImageMimeTypes, TextLayer, + TextLayerImages, TouchManager, updateUrlHash, Util, diff --git a/web/text_layer_builder.css b/web/text_layer_builder.css index a2eb5ad667c19..080ea827d628c 100644 --- a/web/text_layer_builder.css +++ b/web/text_layer_builder.css @@ -142,3 +142,13 @@ top: 0; } } + +.textLayerImages { + position: absolute; + inset: 0; + + canvas { + position: absolute; + transform-origin: 0% 0%; + } +} diff --git a/web/text_layer_builder.js b/web/text_layer_builder.js index 184c244a3d90a..fd84c1a229f85 100644 --- a/web/text_layer_builder.js +++ b/web/text_layer_builder.js @@ -83,7 +83,7 @@ class TextLayerBuilder { * @param {TextLayerBuilderRenderOptions} options * @returns {Promise} */ - async render({ viewport, textContentParams = null }) { + async render({ viewport, images, textContentParams = null }) { if (this.#renderingDone && this.#textLayer) { this.#textLayer.update({ viewport, @@ -101,6 +101,7 @@ class TextLayerBuilder { disableNormalization: true, } ), + images, container: this.div, viewport, });