diff --git a/src/display/canvas.js b/src/display/canvas.js index 05fdd7846..27f90fcb9 100644 --- a/src/display/canvas.js +++ b/src/display/canvas.js @@ -204,36 +204,6 @@ function mirrorContextOperations(ctx, destCtx) { }; } -class CachedCanvases { - #cache = new Map(); - - constructor(canvasFactory) { - this.canvasFactory = canvasFactory; - } - - getCanvas(id, width, height) { - let canvasEntry = this.#cache.get(id); - if (canvasEntry) { - this.canvasFactory.reset(canvasEntry, width, height); - } else { - canvasEntry = this.canvasFactory.create(width, height); - this.#cache.set(id, canvasEntry); - } - return canvasEntry; - } - - delete(id) { - this.#cache.delete(id); - } - - clear() { - for (const canvasEntry of this.#cache.values()) { - this.canvasFactory.destroy(canvasEntry); - } - this.#cache.clear(); - } -} - function drawImageAtIntegerCoords( ctx, srcImg, @@ -681,11 +651,11 @@ class CanvasGraphics { this.smaskStack = []; this.smaskCounter = 0; this.tempSMask = null; + this.smaskGroupCanvases = []; this.suspendedCtx = null; this.contentVisible = true; this.markedContentStack = markedContentStack || []; this.optionalContentConfig = optionalContentConfig; - this.cachedCanvases = new CachedCanvases(this.canvasFactory); this.cachedPatterns = new Map(); this.annotationCanvasMap = annotationCanvasMap; this.viewportScale = 1; @@ -731,14 +701,11 @@ class CanvasGraphics { this.ctx.fillStyle = savedFillStyle; if (transparency) { - const transparentCanvas = this.cachedCanvases.getCanvas( - "transparent", - width, - height - ); + const transparentCanvas = (this.transparentCanvasEntry = + this.canvasFactory.create(width, height)); this.compositeCtx = this.ctx; - this.transparentCanvas = transparentCanvas.canvas; - this.ctx = transparentCanvas.context; + ({ canvas: this.transparentCanvas, context: this.ctx } = + transparentCanvas); this.ctx.save(); // The transform can be applied before rendering, transferring it to // the new canvas. @@ -862,14 +829,26 @@ class CanvasGraphics { this.ctx.setTransform(1, 0, 0, 1, 0, 0); // Avoid apply transform twice this.ctx.drawImage(this.transparentCanvas, 0, 0); this.ctx.restore(); + this.canvasFactory.destroy(this.transparentCanvasEntry); this.transparentCanvas = null; + this.transparentCanvasEntry = null; } } endDrawing() { this.#restoreInitialState(); - this.cachedCanvases.clear(); + // Destroy all smask group canvases now that rendering is complete. + // These cannot be destroyed eagerly because activeSMask is part of + // CanvasExtraState and is shared (via Object.create prototype chain) across + // save/restore state copies. + for (const canvas of this.smaskGroupCanvases) { + this.canvasFactory.destroy(canvas); + } + this.smaskGroupCanvases.length = 0; + this.tempSMask = null; + this.smaskStack.length = 0; + this.cachedPatterns.clear(); for (const cache of this._cachedBitmapsMap.values()) { @@ -910,52 +889,80 @@ class CanvasGraphics { // displayWidth and displayHeight are used for VideoFrame. const width = img.width ?? img.displayWidth; const height = img.height ?? img.displayHeight; - let widthScale = Math.max( + const widthScale = Math.max( Math.hypot(inverseTransform[0], inverseTransform[1]), 1 ); - let heightScale = Math.max( + const heightScale = Math.max( Math.hypot(inverseTransform[2], inverseTransform[3]), 1 ); - let paintWidth = width, - paintHeight = height; - let tmpCanvasId = "prescale1"; - let tmpCanvas, tmpCtx; - while ( - (widthScale > 2 && paintWidth > 1) || - (heightScale > 2 && paintHeight > 1) - ) { - let newWidth = paintWidth, - newHeight = paintHeight; - if (widthScale > 2 && paintWidth > 1) { + // Pre-compute each step's output dimensions. + const scaleSteps = []; + let ws = widthScale, + hs = heightScale, + pw = width, + ph = height; + while ((ws > 2 && pw > 1) || (hs > 2 && ph > 1)) { + let nw = pw, + nh = ph; + if (ws > 2 && pw > 1) { // See bug 1820511 (Windows specific bug). // TODO: once the above bug is fixed we could revert to: - // newWidth = Math.ceil(paintWidth / 2); - newWidth = - paintWidth >= 16384 - ? Math.floor(paintWidth / 2) - 1 || 1 - : Math.ceil(paintWidth / 2); - widthScale /= paintWidth / newWidth; + // nw = Math.ceil(pw / 2); + nw = pw >= 16384 ? Math.floor(pw / 2) - 1 || 1 : Math.ceil(pw / 2); + ws /= pw / nw; } - if (heightScale > 2 && paintHeight > 1) { + if (hs > 2 && ph > 1) { // TODO: see the comment above. - newHeight = - paintHeight >= 16384 - ? Math.floor(paintHeight / 2) - 1 || 1 - : Math.ceil(paintHeight) / 2; - heightScale /= paintHeight / newHeight; + nh = ph >= 16384 ? Math.floor(ph / 2) - 1 || 1 : Math.ceil(ph) / 2; + hs /= ph / nh; } - tmpCanvas = this.cachedCanvases.getCanvas( - tmpCanvasId, + scaleSteps.push({ newWidth: nw, newHeight: nh }); + pw = nw; + ph = nh; + } + + if (scaleSteps.length === 0) { + return { img, paintWidth: width, paintHeight: height, tmpCanvas: null }; + } + + if (scaleSteps.length === 1) { + const { newWidth, newHeight } = scaleSteps[0]; + const tmpCanvas = this.canvasFactory.create(newWidth, newHeight); + tmpCanvas.context.drawImage( + img, + 0, + 0, + width, + height, + 0, + 0, newWidth, newHeight ); - tmpCtx = tmpCanvas.context; - tmpCtx.clearRect(0, 0, newWidth, newHeight); - tmpCtx.drawImage( - img, + return { + img: tmpCanvas.canvas, + paintWidth: newWidth, + paintHeight: newHeight, + tmpCanvas, + }; + } + + // More than 2 steps: ping-pong between two reused canvas entries. + // canvasFactory.reset() resizes (and implicitly clears) a canvas without + // creating a new JS object or calling getContext() again. + let readEntry = this.canvasFactory.create(1, 1); + let writeEntry = this.canvasFactory.create(1, 1); + let paintWidth = width, + paintHeight = height; + let source = img; + + for (const { newWidth, newHeight } of scaleSteps) { + this.canvasFactory.reset(writeEntry, newWidth, newHeight); + writeEntry.context.drawImage( + source, 0, 0, paintWidth, @@ -965,15 +972,19 @@ class CanvasGraphics { newWidth, newHeight ); - img = tmpCanvas.canvas; + [readEntry, writeEntry] = [writeEntry, readEntry]; + source = readEntry.canvas; paintWidth = newWidth; paintHeight = newHeight; - tmpCanvasId = tmpCanvasId === "prescale1" ? "prescale2" : "prescale1"; } + + // writeEntry is now the stale buffer — destroy it. + this.canvasFactory.destroy(writeEntry); return { - img, + img: readEntry.canvas, paintWidth, paintHeight, + tmpCanvas: readEntry, }; } @@ -1025,7 +1036,7 @@ class CanvasGraphics { } if (!scaled) { - maskCanvas = this.cachedCanvases.getCanvas("maskCanvas", width, height); + maskCanvas = this.canvasFactory.create(width, height); putBinaryImageMask(maskCanvas.context, img); } @@ -1048,11 +1059,7 @@ class CanvasGraphics { const [minX, minY, maxX, maxY] = minMax; const drawnWidth = Math.round(maxX - minX) || 1; const drawnHeight = Math.round(maxY - minY) || 1; - const fillCanvas = this.cachedCanvases.getCanvas( - "fillCanvas", - drawnWidth, - drawnHeight - ); + const fillCanvas = this.canvasFactory.create(drawnWidth, drawnHeight); const fillCtx = fillCanvas.context; // The offset will be the top-left coordinate mask. @@ -1064,15 +1071,24 @@ class CanvasGraphics { fillCtx.translate(-offsetX, -offsetY); fillCtx.transform(...maskToCanvas); + let scaledEntry = null; if (!scaled) { // Pre-scale if needed to improve image smoothing. - scaled = this._scaleImage( + const scaleResult = this._scaleImage( maskCanvas.canvas, getCurrentTransformInverse(fillCtx) ); - scaled = scaled.img; + scaled = scaleResult.img; + scaledEntry = scaleResult.tmpCanvas; + if (scaled !== maskCanvas.canvas) { + // _scaleImage created a new canvas; maskCanvas is no longer needed. + this.canvasFactory.destroy(maskCanvas); + maskCanvas = null; + } if (cache && isPatternFill) { cache.set(cacheKey, scaled); + scaledEntry = null; // bitmap cache owns the canvas now + maskCanvas = null; // bitmap cache may own maskCanvas.canvas (= scaled) } } @@ -1093,6 +1109,13 @@ class CanvasGraphics { width, height ); + if (scaledEntry) { + this.canvasFactory.destroy(scaledEntry); + } + if (maskCanvas) { + // scaled === maskCanvas.canvas and not owned by the bitmap cache. + this.canvasFactory.destroy(maskCanvas); + } fillCtx.globalCompositeOperation = "source-in"; const inverse = Util.transform(getCurrentTransformInverse(fillCtx), [ @@ -1111,8 +1134,7 @@ class CanvasGraphics { if (cache && !isPatternFill) { // The fill canvas is put in the cache associated to the mask image - // so we must remove from the cached canvas: it mustn't be used again. - this.cachedCanvases.delete("fillCanvas"); + // so it mustn't be used again. cache.set(cacheKey, fillCanvas.canvas); } @@ -1124,6 +1146,8 @@ class CanvasGraphics { // Round the offsets to avoid drawing fractional pixels. return { canvas: fillCanvas.canvas, + // canvasEntry is null when the bitmap cache owns the canvas. + canvasEntry: cache && !isPatternFill ? null : fillCanvas, offsetX: Math.round(offsetX), offsetY: Math.round(offsetY), }; @@ -1257,12 +1281,8 @@ class CanvasGraphics { } const drawnWidth = this.ctx.canvas.width; const drawnHeight = this.ctx.canvas.height; - const cacheId = "smaskGroupAt" + this.groupLevel; - const scratchCanvas = this.cachedCanvases.getCanvas( - cacheId, - drawnWidth, - drawnHeight - ); + const scratchCanvas = this.canvasFactory.create(drawnWidth, drawnHeight); + this.smaskScratchCanvas = scratchCanvas; this.suspendedCtx = this.ctx; const ctx = (this.ctx = scratchCanvas.context); ctx.setTransform(this.suspendedCtx.getTransform()); @@ -1283,6 +1303,8 @@ class CanvasGraphics { this.ctx = this.suspendedCtx; this.suspendedCtx = null; + this.canvasFactory.destroy(this.smaskScratchCanvas); + this.smaskScratchCanvas = null; } compose(dirtyBox) { @@ -1356,6 +1378,7 @@ class CanvasGraphics { let maskX = layerOffsetX - maskOffsetX; let maskY = layerOffsetY - maskOffsetY; + let maskExtensionEntry = null; if (backdrop) { if ( maskX < 0 || @@ -1363,19 +1386,15 @@ class CanvasGraphics { maskX + width > maskCanvas.width || maskY + height > maskCanvas.height ) { - const canvas = this.cachedCanvases.getCanvas( - "maskExtension", - width, - height - ); - const ctx = canvas.context; + maskExtensionEntry = this.canvasFactory.create(width, height); + const ctx = maskExtensionEntry.context; ctx.drawImage(maskCanvas, -maskX, -maskY); ctx.globalCompositeOperation = "destination-atop"; ctx.fillStyle = backdrop; ctx.fillRect(0, 0, width, height); ctx.globalCompositeOperation = "source-over"; - maskCanvas = canvas.canvas; + maskCanvas = maskExtensionEntry.canvas; maskX = maskY = 0; } else { maskCtx.save(); @@ -1417,6 +1436,9 @@ class CanvasGraphics { height ); layerCtx.restore(); + if (maskExtensionEntry) { + this.canvasFactory.destroy(maskExtensionEntry); + } } save(opIdx) { @@ -1992,14 +2014,12 @@ class CanvasGraphics { get isFontSubpixelAAEnabled() { // Checks if anti-aliasing is enabled when scaled text is painted. // On Windows GDI scaled fonts looks bad. - const { context: ctx } = this.cachedCanvases.getCanvas( - "isFontSubpixelAAEnabled", - 10, - 10 - ); + const tmpCanvas = this.canvasFactory.create(10, 10); + const ctx = tmpCanvas.context; ctx.scale(1.5, 1); ctx.fillText("I", 0, 10); const data = ctx.getImageData(0, 0, 10, 10).data; + this.canvasFactory.destroy(tmpCanvas); let enabled = false; for (let i = 3; i < data.length; i += 4) { if (data[i] > 0 && data[i] < 255) { @@ -2610,16 +2630,13 @@ class CanvasGraphics { this.current.startNewPathAndClipBox([0, 0, drawnWidth, drawnHeight]); - let cacheId = "groupAt" + this.groupLevel; if (group.smask) { - // Using two cache entries is case if masks are used one after another. - cacheId += "_smask_" + (this.smaskCounter++ % 2); + this.smaskCounter++; + } + const scratchCanvas = this.canvasFactory.create(drawnWidth, drawnHeight); + if (group.smask) { + this.smaskGroupCanvases.push(scratchCanvas); } - const scratchCanvas = this.cachedCanvases.getCanvas( - cacheId, - drawnWidth, - drawnHeight - ); const groupCtx = scratchCanvas.context; // Since we created a new canvas that is just the size of the bounding box @@ -2721,6 +2738,10 @@ class CanvasGraphics { ); this.ctx.drawImage(groupCtx.canvas, 0, 0); this.ctx.restore(); + this.canvasFactory.destroy({ + canvas: groupCtx.canvas, + context: groupCtx, + }); this.compose(dirtyBox); } } @@ -2837,6 +2858,9 @@ class CanvasGraphics { ) .recordOperation(opIdx); ctx.restore(); + if (mask.canvasEntry) { + this.canvasFactory.destroy(mask.canvasEntry); + } this.compose(); } @@ -2893,6 +2917,9 @@ class CanvasGraphics { ); } ctx.restore(); + if (mask.canvasEntry) { + this.canvasFactory.destroy(mask.canvasEntry); + } this.compose(); this.dependencyTracker?.recordOperation(opIdx); @@ -2914,11 +2941,7 @@ class CanvasGraphics { for (const image of images) { const { data, width, height, transform } = image; - const maskCanvas = this.cachedCanvases.getCanvas( - "maskCanvas", - width, - height - ); + const maskCanvas = this.canvasFactory.create(width, height); const maskCtx = maskCanvas.context; maskCtx.save(); @@ -2955,6 +2978,7 @@ class CanvasGraphics { 1, 1 ); + this.canvasFactory.destroy(maskCanvas); this.dependencyTracker?.recordBBox(opIdx, ctx, 0, width, 0, height); ctx.restore(); @@ -3012,20 +3036,16 @@ class CanvasGraphics { applyTransferMapsToBitmap(imgData) { if (this.current.transferMaps === "none") { - return imgData.bitmap; + return { img: imgData.bitmap, canvasEntry: null }; } const { bitmap, width, height } = imgData; - const tmpCanvas = this.cachedCanvases.getCanvas( - "inlineImage", - width, - height - ); + const tmpCanvas = this.canvasFactory.create(width, height); const tmpCtx = tmpCanvas.context; tmpCtx.filter = this.current.transferMaps; tmpCtx.drawImage(bitmap, 0, 0); tmpCtx.filter = "none"; - return tmpCanvas.canvas; + return { img: tmpCanvas.canvas, canvasEntry: tmpCanvas }; } paintInlineImageXObject(opIdx, imgData) { @@ -3051,8 +3071,11 @@ class CanvasGraphics { ctx.scale(1 / width, -1 / height); let imgToPaint; + let inlineImgCanvas = null; if (imgData.bitmap) { - imgToPaint = this.applyTransferMapsToBitmap(imgData); + const result = this.applyTransferMapsToBitmap(imgData); + imgToPaint = result.img; + inlineImgCanvas = result.canvasEntry; } else if ( (typeof HTMLElement === "function" && imgData instanceof HTMLElement) || !imgData.data @@ -3060,14 +3083,10 @@ class CanvasGraphics { // typeof check is needed due to node.js support, see issue #8489 imgToPaint = imgData; } else { - const tmpCanvas = this.cachedCanvases.getCanvas( - "inlineImage", - width, - height - ); - const tmpCtx = tmpCanvas.context; - putBinaryImageData(tmpCtx, imgData); - imgToPaint = this.applyTransferMapsToCanvas(tmpCtx); + const tmpCanvas = this.canvasFactory.create(width, height); + putBinaryImageData(tmpCanvas.context, imgData); + imgToPaint = this.applyTransferMapsToCanvas(tmpCanvas.context); + inlineImgCanvas = tmpCanvas; } const scaled = this._scaleImage( @@ -3105,6 +3124,12 @@ class CanvasGraphics { width, height ); + if (scaled.tmpCanvas) { + this.canvasFactory.destroy(scaled.tmpCanvas); + } + if (inlineImgCanvas) { + this.canvasFactory.destroy(inlineImgCanvas); + } this.compose(); this.restore(opIdx); } @@ -3115,16 +3140,17 @@ class CanvasGraphics { } const ctx = this.ctx; let imgToPaint; + let inlineImgCanvas = null; if (imgData.bitmap) { imgToPaint = imgData.bitmap; } else { const w = imgData.width; const h = imgData.height; - const tmpCanvas = this.cachedCanvases.getCanvas("inlineImage", w, h); - const tmpCtx = tmpCanvas.context; - putBinaryImageData(tmpCtx, imgData); - imgToPaint = this.applyTransferMapsToCanvas(tmpCtx); + const tmpCanvas = this.canvasFactory.create(w, h); + putBinaryImageData(tmpCanvas.context, imgData); + imgToPaint = this.applyTransferMapsToCanvas(tmpCanvas.context); + inlineImgCanvas = tmpCanvas; } this.dependencyTracker?.resetBBox(opIdx); @@ -3148,6 +3174,9 @@ class CanvasGraphics { this.dependencyTracker?.recordBBox(opIdx, ctx, 0, 1, -1, 0); ctx.restore(); } + if (inlineImgCanvas) { + this.canvasFactory.destroy(inlineImgCanvas); + } this.dependencyTracker?.recordOperation(opIdx); this.compose(); } diff --git a/src/display/canvas_factory.js b/src/display/canvas_factory.js index 988e76485..16c0e55fa 100644 --- a/src/display/canvas_factory.js +++ b/src/display/canvas_factory.js @@ -41,25 +41,25 @@ class BaseCanvasFactory { }; } - reset(canvasAndContext, width, height) { - if (!canvasAndContext.canvas) { + reset({ canvas }, width, height) { + if (!canvas) { throw new Error("Canvas is not specified"); } if (width <= 0 || height <= 0) { throw new Error("Invalid canvas size"); } - canvasAndContext.canvas.width = width; - canvasAndContext.canvas.height = height; + canvas.width = width; + canvas.height = height; } destroy(canvasAndContext) { - if (!canvasAndContext.canvas) { + const { canvas } = canvasAndContext; + if (!canvas) { throw new Error("Canvas is not specified"); } // Zeroing the width and height cause Firefox to release graphics // resources immediately, which can greatly reduce memory consumption. - canvasAndContext.canvas.width = 0; - canvasAndContext.canvas.height = 0; + canvas.width = canvas.height = 0; canvasAndContext.canvas = null; canvasAndContext.context = null; } diff --git a/src/display/pattern_helper.js b/src/display/pattern_helper.js index 6690b711b..a777a68a1 100644 --- a/src/display/pattern_helper.js +++ b/src/display/pattern_helper.js @@ -217,11 +217,7 @@ class RadialAxialShadingPattern extends BaseShadingPattern { const width = Math.ceil(ownerBBox[2] - ownerBBox[0]) || 1; const height = Math.ceil(ownerBBox[3] - ownerBBox[1]) || 1; - const tmpCanvas = owner.cachedCanvases.getCanvas( - "pattern", - width, - height - ); + const tmpCanvas = owner.canvasFactory.create(width, height); const tmpCtx = tmpCanvas.context; tmpCtx.clearRect(0, 0, tmpCtx.canvas.width, tmpCtx.canvas.height); @@ -254,6 +250,7 @@ class RadialAxialShadingPattern extends BaseShadingPattern { tmpCtx.fill(); pattern = ctx.createPattern(tmpCanvas.canvas, "no-repeat"); + owner.canvasFactory.destroy(tmpCanvas); const domMatrix = new DOMMatrix(inverse); pattern.setTransform(domMatrix); } else { @@ -448,7 +445,7 @@ class MeshShadingPattern extends BaseShadingPattern { this.matrix = null; } - _createMeshCanvas(combinedScale, backgroundColor, cachedCanvases) { + _createMeshCanvas(combinedScale, backgroundColor, canvasFactory) { // we will increase scale on some weird factor to let antialiasing take // care of "rough" edges const EXPECTED_SCALE = 1.1; @@ -489,12 +486,7 @@ class MeshShadingPattern extends BaseShadingPattern { const paddedWidth = width + BORDER_SIZE * 2; const paddedHeight = height + BORDER_SIZE * 2; - - const tmpCanvas = cachedCanvases.getCanvas( - "mesh", - paddedWidth, - paddedHeight - ); + const tmpCanvas = canvasFactory.create(paddedWidth, paddedHeight); if (isWebGPUMeshReady()) { tmpCanvas.context.drawImage( @@ -560,7 +552,7 @@ class MeshShadingPattern extends BaseShadingPattern { const temporaryPatternCanvas = this._createMeshCanvas( scale, pathType === PathType.SHADING ? null : this._background, - owner.cachedCanvases + owner.canvasFactory ); if (pathType !== PathType.SHADING) { @@ -576,7 +568,12 @@ class MeshShadingPattern extends BaseShadingPattern { ); ctx.scale(temporaryPatternCanvas.scaleX, temporaryPatternCanvas.scaleY); - return ctx.createPattern(temporaryPatternCanvas.canvas, "no-repeat"); + const pattern = ctx.createPattern( + temporaryPatternCanvas.canvas, + "no-repeat" + ); + owner.canvasFactory.destroy(temporaryPatternCanvas); + return pattern; } } @@ -704,11 +701,7 @@ class TilingPattern { combinedScaleY ); - const tmpCanvas = owner.cachedCanvases.getCanvas( - "pattern", - dimx.size, - dimy.size - ); + const tmpCanvas = owner.canvasFactory.create(dimx.size, dimy.size); const tmpCtx = tmpCanvas.context; const graphics = canvasGraphicsFactory.createCanvasGraphics(tmpCtx, opIdx); graphics.groupLevel = owner.groupLevel; @@ -775,11 +768,7 @@ class TilingPattern { const xSize = dimx2.size; const ySize = dimy2.size; - const tmpCanvas2 = owner.cachedCanvases.getCanvas( - "pattern-workaround", - xSize, - ySize - ); + const tmpCanvas2 = owner.canvasFactory.create(xSize, ySize); const tmpCtx2 = tmpCanvas2.context; const ii = redrawHorizontally ? Math.floor(width / xstep) : 0; const jj = redrawVertically ? Math.floor(height / ystep) : 0; @@ -800,8 +789,10 @@ class TilingPattern { ); } } + owner.canvasFactory.destroy(tmpCanvas); return { canvas: tmpCanvas2.canvas, + canvasEntry: tmpCanvas2, scaleX: dimx2.scale, scaleY: dimy2.scale, offsetX: x0, @@ -811,6 +802,7 @@ class TilingPattern { return { canvas: tmpCanvas.canvas, + canvasEntry: tmpCanvas, scaleX: dimx.scale, scaleY: dimy.scale, offsetX: x0, @@ -894,6 +886,7 @@ class TilingPattern { ); const pattern = ctx.createPattern(temporaryPatternCanvas.canvas, "repeat"); + owner.canvasFactory.destroy(temporaryPatternCanvas.canvasEntry); pattern.setTransform(domMatrix); return pattern;