From c0e39773215bf79cf297bab1f916efc9f9cea66b Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Tue, 24 Mar 2026 14:49:30 +0100 Subject: [PATCH] Remove the canvases cache The cache has been added in #3312 in 2013 and a lot of things changed since. Having too many cached accelerated canvases can lead to have to move their data from the GPU to the RAM which is costly. So this patch: - removes all the cached canvases; - destroys the useless canvases in order to free their associated memory asap; - slightly rewrite canvas.js::_scaleImage to avoid too much canvas creation. --- src/display/canvas.js | 297 +++++++++++++++++++--------------- src/display/canvas_factory.js | 14 +- src/display/pattern_helper.js | 41 ++--- 3 files changed, 187 insertions(+), 165 deletions(-) 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;